Compare commits

...

41 Commits

Author SHA1 Message Date
gitea-actions
c061a5f2e7 chore: bump version to v0.1.30
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 20s
2026-04-15 08:31:11 +00:00
Matthieu
46fa7d17ae chore(core) : merge RBAC ticket #343 + fix user:write sensibles (PR #2)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-15 10:30:59 +02:00
Matthieu
0fc4e1651b fix(core) : retire user:write des champs RBAC sensibles du User
isAdmin, roles et directPermissions ne doivent pas etre modifiables via
PATCH /api/users/{id}. L exposition en ecriture sera traitee par un
processor dedie dans le ticket #344 (spec section 2 OUT).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 08:15:43 +02:00
Matthieu
d8bda517f9 docs : ajoute note delegation Codex pour taches mecaniques 2026-04-15 08:12:17 +02:00
Matthieu
7ccc913862 docs : exception CLAUDE.md pour les migrations multi-namespace
Documente le bug Doctrine Migrations 3.x (tri par FQCN au lieu de
version timestamp avec plusieurs migrations_paths) et la regle
provisoire : migrations d'init au namespace racine, namespace
modulaire reserve aux migrations applicatives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:25:26 +02:00
Matthieu
eb0b49a7ef fix(core) : RBAC migration deplacee vers le namespace DoctrineMigrations racine
Bug decouvert a l'execution de 'make db-reset' sur base vide : Doctrine
Migrations 3.x avec plusieurs 'migrations_paths' execute les migrations
dans l'ordre (namespace, version) et non (version, namespace). Le
Version20260414150034 sous 'App\Module\Core\...' passait donc avant
Version20260407095546 sous 'DoctrineMigrations', provoquant un
"relation user does not exist".

Deplacement du fichier vers 'migrations/' (namespace DoctrineMigrations).
Le chemin modulaire reste configure pour les futurs modules, mais
la migration RBAC d'initialisation vit a la racine pour que
'make db-reset' fonctionne en one-shot.

Smoke test end-to-end valide :
- db-reset + fixtures : admin (is_admin=t, role admin), alice/bob
  (is_admin=f, role user)
- app:sync-permissions : 4 permissions Core ajoutees, idempotent au 2e run
- User::getRoles() : ['ROLE_USER', 'ROLE_ADMIN'] pour admin, ['ROLE_USER']
  pour alice/bob
- User::getEffectivePermissions() : union triee des permissions via roles

Ticket #343 - 7/7 : smoke test end-to-end OK.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:21:43 +02:00
Matthieu
0a496f34e0 fix(core) : RBAC Task 6 polish - descriptions des roles systeme coherentes
ensureSystemRole() recopie desormais la description depuis la migration
RBAC pour que les chemins prod (migration) et dev (fixtures) produisent
un etat identique.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:15:23 +02:00
Matthieu
aafe08b6ad feat(core) : RBAC Task 6 - fixtures et CreateUserCommand branches sur les roles systeme
- AppFixtures : rattachement des users aux entites Role via
  RoleRepositoryInterface. Re-seed idempotent des roles systeme dans
  ensureSystemRole() pour compenser le purger Doctrine qui vide la table
  role avant load(), afin que "make db-reset && make fixtures" reste un
  workflow one-shot.
- CreateUserCommand : flag --admin attache au role systeme admin + is_admin,
  sinon au role user. Gestion d'erreur explicite si les roles systeme sont
  absents (FAILURE + message pointant vers la migration).
- CreateUserCommand devient final, descriptions traduites en francais.

Ticket #343 - 6/7 : fixtures et command alignes sur le RBAC relationnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:12:09 +02:00
Matthieu
d68aa0456a feat(core) : RBAC Task 5 - migration Doctrine RBAC + data-migration JSON roles
- Nouvelles tables permission, role, role_permission, user_role, user_permission
- Ajout user.is_admin (BOOLEAN, default false)
- Seed des roles systeme admin et user via SQL brut (autonome, pas besoin
  de fixtures pour cette etape)
- Migration des donnees : is_admin reflete ROLE_ADMIN du JSON roles, puis
  rattachement user_role selon admin/user
- Drop user.roles en dernier (apres la migration de donnees)
- down() recree la colonne roles et la rehydrate depuis is_admin

Ticket #343 - 5/7 : persistance + migration donnees safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:02:26 +02:00
Matthieu
3b1f18b0e0 feat(core) : RBAC Task 4 - CoreModule::permissions() + SyncPermissionsCommand
- CoreModule declare 4 permissions initiales (users.view/manage, roles.manage,
  permissions.view)
- Nouvelle commande app:sync-permissions :
  * scan des *Module::permissions() via config/modules.php
  * validation stricte : cles [code, label], prefixe module, non-vides
  * upsert transactionnel non-destructif
  * revival des permissions orphelines qui reapparaissent
  * marquage orphan pour les permissions disparues du code
  * un seul flush() final (evite le flush-par-save de la repo save())

Ticket #343 - 4/7 : scanner et synchroniseur de permissions RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:56:50 +02:00
Matthieu
7aa32b1972 feat(core) : RBAC Task 3 - mutation User (isAdmin + roles RBAC + permissions directes)
- Suppression de la colonne JSON roles (persiste jusqu'a la migration Task 5)
- Ajout is_admin bool (seul levier de bypass RBAC via getRoles())
- Ajout ManyToMany User-Role (EAGER, table user_role)
- Ajout ManyToMany User-Permission directes (EAGER, table user_permission)
- getEffectivePermissions() : union dedupliquee triee, utilisee par le
  futur PermissionVoter (#345)
- getRbacRoles() pour ne pas shadow getRoles() de UserInterface Symfony
- Tests unitaires couvrant derivation getRoles, union, deduplication, tri

Ticket #343 - 3/7 : migration du User vers le modele RBAC relationnel.
Fetch EAGER documente : evite le lazy-load au refresh JWT.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:48:49 +02:00
Matthieu
3b34d00872 feat(core) : RBAC Task 2 - repositories Permission et Role
- PermissionRepositoryInterface avec findByCode et findAllCodes (pour le sync
  command et le futur PermissionVoter)
- RoleRepositoryInterface avec findByCode
- Implementations Doctrine alignees sur DoctrineUserRepository
- Alias DI dans config/services.yaml
- Rebranchement de repositoryClass sur les entites Permission et Role

Ticket #343 - 2/7 : couche persistence RBAC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:40:44 +02:00
Matthieu
0fc0b57e37 refactor(core) : RBAC Task 1 - polish apres revue qualite
- Permission : guards constructeur (code/label/module non vides, code avec point)
- Permission::revive() reutilise updateMetadata() pour eviter la duplication
- Suppression de SystemRolesTest (tautologique, ne capture aucun comportement)
- Role::permissions : commentaire explicite sur la raison du fetch EAGER
- Alignement des types de retour sur static (style User.php)
- Nouveau test Role::addPermission avec permissions distinctes

Ticket #343 - Task 1 polish (revue qualite).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:53 +02:00
Matthieu
f0ea9201f5 feat(core) : RBAC Task 1 - entites Permission et Role + domaine securite
- Entite Permission avec methodes markOrphan/revive/updateMetadata
- Entite Role avec addPermission/removePermission/ensureDeletable
- Constantes SystemRoles (codes admin/user partages)
- Exception SystemRoleDeletionException pour la garde de suppression
- Tests unitaires couvrant le comportement domaine (pas de BDD)

Ticket #343 - 1/7 : fondations RBAC (domaine pur, sans persistence).
Les entites ne portent pas encore repositoryClass (ajoute en Task 2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:30:15 +02:00
Matthieu
e3025bf2c9 docs(rbac) : plan et spec ticket #343 + conventions permissions
- Spec detaillee des fondations RBAC backend (entites Role/Permission, sync
  command, migration, fixtures, tests) dans docs/rbac/ticket-343-spec.md
- Ajout CLAUDE.md des regles projet : commentaires francais (PHP + TS/Vue)
  et convention de nommage des permissions module.resource[.sub].action

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:26:49 +02:00
gitea-actions
b59d0f8a44 chore: bump version to v0.1.29
Some checks failed
Build & Push Docker Image / build (push) Failing after 16s
Auto Tag Develop / tag (push) Successful in 5s
2026-04-14 13:12:49 +00:00
Matthieu
5cb8cff4ce Merge branch 'feature/ERP-7-mise-en-place-du-modular-monolith' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
# Conflicts:
#	docker-compose.yml
2026-04-14 15:11:59 +02:00
gitea-actions
c62f054da1 chore: bump version to v0.1.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 53s
2026-04-14 13:07:45 +00:00
Matthieu
168dad4657 feat(infra) : add logs volume to prod docker-compose
Persist var/log/ via named volume coltura_logs so logs survive
container restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:28:09 +02:00
Matthieu
68bdb6ff72 docs : add code review report for PR #1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:16:33 +02:00
Matthieu
7045debc66 feat : add ESLint linter to frontend with pre-commit hook
Add ESLint with @nuxt/eslint-config enforcing 4-space indentation.
Add make nuxt-lint and nuxt-lint-fix targets.
Add ESLint check to pre-commit hook (lint only, no auto-fix).
Fix auth.vue indentation from 2 to 4 spaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:16:25 +02:00
Matthieu
180bc5c556 fix : fix UserOutput type and use UserRepositoryInterface in CreateUserCommand
Change UserOutput.id from int to ?int to match User::getId() return type.
Replace EntityManagerInterface with UserRepositoryInterface in CreateUserCommand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:16:14 +02:00
Matthieu
999cccabaf fix : reset sidebar state on logout
Add resetSidebar() to useSidebar composable and call it on logout
to prevent stale sidebar data after re-login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:16:03 +02:00
Matthieu
d42311f22f docs : update ports and fix CHANGELOG project name
Update CLAUDE.md to reflect actual ports (PG 5437, frontend 3004).
Fix CHANGELOG.md header from "Ferme" to "Coltura".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:15:53 +02:00
Matthieu
be57451d29 fix : change frontend dev port from 3003 to 3004 to avoid conflicts
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:25:17 +02:00
8ebdf56435 feat : mise à jour du CHANGELOG.md 2026-04-09 11:04:18 +02:00
68d62c31ec feat : mise à jour de la structure du projet 2026-04-09 11:02:19 +02:00
gitea-actions
bcfecb2281 chore: bump version to v0.1.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 17s
2026-04-07 13:33:46 +00:00
Matthieu
90147bd93b fix(infra) : fix public dir permissions in deploy.sh
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:33:39 +02:00
gitea-actions
4d106e9625 chore: bump version to v0.1.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-07 13:29:50 +00:00
Matthieu
9748862684 fix(infra) : add deploy.sh with maintenance mode like Inventory
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Maintenance is handled by nginx-proxy on the host, not inside the
container. deploy.sh extracts maintenance.html from the container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:29:42 +02:00
gitea-actions
1904c999ec chore: bump version to v0.1.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-07 13:25:29 +00:00
Matthieu
81266dd64b fix(infra) : update proxy port to 8086 and add maintenance mode
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:25:23 +02:00
gitea-actions
c5e2800e4c chore: bump version to v0.1.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-07 13:09:32 +00:00
Matthieu
ef1c14f8da feat : add app:create-user console command
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:09:23 +02:00
gitea-actions
7e5080859d chore: bump version to v0.1.23
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 2m16s
2026-04-07 12:59:29 +00:00
Matthieu
414916a20d fix(ci) : pin node:22-alpine instead of lts (now node 24 / npm 11)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:59:19 +02:00
gitea-actions
70c05946bd chore: bump version to v0.1.22
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 18s
2026-04-07 12:56:03 +00:00
Matthieu
ede55b9f08 fix(ci) : regenerate package-lock.json for npm ci compatibility
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:55:54 +02:00
gitea-actions
c61b24bea3 chore: bump version to v0.1.21
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 10s
2026-04-07 12:53:26 +00:00
Matthieu
389bfbef13 refactor(infra) : align prod setup with Lesstime pattern
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Single container with supervisord (Nginx + PHP-FPM), 3-stage
Dockerfile build, pre-built image from registry, port 8086.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 14:53:18 +02:00
100 changed files with 19487 additions and 15762 deletions

View File

@@ -0,0 +1,208 @@
---
name: create-module
description: Scaffold a new Coltura module (backend + frontend) and optionally wire its entries into the sidebar config. Use when the user asks to create, add, scaffold, or generate a new module — e.g., "crée un module Paie", "add a Pointage module", "ajoute un module RH". The backend is the source of truth for activation and sidebar layout; the frontend scans modules automatically.
---
# Create a new Coltura module
Scaffolds a new module across backend and frontend following Coltura's modular monolith DDD architecture.
## Architecture reminder — read before acting
The module system has **two concerns that are decoupled**:
1. **Module** = code + routes + pages. A module is a bounded context that owns feature code. Declared in `config/modules.php` (backend) and scanned automatically on frontend via `nuxt.config.ts` (any directory under `frontend/modules/` becomes a Nuxt layer).
2. **Sidebar** = navigation layout. Defined in `config/sidebar.php`. Each sidebar item references the module that owns it via the `module` key. Items whose module is not active are filtered out by the backend. **You can freely move a sidebar item from one section to another without touching module code.**
Consequences:
- Frontend never hardcodes module metadata — no `.module.ts`, no `modules-loader.ts`, no manual registration.
- Frontend never hardcodes the sidebar — it fetches `GET /api/sidebar`.
- To add a new module in the sidebar: edit `config/sidebar.php`.
- To move an item between sections: edit `config/sidebar.php`.
- To disable a module: remove/comment it from `config/modules.php`. Its sidebar items are auto-filtered AND its routes land in `disabledRoutes`, which the frontend middleware `modules.global.ts` uses to redirect any direct URL access back to `/`.
The `/api/sidebar` response shape is:
```json
{
"sections": [{ "label": "...", "icon": "...", "items": [...] }],
"disabledRoutes": ["/commercial", "/commercial/orders"]
}
```
Any item whose `module` key matches an inactive module is moved from `sections` into `disabledRoutes` automatically by `SidebarProvider`.
## When to use
User wants to create a new vendable module. Example triggers:
- "Crée un module Paie"
- "Ajoute un module Pointage"
- "Scaffold a new Stock module"
## Prerequisites — gather before acting
Ask the user (via `AskUserQuestion` if not already provided):
1. **Module name** (French, human readable, e.g., "Paie", "Gestion RH", "Pointage"). Derive:
- `PascalCase` for backend folders/classes: `Paie`, `GestionRh`, `Pointage`
- `kebab-case` for frontend folders/routes: `paie`, `gestion-rh`, `pointage`
- `camelCase` for i18n keys: `paie`, `gestionRh`, `pointage`
- `snake_case` for module ID: `paie`, `gestion_rh`, `pointage`
2. **Icon** — an mdi icon identifier for the sidebar section (e.g., `mdi:cash-multiple`). If not given, suggest a relevant one.
3. **Should it appear in the sidebar immediately?** If yes, ask which section (new one or existing) and what nav items (label + route).
Don't guess these — ask.
## Placeholders used below
- `{Pascal}` = PascalCase (e.g., `Paie`)
- `{kebab}` = kebab-case (e.g., `paie`)
- `{camel}` = camelCase i18n slug (e.g., `paie`, `gestionRh`)
- `{id}` = module ID for backend + config (same as `{camel}` for single-word names, `snake_case` otherwise)
- `{label}` = French display label (e.g., `Paie`)
- `{icon}` = mdi icon string (e.g., `mdi:cash-multiple`)
## Files to create/edit
### Backend — module declaration
**1. `src/Module/{Pascal}/{Pascal}Module.php`**
```php
<?php
declare(strict_types=1);
namespace App\Module\{Pascal};
final class {Pascal}Module
{
public const string ID = '{id}';
public const string LABEL = '{label}';
public const bool REQUIRED = false;
}
```
**2. Edit `config/modules.php`** — append the module class:
```php
return [
\App\Module\Core\CoreModule::class,
// ... existing modules
\App\Module\{Pascal}\{Pascal}Module::class,
];
```
### Backend — sidebar config (only if the user wants sidebar entries)
**3. Edit `config/sidebar.php`** — either add items to an existing section or append a new section:
```php
[
'label' => 'sidebar.{camel}.section',
'icon' => '{icon}',
'items' => [
[
'label' => 'sidebar.{camel}.overview',
'to' => '/{kebab}',
'icon' => '{icon}',
'module' => '{id}',
],
// more items from user input...
],
],
```
Every item must have a `module` key matching an active module ID — otherwise the backend will filter it out.
### Frontend — module code (auto-detected)
**4a. `frontend/modules/{kebab}/nuxt.config.ts`** — required for Nuxt to treat the folder as a layer. Content:
```typescript
export default defineNuxtConfig({})
```
**4b. `frontend/modules/{kebab}/pages/{kebab}.vue`** — placeholder page. The filename becomes the route `/{kebab}`.
```vue
<template>
<div>
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('{camel}.title') }}</h1>
<p class="mt-4 text-neutral-500">{{ $t('{camel}.welcome') }}</p>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
useHead({ title: t('{camel}.title') })
</script>
```
Create one page file per nav item the user specified. Derive filenames from the `to` paths (e.g., `/paie/bulletins``pages/paie/bulletins.vue`).
**Do NOT create any of these — they no longer exist in this architecture:**
-`frontend/modules/{kebab}/{kebab}.module.ts`
-`frontend/plugins/modules-loader.ts`
- ❌ Any `extends` entry in `nuxt.config.ts` — modules are auto-detected from `frontend/modules/*/`.
### Frontend — translations
**5. Edit `frontend/i18n/locales/fr.json`** — add the sidebar keys and page content keys:
```json
{
"sidebar": {
"{camel}": {
"section": "{label}",
"overview": "Vue d'ensemble"
}
},
"{camel}": {
"title": "{label}",
"welcome": "Module {label}"
}
}
```
Every `label` in the new `config/sidebar.php` entries must have a matching key here, and every page's `$t('{camel}.*')` calls must match too.
## Implementation steps
Execute in this exact order:
1. **Clarify inputs** — use `AskUserQuestion` for name, icon, and sidebar decisions.
2. **Derive naming** — confirm Pascal/kebab/camel/id are sensible (e.g., "Gestion RH" → `GestionRh`/`gestion-rh`/`gestionRh`/`gestion_rh`).
3. **Read current state** — Read `config/modules.php`, `config/sidebar.php`, `frontend/i18n/locales/fr.json` so you know what exists before editing.
4. **Backend: declare the module** — create `{Pascal}Module.php`, edit `config/modules.php`.
5. **Frontend: create pages** — create the placeholder `.vue` files under `frontend/modules/{kebab}/pages/`.
6. **Backend: sidebar** — if the user wants sidebar entries, edit `config/sidebar.php`.
7. **Frontend: translations** — edit `frontend/i18n/locales/fr.json`.
8. **Verify** — run:
- `docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var` (avoid permission issues)
- `docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear` (validates backend)
- `cd frontend && npx nuxi prepare` (validates Nuxt auto-detection of the new layer)
9. **Report** — list files created, the route(s) to test, and the sidebar items added.
## Rules — do not violate
- **Never create `.module.ts` files** — the old `ModuleDefinition` / `registerModule` pattern is gone. Activation and sidebar are 100% backend-driven.
- **Never edit `extends` in `nuxt.config.ts`** — it auto-scans `frontend/modules/*/`. Adding entries manually causes duplicates.
- **Never create `frontend/plugins/modules-loader.ts`** — it was deleted on purpose.
- **All sidebar labels are i18n keys, not raw text** — the layout calls `t()` on every label. Raw text will display as-is and look broken.
- **Every sidebar item needs a `module` key** — if omitted, the backend filters it out silently.
- **Backend module must be in `config/modules.php`** — otherwise `/api/sidebar` will hide its items even though the sidebar config references them.
- **No DDD scaffolding unless asked** — only create the `{Pascal}Module.php` initially. Don't create empty `Domain/`, `Application/`, `Infrastructure/` folders. Real domain code comes when the module gets features.
- **Don't create a page per nav item blindly** — if the user didn't specify, create only the root `/{kebab}` page and ask whether they want stubs for subpages.
- **Don't use `make cache-clear`** — it may hit permission issues. Use the docker commands directly (see Verify step).
## Naming derivation examples
| User input | Pascal | kebab | camel | id |
|------------|--------|-------|-------|-----|
| Paie | Paie | paie | paie | paie |
| Pointage | Pointage | pointage | pointage | pointage |
| Gestion RH | GestionRh | gestion-rh | gestionRh | gestion_rh |
| Stock & Inventaire | StockInventaire | stock-inventaire | stockInventaire | stock_inventaire |
| CRM | Crm | crm | crm | crm |
For accented characters (é, è, à, ç...): strip accents in folder/identifier names (`Forêt``Foret`/`foret`), keep them in the French `label`.

30
CHANGELOG.md Normal file
View File

@@ -0,0 +1,30 @@
# Changelog
Liste des évolutions du projet Coltura
## [0.0.0]
### Parameters
Ajouter dans le fichier .env
- DEFAULT_URI
- DATABASE_URL
- PONT_BASCULE_BYPASS (doit être à true en dev)
- PONT_BASCULE_URL
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
- JWT_PUBLIC_KEY
- JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- COOKIE_SECURE=0 (en dev 0 et en prod 1)
Ajouter dans le fichier .env du frontend
- NUXT_PUBLIC_API_BASE
### Added
- [#ERP-7] Mise en place du modular monolith
### Changed
### Fixed

275
CLAUDE.md
View File

@@ -1,112 +1,163 @@
# Coltura
CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. **Architecture DDD (Domain-Driven Design).**
CRM/ERP. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. **Architecture Modular Monolith DDD.**
## Architecture DDD
## Architecture Modulaire
Le projet suit une architecture DDD cote backend ET frontend. Le code est organise par **domaine metier** (Bounded Context), pas par type technique.
Le projet suit une architecture **modular monolith** pilotee par le backend : chaque module metier est un bounded context autonome, activable/desactivable par tenant. Le module `Core` est obligatoire.
### Backend — Organisation par domaine
**Principe fondamental : le backend est la source de verite unique.**
- Le backend dicte quels modules sont actifs (`config/modules.php`).
- Le backend dicte l'organisation de la sidebar (`config/sidebar.php`), decouplee des modules eux-memes.
- Le frontend ne connait rien : il scanne automatiquement les modules comme layers Nuxt et demande la sidebar au backend.
### Backend — Organisation par module
```
src/
Domain/ # Couche domaine (logique metier pure, aucune dependance framework)
{BoundedContext}/ # Ex: Customer, Sales, Catalog, Invoice...
Entity/ # Entites et Aggregates du domaine
ValueObject/ # Value Objects (Money, Address, Email...)
Repository/ # Interfaces des repositories (ports)
Service/ # Services domaine (logique metier)
Event/ # Domain Events
Exception/ # Exceptions metier
Application/ # Couche application (cas d'usage, orchestration)
{BoundedContext}/
Command/ # Commands (write) + Handlers
Query/ # Queries (read) + Handlers
DTO/ # Data Transfer Objects
Infrastructure/ # Couche infrastructure (implementations techniques)
{BoundedContext}/
Repository/ # Implementations Doctrine des repositories
Persistence/ # Mapping Doctrine (si XML/YAML)
Shared/ # Services techniques partages (mail, storage, etc.)
Api/ # Couche API (exposition HTTP)
{BoundedContext}/
Resource/ # ApiResource API Platform
State/ # Providers & Processors API Platform
Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # VO de base (Email...)
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules (UserResolverInterface, TenantAwareInterface)
Application/
Bus/ # CommandBusInterface, QueryBusInterface (interfaces seules)
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/
Core/ # Module obligatoire (auth, users)
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/
Entity/ # Entites Doctrine + API Platform (User)
Repository/ # Interfaces repositories (UserRepositoryInterface)
Event/ # Domain events (UserCreated)
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/
State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/
modules.php # Liste des modules actifs (source de verite activation)
sidebar.php # Structure de la sidebar (source de verite navigation)
version.yaml
jwt/ # Cles JWT
packages/ # Config Symfony
migrations/ # Anciennes migrations Doctrine
infra/dev/ # Docker dev
infra/prod/ # Docker prod (multi-stage)
```
**Regles DDD backend :**
- Le domaine (`Domain/`) ne depend de RIEN (pas de Doctrine, pas de Symfony, pas d'API Platform)
- Les repositories dans `Domain/` sont des **interfaces** ; les implementations Doctrine sont dans `Infrastructure/`
- Les entites API Platform (`Api/Resource/`) sont decouples des entites domaine si necessaire
- Chaque Bounded Context est autonome — pas d'import croise entre contextes (communiquer via events ou services application)
- `User` et `Auth` restent dans `src/` (hors DDD) car c'est du framework pur (Security Bundle)
### Frontend — Organisation par domaine
### Frontend — Organisation modulaire (auto-detectee)
```
frontend/
domains/ # Modules metier
{bounded-context}/ # Ex: customer, sales, catalog, invoice...
components/ # Composants Vue specifiques au domaine
composables/ # Composables specifiques au domaine
services/ # Services API du domaine
dto/ # Types TypeScript du domaine
pages/ # Pages du domaine (optionnel, ou dans pages/)
stores/ # Store Pinia du domaine (si necessaire)
components/ # Composants UI partages (non lies a un domaine)
composables/ # Composables partages (useApi, useAppVersion)
stores/ # Stores globaux (auth, ui)
services/ # Services partages
app/ # Shell applicatif
layouts/ # default.vue, auth.vue
middleware/ # auth.global.ts, modules.global.ts
shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, SidebarItem, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer (vide)
pages/ # index.vue, login.vue
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue # Composant racine
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (cles sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
```
**Regles DDD frontend :**
- Chaque domaine est un dossier autonome dans `frontend/domains/`
- Un domaine ne doit pas importer depuis un autre domaine — utiliser les composables/stores partages
- Les composants, services et types partages restent a la racine (`components/`, `composables/`, etc.)
- Les pages peuvent etre dans `frontend/pages/` (routing Nuxt) et importer les composants du domaine
### Endpoints API cles
- `GET /api/version` (public) — version de l'app
- `GET /api/modules` (public) — liste des IDs de modules actifs
- `GET /api/sidebar` (public) — sections de la sidebar + `disabledRoutes`
- Filtre automatiquement les items dont le `module` owner n'est pas actif
- Les sections vides apres filtrage sont supprimees
- `disabledRoutes` = `to` des items filtres (utilise par le middleware front)
- `GET /api/me` (auth) — user courant
### Flux d'activation/desactivation d'un module
Pour activer/desactiver un module, tu touches **uniquement** `config/modules.php` :
```php
return [
\App\Module\Core\CoreModule::class,
// \App\Module\Commercial\CommercialModule::class, // commente = desactive
];
```
Cascade automatique :
1. `GET /api/modules` ne retourne plus `commercial`
2. `GET /api/sidebar` filtre les items `module: 'commercial'` → section "Commercial" disparait, ses routes passent dans `disabledRoutes`
3. Frontend : sidebar se met a jour, middleware `modules.global.ts` redirige toute navigation vers `/commercial` ou `/commercial/*`
4. Le code du module reste dans le bundle Nuxt (layer auto-detecte) → reactivation instantanee sans rebuild
### Reorganiser la sidebar sans toucher aux modules
Pour deplacer un item (ex: "Commandes fournisseurs") d'une section a une autre, tu edites juste `config/sidebar.php` :
```php
// Avant : sous Commercial
['label' => 'sidebar.commercial.suppliers', 'to' => '/commercial/suppliers', 'module' => 'commercial'],
// Apres : sous Production (l'item reste "owned" par Commercial, seule sa place change)
[
'label' => 'sidebar.production.section',
'items' => [
['label' => 'sidebar.commercial.suppliers', 'to' => '/commercial/suppliers', 'module' => 'commercial'],
],
],
```
Le code du module Commercial n'est pas touche.
### Regles d'architecture
**Backend :**
- Le domaine (`Domain/`) peut garder les attributs ORM (approche pragmatique) mais les repositories sont des interfaces
- Communication inter-modules par `Shared/Domain/Contract/` ou domain events — jamais d'import direct entre modules
- Chaque module declare un `*Module.php` avec `ID`, `LABEL`, `REQUIRED`
- `config/modules.php` = seule source de verite pour l'activation
- `config/sidebar.php` = seule source de verite pour l'organisation de la sidebar (chaque item reference son module owner via la cle `module`)
- Migrations par module dans `src/Module/{Module}/Infrastructure/Doctrine/Migrations/`
- **Exception connue** : avec plusieurs `migrations_paths` configures, Doctrine Migrations 3.x trie les migrations par FQCN alphabetique et non par version timestamp → ordre d'execution incorrect entre namespaces sur une base vide. Tant que ce n'est pas resolu (via un `MigrationsComparator` custom ou un upgrade), les migrations d'initialisation critiques (setup user, RBAC, etc.) vivent au namespace racine `DoctrineMigrations` dans `migrations/`. Le namespace modulaire reste configure pour les futures migrations applicatives (qui dependent d'un schema deja cree).
**Frontend :**
- Chaque module est un layer Nuxt auto-detecte (`modules/*/nuxt.config.ts` minimal)
- Un module front ne doit pas importer depuis un autre module — utiliser `shared/`
- `useSidebar()` fetch `/api/sidebar` et expose `sections`, `disabledRoutes`, `isRouteDisabled()`
- Le layout `default.vue` itere sur les sections retournees par l'API, applique `t()` sur les labels
- Middleware `auth.global.ts` charge la sidebar apres authentification
- Middleware `modules.global.ts` redirige si la route demandee est dans `disabledRoutes`
- Les composables avec state singleton (refs module-level) doivent exposer une fonction `reset*()` et etre reinitialises au logout (ex: `useSidebar().resetSidebar()`)
- **Interdit** : `.module.ts`, `modules-loader.ts`, hardcode de la sidebar, edition manuelle de `extends` dans `nuxt.config.ts`
## Stack
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login a `/login_check`, cookie `BEARER`
- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5436)
## Structure
```
src/
Domain/{Context}/Entity/ # Entites domaine
Domain/{Context}/ValueObject/ # Value Objects
Domain/{Context}/Repository/ # Interfaces repositories
Domain/{Context}/Service/ # Services domaine
Domain/{Context}/Event/ # Domain Events
Application/{Context}/Command/ # Commands + Handlers
Application/{Context}/Query/ # Queries + Handlers
Application/{Context}/DTO/ # Data Transfer Objects
Infrastructure/{Context}/Repository/ # Implementations Doctrine
Api/{Context}/Resource/ # ApiResource API Platform
Api/{Context}/State/ # Providers & Processors
Entity/ # Entites framework (User)
DataFixtures/ # Fixtures
config/ # Config Symfony
config/jwt/ # Cles JWT
migrations/ # Migrations Doctrine
infra/dev/ # Docker dev
infra/prod/ # Docker prod (multi-stage)
frontend/
domains/{context}/components/ # Composants du domaine
domains/{context}/composables/ # Composables du domaine
domains/{context}/services/ # Services API du domaine
domains/{context}/dto/ # Types TS du domaine
domains/{context}/stores/ # Store Pinia du domaine
components/ # Composants UI partages
composables/ # Composables partages (useApi, useAppVersion)
stores/ # Stores globaux (auth, ui)
pages/ # Pages (routing Nuxt)
layouts/ # Layouts
i18n/locales/ # Traductions
```
- **Docker** : PHP-FPM + Node 24, Nginx (port 8083), PostgreSQL (port 5437)
## Commandes
@@ -116,7 +167,7 @@ make stop # Arreter les containers
make restart # Redemarrer les containers
make install # Install complet (composer, migrations, fixtures, build Nuxt)
make reset # Tout supprimer et reinstaller (supprime la BDD)
make dev-nuxt # Dev server Nuxt (hot reload, port 3003)
make dev-nuxt # Dev server Nuxt (hot reload, port 3004)
make shell # Shell dans le container PHP
make shell-root # Shell root dans le container PHP
make cache-clear # Vider le cache Symfony
@@ -128,6 +179,12 @@ make php-cs-fixer-allow-risky # Fix code style PHP
make logs-dev # Tail logs Symfony
```
Si `make cache-clear` echoue pour cause de permissions sur `var/` :
```bash
docker exec -t -u root php-coltura-fpm chown -R www-data:www-data /var/www/html/var
docker exec -t -u www-data php-coltura-fpm php bin/console cache:clear
```
## Conventions
### Commits
@@ -145,14 +202,34 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Faire un commit separe de bump : `chore : bump version to v<X.Y.Z>`
- Puis creer le tag et pusher : `git tag v<X.Y.Z> && git push origin develop --tags`
### Nommage
| Element | Convention | Exemple |
|---------|-----------|---------|
| Module back | PascalCase | `Module/Commercial/` |
| Module front | kebab-case | `modules/commercial/` |
| Module ID | snake_case | `commercial`, `gestion_rh` |
| Entity | PascalCase singulier | `User.php` |
| Repository interface | `*RepositoryInterface` | `UserRepositoryInterface.php` |
| Repository impl | `Doctrine*Repository` | `DoctrineUserRepository.php` |
| DTO | `*Output` / `*Input` | `UserOutput.php` |
| API Resource | classe dans `Infrastructure/ApiPlatform/Resource/` | `UserResource.php` |
| Provider | `*Provider` | `MeProvider.php` |
| Processor | `*Processor` | `UserPasswordHasherProcessor.php` |
| Module declaration back | `*Module.php` | `CommercialModule.php` |
| Composable front | `use*` | `useSidebar.ts` |
| Cles i18n sidebar | `sidebar.<module>.*` | `sidebar.commercial.overview` |
### Backend
- Toujours `declare(strict_types=1)` en haut des fichiers PHP
- API Platform : utiliser ApiResource, Providers (`src/State/`), Processors — pas de controllers
- **Commentaires en francais** : tout commentaire PHP (docblock, inline, bloc) doit etre redige en francais. Le code (noms de classes, methodes, variables) reste en anglais. Objectif : faciliter la relecture par l'equipe FR sans polluer l'API publique du code.
- API Platform : utiliser ApiResource, Providers, Processors — pas de controllers
- Routes API prefixees `/api` (via `config/routes/api_platform.yaml`)
- Le login (`/login_check`) est hors prefix `/api`, nginx reecrit `REQUEST_URI` vers `/login_check`
- PHP CS Fixer : regles Symfony + PSR-12 + strict types
- Roles : `ROLE_ADMIN`, `ROLE_USER` — hierarchie dans `security.yaml`
- **Permissions RBAC** : format obligatoire `module.resource[.subresource].action` en snake_case, ex : `core.users.view`, `commercial.clients.contacts.edit`. Declarees via la methode statique `permissions()` des `*Module.php`, synchronisees par la commande `app:sync-permissions`. Verification via `is_granted('module.resource.action')` cote API Platform et `usePermissions()` cote front.
- PostgreSQL : noms de colonnes toujours en **minuscules** dans le SQL brut
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour eviter le conflit avec API Platform `{id}`
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux proprietes de l'entite cible
@@ -161,11 +238,14 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
### Frontend
- TypeScript strict
- **Commentaires en francais** : tout commentaire TS/Vue (JSDoc, inline, bloc) doit etre redige en francais. Le code reste en anglais. Meme regle que cote backend.
- Composable `useApi()` pour tous les appels API (gere cookies, erreurs, toasts, i18n)
- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui)
- Middleware global `auth.global.ts` protege les routes
- Traductions dans `frontend/i18n/locales/`
- Middleware global `auth.global.ts` protege les routes + charge la sidebar apres login
- Middleware global `modules.global.ts` redirige les routes des modules desactives
- Traductions dans `frontend/i18n/locales/` avec le namespace `sidebar.*` pour la nav
- 4 espaces d'indentation
- Les labels de sidebar sont des cles i18n, jamais du texte brut (le layout applique `t()` dessus)
### Nginx
@@ -177,7 +257,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Container PHP : `php-coltura-fpm`
- Container Nginx : `nginx-coltura`
- Container DB : PostgreSQL sur port **5436** (interne et externe)
- Container DB : PostgreSQL sur port **5437** (interne et externe)
- Config Docker dev : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Config Docker prod : `infra/prod/` (Dockerfile multi-stage, docker-compose.prod.yml)
- Apres modif nginx : `docker restart nginx-coltura`
@@ -186,3 +266,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- User admin : `admin` / `admin` (ROLE_ADMIN)
- Users internes : `alice` / `alice`, `bob` / `bob` (ROLE_USER)
## Delegation Codex
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.

102
README.md
View File

@@ -28,8 +28,8 @@ make dev-nuxt # Port 3003
| Service | Port |
|------------|------|
| API (Nginx)| 8083 |
| Frontend | 3003 |
| PostgreSQL | 5436 |
| Frontend | 3004 |
| PostgreSQL | 5437 |
## Commandes
@@ -50,29 +50,89 @@ make dev-nuxt # Port 3003
| `make php-cs-fixer-allow-risky` | Fix code style PHP |
| `make logs-dev` | Tail logs Symfony |
## Architecture
**Modular Monolith DDD** : chaque module est un bounded context autonome, activable/desactivable par tenant. Le backend est la seule source de verite pour l'activation et l'organisation de la sidebar.
- `config/modules.php` — liste des modules actifs
- `config/sidebar.php` — structure de la sidebar (sections + items avec module owner)
- `GET /api/sidebar` — retourne les sections filtrees par les modules actifs + les routes desactivees
- Frontend : chaque `frontend/modules/*/` est auto-detecte comme layer Nuxt, la sidebar est fetchee de l'API
Pour desactiver un module : commenter sa ligne dans `config/modules.php`, clear cache. Ses items de sidebar disparaissent et ses routes sont bloquees par le middleware front.
Pour reorganiser la sidebar (ex: deplacer un item d'une section a l'autre) : editer `config/sidebar.php` uniquement, le code des modules n'est pas touche.
## Structure
```
src/ # Backend Symfony
Entity/ # Entites Doctrine
ApiResource/ # Ressources API Platform
State/ # Providers & Processors
Repository/ # Repositories Doctrine
DataFixtures/ # Fixtures
config/ # Config Symfony
migrations/ # Migrations Doctrine
frontend/ # App Nuxt 4
pages/ # Pages Vue
layouts/ # Layouts
components/ # Composants
composables/ # Composables (useApi, useAppVersion)
stores/ # Stores Pinia (auth, ui)
services/ # Services API + DTOs
i18n/ # Traductions
src/ # Backend Symfony
Kernel.php
Shared/ # Noyau technique partage
Domain/
ValueObject/ # Email, ...
Event/ # DomainEventInterface
Contract/ # Interfaces inter-modules
Application/
Bus/ # CommandBusInterface, QueryBusInterface
Infrastructure/
ApiPlatform/
Resource/ # AppVersion, ModulesResource, SidebarResource
State/ # AppVersionProvider, ModulesProvider, SidebarProvider
Module/
Core/ # Module obligatoire (auth, users)
CoreModule.php # Declaration (ID, LABEL, REQUIRED)
Domain/
Entity/ # User
Repository/ # UserRepositoryInterface
Event/ # UserCreated
Application/
DTO/ # UserOutput
Infrastructure/
Doctrine/ # DoctrineUserRepository, Migrations/
ApiPlatform/State/
Provider/ # MeProvider
Processor/ # UserPasswordHasherProcessor
Console/ # CreateUserCommand
DataFixtures/ # AppFixtures
Commercial/ # Autre module (exemple)
CommercialModule.php
config/
modules.php # Source de verite activation
sidebar.php # Source de verite navigation
version.yaml
packages/ # Config Symfony
jwt/ # Cles JWT
migrations/ # Anciennes migrations
frontend/ # App Nuxt 4 (SPA)
app/
layouts/ # default.vue, auth.vue
middleware/ # auth.global.ts, modules.global.ts
shared/ # Code partage (hors modules)
composables/ # useApi, useAppVersion, useSidebar
components/ui/ # AppTopNav, ...
stores/ # auth, ui
services/ # auth
types/ # SidebarSection, UserData
utils/ # api (Hydra)
modules/ # Modules auto-detectes comme layers Nuxt
core/
nuxt.config.ts # Marqueur layer
pages/ # index, login, logout
commercial/
nuxt.config.ts
pages/ # commercial.vue
app.vue
nuxt.config.ts # Scanne modules/*/ automatiquement
i18n/locales/ # Traductions (sidebar.*, etc.)
assets/ # CSS, images
public/ # Fichiers statiques
infra/
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
dev/ # Docker dev (Dockerfile, nginx, php.ini, xdebug)
prod/ # Docker prod (multi-stage, nginx, php-prod.ini)
.gitea/workflows/ # CI Gitea (auto-tag, build Docker)
.claude/
skills/create-module/ # Skill Claude Code pour scaffolder un module
```
## CI/CD

10
config/modules.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
use App\Module\Commercial\CommercialModule;
use App\Module\Core\CoreModule;
return [
CoreModule::class,
CommercialModule::class,
];

View File

@@ -9,12 +9,12 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
mappings:
App:
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
alias: Core
controller_resolver:
auto_mapping: false

View File

@@ -1,4 +1,5 @@
doctrine_migrations:
migrations_paths:
'DoctrineMigrations': '%kernel.project_dir%/migrations'
'App\Module\Core\Infrastructure\Doctrine\Migrations': '%kernel.project_dir%/src/Module/Core/Infrastructure/Doctrine/Migrations'
enable_profiler: false

View File

@@ -8,7 +8,7 @@ security:
providers:
app_user_provider:
entity:
class: App\Entity\User
class: App\Module\Core\Domain\Entity\User
property: username
firewalls:
@@ -45,6 +45,8 @@ security:
- { 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 }
when@test:

View File

@@ -15,3 +15,12 @@ services:
# this creates a service per class whose id is the fully-qualified class name
App\:
resource: '../src/'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository
App\Module\Core\Domain\Repository\RoleRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository
App\Module\Core\Domain\Repository\UserRepositoryInterface:
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository

55
config/sidebar.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
/*
* Sidebar configuration.
*
* This file defines the sidebar sections displayed in the frontend.
* Each item references the module that owns it via the `module` key.
* Items whose module is not active (see config/modules.php) are filtered out.
*
* This config is decoupled from the modules themselves: you can freely
* move an item from one section to another without touching the module code.
*
* Label keys are i18n keys resolved by the frontend (see frontend/i18n/locales/).
*/
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
[
'label' => 'sidebar.general.dashboard',
'to' => '/',
'icon' => 'mdi:view-dashboard-outline',
'module' => 'core',
],
[
'label' => 'sidebar.general.admin',
'to' => '/admin',
'icon' => 'mdi:cog-outline',
'module' => 'core',
],
[
'label' => 'sidebar.general.logout',
'to' => '/logout',
'icon' => 'mdi:logout',
'module' => 'core',
],
],
],
[
'label' => 'sidebar.commercial.section',
'icon' => 'mdi:account-arrow-left-outline',
'items' => [
[
'label' => 'sidebar.commercial.suppliers',
'to' => '/suppliers',
'icon' => 'mdi:account-arrow-left-outline',
'module' => 'commercial',
],
],
],
];

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.20'
app.version: '0.1.30'

View File

@@ -0,0 +1,364 @@
# Architecture Modulaire Monolith — MALIO
## Contexte
Projet monolith Symfony API Platform (back) + Nuxt (front dans un dossier `frontend/`).
L'objectif est une architecture **modular monolith DDD** permettant de vendre des modules indépendamment à chaque client (ex : un client achète GestionRH + Formation, un autre GestionRH + Paie + Pointage).
---
## Principes fondamentaux
1. **Chaque module est un bounded context autonome** — son propre Domain, Application, Infrastructure.
2. **Communication inter-modules uniquement par events ou contrats** — jamais d'import direct d'une entité d'un autre module.
3. **Les modules sont activables/désactivables** par tenant sans casser le reste.
4. **Le module `Core` est obligatoire** — il gère users, tenants, auth.
5. **Le dossier `Shared/`** contient le noyau technique commun (interfaces, value objects de base, bus).
6. **API Platform** : les `#[ApiResource]` sont sur des classes Resource dédiées dans `Infrastructure/ApiPlatform/Resource/`, jamais directement sur les entités du Domain.
7. **CQRS** : Command/Query handlers dans la couche Application, DTO d'entrée/sortie découplés des entités.
8. **Multi-tenant natif** : chaque entité porte un `tenantId`.
---
## Structure cible — Backend (`src/`)
```
src/
├── Kernel.php
├── Shared/
│ ├── Domain/
│ │ ├── ValueObject/
│ │ │ ├── AggregateId.php
│ │ │ └── Email.php
│ │ ├── Event/
│ │ │ └── DomainEventInterface.php
│ │ └── Contract/ ← Interfaces inter-modules
│ │ ├── UserResolverInterface.php
│ │ └── TenantAwareInterface.php
│ ├── Application/
│ │ └── Bus/
│ │ ├── CommandBusInterface.php
│ │ └── QueryBusInterface.php
│ └── Infrastructure/
│ ├── Doctrine/
│ ├── Messenger/
│ └── ApiPlatform/
│ └── OpenApi/
├── Module/
│ ├── Core/ ← Module obligatoire
│ │ ├── Domain/
│ │ │ ├── Entity/
│ │ │ │ ├── User.php
│ │ │ │ └── Tenant.php
│ │ │ ├── Repository/
│ │ │ │ └── UserRepositoryInterface.php
│ │ │ └── Event/
│ │ │ └── UserCreated.php
│ │ ├── Application/
│ │ │ ├── Command/
│ │ │ │ ├── CreateUser.php
│ │ │ │ └── CreateUserHandler.php
│ │ │ ├── Query/
│ │ │ │ ├── GetUserById.php
│ │ │ │ └── GetUserByIdHandler.php
│ │ │ └── DTO/
│ │ │ └── UserOutput.php
│ │ ├── Infrastructure/
│ │ │ ├── Doctrine/
│ │ │ │ ├── DoctrineUserRepository.php
│ │ │ │ └── mapping/
│ │ │ └── ApiPlatform/
│ │ │ ├── Resource/
│ │ │ │ └── UserResource.php
│ │ │ ├── State/
│ │ │ │ ├── Provider/
│ │ │ │ └── Processor/
│ │ │ └── Filter/
│ │ └── CoreModule.php ← Déclaration : config, routes, dépendances
│ │
│ ├── GestionRH/ ← Module vendable
│ │ ├── Domain/
│ │ │ ├── Entity/
│ │ │ │ ├── Employee.php
│ │ │ │ ├── Contract.php
│ │ │ │ └── Leave.php
│ │ │ ├── ValueObject/
│ │ │ ├── Repository/
│ │ │ │ └── EmployeeRepositoryInterface.php
│ │ │ ├── Event/
│ │ │ │ └── EmployeeHired.php
│ │ │ ├── Exception/
│ │ │ └── Service/
│ │ ├── Application/
│ │ │ ├── Command/
│ │ │ ├── Query/
│ │ │ ├── DTO/
│ │ │ └── Listener/ ← Réagit aux events d'autres modules
│ │ ├── Infrastructure/
│ │ │ ├── Doctrine/
│ │ │ │ ├── DoctrineEmployeeRepository.php
│ │ │ │ ├── mapping/
│ │ │ │ └── migrations/ ← Migrations propres au module
│ │ │ └── ApiPlatform/
│ │ │ ├── Resource/
│ │ │ │ └── EmployeeResource.php
│ │ │ └── State/
│ │ │ ├── Provider/
│ │ │ └── Processor/
│ │ └── GestionRHModule.php
│ │
│ ├── Formation/ ← Module vendable
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── FormationModule.php
│ │
│ ├── Pointage/ ← Module vendable
│ │ ├── Domain/
│ │ ├── Application/
│ │ ├── Infrastructure/
│ │ └── PointageModule.php
│ │
│ └── Paie/ ← Module vendable
│ ├── Domain/
│ ├── Application/
│ ├── Infrastructure/
│ └── PaieModule.php
└── config/
└── modules.php ← Liste des modules activés
```
---
## Structure cible — Frontend (`frontend/`)
```
frontend/
├── app/
│ ├── layouts/
│ │ └── default.vue ← Menu dynamique selon modules activés
│ ├── middleware/
│ │ └── modules.global.ts ← Bloque les routes de modules désactivés
│ └── app.vue
├── shared/
│ ├── composables/
│ │ ├── useAuth.ts
│ │ ├── useApi.ts
│ │ ├── useTenant.ts
│ │ └── useModules.ts ← Expose les modules activés (via API)
│ ├── components/
│ │ ├── ui/ ← Design system (boutons, tables, modals…)
│ │ ├── AppSidebar.vue
│ │ └── AppHeader.vue
│ ├── types/
│ │ └── index.ts
│ ├── utils/
│ └── stores/
│ ├── auth.ts
│ └── tenant.ts
├── modules/
│ ├── core/
│ │ ├── pages/
│ │ │ ├── login.vue
│ │ │ ├── dashboard.vue
│ │ │ └── users/
│ │ │ ├── index.vue
│ │ │ └── [id].vue
│ │ ├── components/
│ │ ├── composables/
│ │ ├── stores/
│ │ │ └── users.ts
│ │ ├── types/
│ │ └── core.module.ts
│ │
│ ├── gestion-rh/
│ │ ├── pages/
│ │ │ ├── employees/
│ │ │ │ ├── index.vue
│ │ │ │ └── [id].vue
│ │ │ ├── contracts/
│ │ │ └── leaves/
│ │ ├── components/
│ │ │ ├── EmployeeCard.vue
│ │ │ └── LeaveCalendar.vue
│ │ ├── composables/
│ │ │ └── useEmployees.ts
│ │ ├── stores/
│ │ │ └── employees.ts
│ │ ├── types/
│ │ │ └── index.ts
│ │ └── gestion-rh.module.ts
│ │
│ ├── formation/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── stores/
│ │ ├── types/
│ │ └── formation.module.ts
│ │
│ ├── pointage/
│ │ ├── pages/
│ │ ├── components/
│ │ ├── composables/
│ │ ├── stores/
│ │ ├── types/
│ │ └── pointage.module.ts
│ │
│ └── paie/
│ ├── pages/
│ ├── components/
│ ├── composables/
│ ├── stores/
│ ├── types/
│ └── paie.module.ts
├── plugins/
│ └── modules-loader.ts ← Charge dynamiquement les modules activés
├── nuxt.config.ts
└── package.json
```
---
## Règles d'implémentation
### Backend
#### 1. Communication inter-modules
- **INTERDIT** : `use Module\GestionRH\Domain\Entity\Employee` depuis le module Paie.
- **AUTORISÉ** : passer par `Shared\Domain\Contract\EmployeeResolverInterface` ou écouter un domain event comme `EmployeeHired`.
- Les contrats (interfaces) partagés vivent dans `Shared/Domain/Contract/`.
#### 2. Couche Domain (aucune dépendance Symfony)
- Entités avec logique métier encapsulée (pas d'anemic model).
- Value Objects pour la validation (Email, Money, OrderStatus…).
- Repository = interface uniquement.
- Domain Events pour notifier les autres modules.
- Aucun `use Symfony\...` dans ce dossier.
#### 3. Couche Application
- CQRS : Command (écriture) + Query (lecture) avec leurs Handlers.
- Les Handlers orchestrent : ils appellent le Domain et les interfaces Repository.
- DTO Input/Output pour le contrat d'entrée/sortie, découplés des entités.
- Listeners pour réagir aux events d'autres modules.
#### 4. Couche Infrastructure
- Implémentations Doctrine des repositories.
- Mapping et migrations **propres à chaque module** (pas de migration centralisée).
- API Platform :
- `Resource/` : classes avec `#[ApiResource]`, jamais posé sur les entités Domain.
- `State/Provider/` : fournisseurs de données (GET).
- `State/Processor/` : traitement des mutations (POST/PUT/PATCH/DELETE), délègue au bus ou aux handlers.
- `Filter/` : filtres API Platform spécifiques au module.
- Les endpoints n'apparaissent dans l'OpenAPI que si le module est activé.
#### 5. Module declaration (`*Module.php`)
Chaque module déclare :
- Son identifiant unique.
- Ses dépendances (ex : GestionRH dépend de Core).
- Sa configuration de services.
- Ses routes.
#### 6. Activation/désactivation
- Fichier `config/modules.php` ou variable d'environnement listant les modules actifs.
- Le Kernel ne charge que les services/routes/migrations des modules activés.
- Endpoint API : `GET /api/tenant/modules` retourne la liste des modules activés pour le tenant courant.
#### 7. Multi-tenant
- Chaque entité porte un `tenantId`.
- Filtrage automatique Doctrine par tenant (Doctrine Filter ou listeners).
### Frontend
#### 1. Déclaration de module (`*.module.ts`)
```typescript
export default defineAppModule({
id: 'gestion-rh',
label: 'Gestion RH',
icon: 'i-lucide-users',
requiredModules: ['core'],
navigation: [
{ label: 'Employés', to: '/employees', icon: 'i-lucide-user' },
{ label: 'Contrats', to: '/contracts', icon: 'i-lucide-file-text' },
{ label: 'Congés', to: '/leaves', icon: 'i-lucide-calendar' },
],
permissions: ['employee.read', 'employee.write', 'leave.manage'],
})
```
#### 2. Chargement dynamique (`useModules`)
```typescript
export const useModules = () => {
const enabledModules = useState<string[]>('modules', () => [])
const isEnabled = (moduleId: string) =>
enabledModules.value.includes(moduleId)
return { enabledModules, isEnabled }
}
```
Au boot de l'app, appel `GET /api/tenant/modules` pour récupérer les modules activés.
#### 3. Middleware de protection des routes
```typescript
export default defineNuxtRouteMiddleware((to) => {
const { isEnabled } = useModules()
const moduleId = resolveModuleFromRoute(to.path)
if (moduleId && !isEnabled(moduleId)) {
return navigateTo('/dashboard')
}
})
```
#### 4. Sidebar dynamique
Le layout `default.vue` itère sur les modules activés et affiche leurs entrées `navigation`.
#### 5. Isolation
- Chaque module front a ses propres pages, components, composables, stores, types.
- Les composants partagés (design system) sont dans `shared/components/ui/`.
- Un module front ne doit jamais importer depuis un autre module front. Si besoin de données croisées, passer par l'API ou par un composable partagé dans `shared/`.
---
## Résumé des conventions de nommage
| Élément | Convention | Exemple |
|---------|-----------|---------|
| Module back | PascalCase | `Module/GestionRH/` |
| Module front | kebab-case | `modules/gestion-rh/` |
| Entity | PascalCase singulier | `Employee.php` |
| Repository interface | `*RepositoryInterface` | `EmployeeRepositoryInterface.php` |
| Repository impl | `Doctrine*Repository` | `DoctrineEmployeeRepository.php` |
| Command | Verbe + Nom | `CreateEmployee.php` |
| Command Handler | `*Handler` | `CreateEmployeeHandler.php` |
| DTO | `*Input` / `*Output` | `EmployeeOutput.php` |
| API Resource | `*Resource` | `EmployeeResource.php` |
| Provider | `*Provider` | `EmployeeProvider.php` |
| Processor | `*Processor` | `CreateEmployeeProcessor.php` |
| Module declaration back | `*Module.php` | `GestionRHModule.php` |
| Module declaration front | `*.module.ts` | `gestion-rh.module.ts` |
| Composable | `use*` | `useEmployees.ts` |
| Store | nom du domaine | `employees.ts` |
---
## Checklist de migration
Si le projet existe déjà avec une structure plate, voici l'ordre de migration recommandé :
1. Créer `Shared/` et y déplacer les interfaces/VO de base.
2. Créer `Module/Core/` et y migrer users, auth, tenants.
3. Pour chaque futur module vendable, créer le dossier `Module/<Nom>/` avec les 3 couches (Domain, Application, Infrastructure).
4. Déplacer les entités, repositories, services dans le bon module.
5. Remplacer les imports directs inter-modules par des contrats (`Shared/Domain/Contract/`) ou des events.
6. Isoler les migrations Doctrine par module.
7. Adapter les resources API Platform (les sortir des entités, créer les Providers/Processors).
8. Côté front, créer `shared/` et `modules/`, migrer les pages/composants dans le bon module.
9. Implémenter `useModules` + middleware de routes + sidebar dynamique.
10. Tester l'activation/désactivation d'un module de bout en bout.

View File

@@ -0,0 +1,267 @@
# Code Review — PR #1 [ERP-7] Mise en place du modular monolith
**Branche** : `feature/ERP-7-mise-en-place-du-modular-monolith``develop`
**Auteur** : Tristan
**Date de review** : 2026-04-09
**Scope** : 55 commits, 85 fichiers modifiés, ~30 000 lignes ajoutées
---
## Résumé de la PR
Cette PR restructure Coltura (CRM/ERP) en **architecture modulaire DDD** (Domain-Driven Design) :
- **Backend** : introduction de bounded contexts (`Module/Core`, `Module/Commercial`) avec séparation Domain / Application / Infrastructure
- **Shared** : couche partagée (events, value objects, contracts, bus interfaces)
- **Modules activables** : `config/modules.php` comme source de vérité, activation/désactivation sans toucher au code
- **Sidebar dynamique** : `config/sidebar.php` déclare la navigation, le backend filtre selon les modules actifs, le frontend consomme via `/api/sidebar`
- **Frontend** : réorganisation en `app/` (shell), `shared/` (composables, stores, types), `modules/` (Nuxt layers auto-détectés)
- **Infra** : migration Doctrine, Dockerfile prod, deploy.sh, nginx-proxy, maintenance mode
---
## Issues trouvées
### Issue 1 — CHANGELOG mentionne le mauvais projet
| | |
|---|---|
| **Sévérité** | Critique |
| **Fichier** | `CHANGELOG.md`, ligne 3 |
| **Confiance** | 100/100 |
**Constat** : Le header du CHANGELOG dit :
```
Liste des évolutions du projet Ferme
```
Ce fichier appartient à **Coltura**, pas au projet Ferme. C'est une erreur de copier-coller lors du scaffolding initial.
**Correction** : Remplacer "Ferme" par "Coltura".
---
### Issue 2 — Sidebar link `/suppliers` pointe vers une page inexistante (404)
| | |
|---|---|
| **Sévérité** | Majeure |
| **Fichier** | `config/sidebar.php`, ligne 49 |
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
**Constat** : La sidebar déclare un lien vers `/suppliers` pour le module commercial :
```php
// config/sidebar.php
[
'label' => 'sidebar.commercial.suppliers',
'icon' => 'i-heroicons-truck',
'to' => '/suppliers', // ← route inexistante
'module' => 'commercial',
],
```
Mais la seule page du module commercial est `frontend/modules/commercial/pages/commercial.vue`, que Nuxt mappe sur la route `/commercial` (pas `/suppliers`).
**Impact** : Cliquer sur ce lien dans la sidebar donnera un 404.
**Correction** : Soit renommer la page en `suppliers.vue`, soit changer le `'to'` en `'/commercial'`.
---
### Issue 3 — Port PostgreSQL changé de 5436 à 5437
| | |
|---|---|
| **Sévérité** | Majeure |
| **Fichier** | `infra/dev/.env.docker` |
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / 3003 / **5436**" |
| **Confiance** | 75/100 |
**Constat** : Le fichier `.env.docker` définit `POSTGRES_PORT=5437`, alors que le port documenté pour Coltura est `5436`.
**Impact** : Tout développeur qui suit les ports documentés (ou qui utilise des scripts basés sur ces ports) ne pourra pas se connecter à la base.
**Correction** : Revenir à `5436` ou mettre à jour les CLAUDE.md (workspace + projet).
---
### Issue 4 — Port frontend changé de 3003 à 3004 sans mise à jour de la documentation
| | |
|---|---|
| **Sévérité** | Majeure |
| **Fichiers** | `frontend/nuxt.config.ts` (ligne 40), `docker-compose.yml` (ligne 33) |
| **Règle violée** | Workspace `CLAUDE.md` : "Coltura — 8083 / **3003** / 5436" et `CLAUDE.md` projet : "make dev-nuxt # port 3003" |
| **Confiance** | 75/100 (confirmé par 3 agents indépendants) |
**Constat** :
```typescript
// nuxt.config.ts
devServer: { port: 3004 } // était 3003
```
```yaml
# docker-compose.yml
ports:
- "3004:3004" # était 3004:3003
```
Les deux `CLAUDE.md` documentent toujours le port 3003.
**Correction** : Mettre à jour les deux CLAUDE.md pour refléter le nouveau port, ou revenir à 3003.
---
### Issue 5 — `useSidebar` : state singleton jamais réinitialisé au logout
| | |
|---|---|
| **Sévérité** | Majeure |
| **Fichier** | `frontend/shared/composables/useSidebar.ts`, lignes 3-5 |
| **Confiance** | 75/100 |
**Constat** : Les refs `sections`, `disabledRoutes` et `loaded` sont déclarées au niveau module (en dehors de la fonction composable) :
```typescript
const sections = ref<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
```
Ce sont des singletons partagés sur toute la durée de vie de l'app. Après un logout + re-login :
1. `loaded.value` reste `true`
2. `loadSidebar()` n'est jamais rappelé
3. La sidebar affiche les données de la session précédente
Le middleware `auth.global.ts` ne recharge que si `!loaded.value`, et `logout.vue` ne reset jamais `loaded`.
**Impact** : Sidebar périmée après re-connexion. Si les modules changent côté serveur, le frontend ne le saura jamais sans un hard refresh.
**Correction** : Ajouter une fonction `resetSidebar()` appelée au logout, ou conditionner le rechargement autrement (ex: toujours recharger après login).
---
### Issue 6 — `UserOutput` DTO : type mismatch `int` vs `?int` + dead code
| | |
|---|---|
| **Sévérité** | Moyenne |
| **Fichier** | `src/Module/Core/Application/DTO/UserOutput.php`, lignes 13 et 23 |
| **Confiance** | 75/100 |
**Constat** :
```php
// Le constructeur attend un int non-nullable
public function __construct(
public int $id, // ← int, pas ?int
// ...
)
// Mais User::getId() retourne ?int
public static function fromEntity(User $user): self
{
return new self(
id: $user->getId(), // ← peut être null
// ...
);
}
```
Avec `declare(strict_types=1)`, passer `null` à un paramètre `int` lève un `TypeError`.
**De plus** : Ce DTO n'est utilisé nulle part. `MeProvider` retourne directement l'entité `User` via `$this->security->getUser()`. Le DTO est du dead code.
**Correction** : Soit utiliser le DTO dans `MeProvider` (comme l'architecture le prévoit), soit le supprimer. Dans tous les cas, changer `int $id` en `?int $id`.
---
### Issue 7 — `CreateUserCommand` contourne `UserRepositoryInterface`
| | |
|---|---|
| **Sévérité** | Moyenne |
| **Fichier** | `src/Module/Core/Infrastructure/Console/CreateUserCommand.php`, lignes 22-27 |
| **Règle violée** | `CLAUDE.md` projet : "les repositories sont des interfaces" |
| **Confiance** | 75/100 (confirmé par 2 agents indépendants) |
**Constat** :
```php
public function __construct(
private EntityManagerInterface $em, // ← injection directe de Doctrine
private UserPasswordHasherInterface $hasher,
) {}
// ...
$this->em->persist($user);
$this->em->flush();
```
Le `UserRepositoryInterface::save(User $user)` existe et est implémenté par `DoctrineUserRepository`. La commande devrait l'utiliser :
```php
// Correction attendue
public function __construct(
private UserRepositoryInterface $userRepository,
private UserPasswordHasherInterface $hasher,
) {}
// ...
$this->userRepository->save($user);
```
**Impact** : Viole le pattern DDD introduit dans cette même PR et crée un second chemin de persistance non contrôlé.
---
### Issue 8 — `auth.vue` : indentation 2 espaces au lieu de 4
| | |
|---|---|
| **Sévérité** | Mineure |
| **Fichier** | `frontend/app/layouts/auth.vue` |
| **Règle violée** | `CLAUDE.md` projet : "4 espaces d'indentation" |
| **Confiance** | 75/100 |
**Constat** : Le fichier utilise 2 espaces d'indentation :
```vue
<template>
<div class="min-h-screen">
<slot />
</div>
</template>
```
Tous les autres fichiers Vue de la PR (`default.vue`, `login.vue`, `index.vue`, `logout.vue`, `commercial.vue`) utilisent correctement 4 espaces.
**Correction** : Passer en 4 espaces.
---
## Issues mineures non retenues (score < 75)
Pour information, ces points ont été identifiés mais jugés moins critiques :
- **`/api/sidebar` et `/api/modules` en PUBLIC_ACCESS** : intentionnel selon le CLAUDE.md qui documente ces endpoints comme publics
- **`doctrine.yaml` ne mappe que le module Core** : les entités de futurs modules ne seront pas détectées automatiquement (à documenter)
- **Middleware `modules.global.ts`** : boucle de redirection infinie possible si `/` est dans `disabledRoutes` (requiert une mauvaise config)
- **Lien sidebar `/admin`** : pointe vers une page inexistante (pré-existant sur develop)
- **Labels hardcodés en français dans `login.vue`** : `"Nom d'utilisateur"`, `"Mot de passe"`, `"Se connecter"` au lieu de `$t('auth.username')` etc. (les clés i18n existent dans `fr.json`)
---
## Verdict
La PR pose de bonnes bases architecturales (DDD, modules activables, sidebar dynamique). Les issues principales sont :
- **2 bugs fonctionnels** : sidebar `/suppliers` en 404 et state `useSidebar` jamais reset
- **3 incohérences config/doc** : ports PG et frontend changés sans MAJ CLAUDE.md, CHANGELOG mauvais projet
- **2 incohérences architecturales** : `CreateUserCommand` et `UserOutput` ne respectent pas les patterns introduits dans la PR
- **1 style** : indentation auth.vue
Aucun de ces problèmes ne bloque le merge mais les 2 bugs fonctionnels (issues 2 et 5) devraient être corrigés avant.

View File

@@ -30,7 +30,7 @@ services:
depends_on:
- db
ports:
- "3003:3003"
- "3004:3004"
restart: unless-stopped
nginx:
image: nginx:1.27-alpine

View File

@@ -0,0 +1,556 @@
# Ticket #343 - 1/5 - Entités Permission et Role (Backend)
## 1. Objectif
Ce ticket livre la base RBAC backend de l'epic en 5 tickets en remplacant le stockage historique des roles Symfony dans `User::$roles` par un modele metier explicite `Role` + `Permission` + rattachements utilisateur. Le resultat attendu est un socle de persistance et de synchronisation utilisable par les tickets suivants pour exposer l'API, brancher les voters et alimenter les interfaces. Le ticket couvre aussi la migration de donnees depuis la colonne JSON existante et l'outillage necessaire pour synchroniser les permissions declarees par les modules actifs.
## 2. Périmètre
### IN
- Creer l'entite `Role` avec `id`, `code`, `label`, `description`, `isSystem` et relation ManyToMany vers `Permission`.
- Creer l'entite `Permission` avec `id`, `code`, `label`, `module`, `orphan` et unicite sur `code`.
- Faire evoluer `User` avec une relation ManyToMany vers `Role`, une relation ManyToMany vers `Permission` pour les permissions directes et un booleen `is_admin`.
- Faire evoluer `User::getRoles()` pour rester compatible Symfony en retournant toujours `ROLE_USER` et `ROLE_ADMIN` si `is_admin = true`.
- Ajouter `User::getEffectivePermissions()` pour retourner l'union des codes de permissions provenant des roles et des permissions directes.
- Ajouter une methode statique `permissions()` sur `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` et definir le pattern a reproduire pour les autres modules.
- Ajouter une commande console `app:sync-permissions` transactionnelle, idempotente et non destructive avec gestion `orphan`.
- Ajouter une migration Doctrine modulaire Core qui cree les tables RBAC, migre les donnees depuis `user.roles`, cree les roles systeme `admin` et `user`, puis supprime la colonne JSON `roles`.
- Mettre a jour les fixtures Core pour creer les roles systeme et rattacher l'utilisateur admin au role `admin`.
- Ajouter une protection domaine empechant la suppression d'un role systeme via `SystemRoleDeletionException`.
- Integrer les decisions de hardening demandees: fetch `EAGER`, constantes partagees pour les codes systeme, synchronisation non destructive, commentaires PHP en francais, identifiants anglais, `declare(strict_types=1)`, colonnes PostgreSQL en minuscules.
### OUT
- Ticket `#344` : ressources API Platform, providers, processors et traduction HTTP de `SystemRoleDeletionException` vers `403`.
- Ticket `#345` : voter / authorisation applicative basee sur les permissions.
- Ticket `#346` : interfaces d'administration RBAC.
- Ticket `#347` : couche de traduction / UX des erreurs `403` et integration front complete.
## 3. Fichiers à créer
### Domaine - Entités
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Permission.php` : entite Doctrine de permission RBAC, code unique, module source et etat `orphan`.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/Role.php` : entite Doctrine de role RBAC avec relations vers permissions et garde de role systeme.
### Domaine - Repositories
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/PermissionRepositoryInterface.php` : contrat de lecture/ecriture des permissions pour la commande de sync et les fixtures.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/RoleRepositoryInterface.php` : contrat de lecture/ecriture des roles pour migration fonctionnelle, fixtures et usages futurs.
### Domaine - Exceptions
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Exception/SystemRoleDeletionException.php` : exception domaine levee si une suppression vise un role systeme.
### Infrastructure - Doctrine
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrinePermissionRepository.php` : implementation Doctrine de `PermissionRepositoryInterface`.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineRoleRepository.php` : implementation Doctrine de `RoleRepositoryInterface`.
### Infrastructure - Doctrine Migrations
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` : migration modulaire RBAC Core avec schema + migration de donnees + rollback minimal.
### Infrastructure - Console
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` : commande `app:sync-permissions` qui scanne les modules actifs et synchronise la table `permission`.
### Infrastructure - DataFixtures
- Aucun nouveau fichier necessaire si la logique reste dans le fixture existant.
### Constantes domaine
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` : constantes partagees `ADMIN_CODE = 'admin'` et `USER_CODE = 'user'`, utilisees a la fois par les fixtures et par la migration SQL. Place dans `Domain/Security/` (pas `ValueObject/` : ce n'est pas un VO, c'est un conteneur de constantes metier laissant de la place pour d'autres constantes de securite plus tard).
## 4. Fichiers à modifier
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` : supprimer le stockage JSON `roles`, ajouter `isAdmin`, `roles`, `directPermissions`, initialiser les collections, configurer les relations ManyToMany en `fetch=EAGER`, ajouter `getEffectivePermissions()` et adapter `getRoles()` / mutateurs.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/CoreModule.php` : ajouter une methode statique `public static function permissions(): array` qui declare les permissions natives du module Core et sert de reference pour les autres modules. Contenu initial exact :
```php
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'],
];
}
```
La cle `module` n'est PAS presente dans le payload : elle est auto-injectee par la commande de sync a partir de `CoreModule::ID`. Le code de permission doit obligatoirement commencer par `self::ID . '.'` sous peine d'echec de la sync (garde anti-typo).
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` : aucun changement attendu dans ce ticket. Les nouvelles relations `$roles`, `$directPermissions` sont chargees par Doctrine via leurs mappings `fetch=EAGER` declares sur l'entite. Si les tests d'integration revelent un lazy-load non voulu au refresh JWT ou a la desserialisation, ajouter une methode `findForSecurity(string $username): ?User` avec `leftJoin` + `addSelect` explicites sur `roles`, `roles.permissions`, `directPermissions`, et brancher le user provider dessus. A trancher par les tests, pas en prevention.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Repository/UserRepositoryInterface.php` : aucun changement dans ce ticket. Ajout eventuel de `findForSecurity()` uniquement si le cas ci-dessus se materialise.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` : remplacer l'usage de `setRoles(array)` par la creation des roles systeme, le rattachement des utilisateurs a ces roles et le positionnement de `is_admin`.
- `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php` : remplacer la gestion historique de `ROLE_ADMIN` par `setIsAdmin(true)` et rattachement au role systeme `admin` si l'option `--admin` est conservee.
- `/home/matthieu/dev_malio/Coltura/config/services.yaml` : ajouter 2 alias repository, aligne sur le pattern existant pour `UserRepositoryInterface` :
```yaml
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
```
La commande `SyncPermissionsCommand` est auto-configuree via `autoconfigure: true`, aucun binding manuel necessaire.
- `/home/matthieu/dev_malio/Coltura/config/modules.php` : aucun changement de contenu requis, mais la commande `app:sync-permissions` devra s'appuyer sur ce fichier comme source de verite des modules actifs.
## 5. Schéma cible — mappings Doctrine
Plutot que de prescrire du SQL verbatim (risque de divergence avec la strategie `#[ORM\GeneratedValue]` par defaut utilisee par `User`, qui genere des sequences PostgreSQL, pas des colonnes `GENERATED IDENTITY`), on decrit le schema cible par les attributs Doctrine. Le SQL effectif sera produit par `bin/console doctrine:migrations:diff` puis augmente manuellement du data-migration step (section 6).
Conventions :
- `declare(strict_types=1)` en tete de tous les fichiers.
- Identifiants de classe et proprietes en anglais, commentaires en francais (cf. `CLAUDE.md`).
- PostgreSQL : noms de colonnes en snake_case minuscules, Doctrine les deduit des proprietes camelCase (ex: `isSystem` → `is_system`).
- Les tables `user` et `role` sont des mots reserves PostgreSQL ; Doctrine les quote automatiquement via `#[ORM\Table(name: '`role`')]`.
### Entite `Permission`
```php
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(length: 100)]
private string $module;
#[ORM\Column(options: ['default' => false])]
private bool $orphan = false;
}
```
Contraintes fonctionnelles :
- `code` suit la convention `module.resource[.sub].action` (verifie par le sync command).
- `module` contient l'identifiant declare dans `*Module::ID`, auto-injecte par le sync.
- `orphan = true` signifie "permission conservee en base mais absente de la declaration courante".
### Entite `Role`
```php
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(name: 'is_system', options: ['default' => false])]
private bool $isSystem = false;
/** @var Collection<int, Permission> */
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
private Collection $permissions;
}
```
Contraintes fonctionnelles :
- `code` porte la cle metier stable du role (`admin`, `user`, ...).
- `isSystem = true` interdit la suppression via `Role::ensureDeletable()` au niveau domaine.
- `fetch: EAGER` sur `$permissions` : evite qu'un `User::getEffectivePermissions()` cascade du lazy-loading hors contexte EntityManager (refresh JWT, serialisation asynchrone).
### Evolution de l'entite `User`
- Suppression de la propriete `private array $roles = []` (et donc de la colonne `roles JSON`).
- Ajout :
```php
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
private bool $isAdmin = false;
/** @var Collection<int, Role> */
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
private Collection $roles;
/** @var Collection<int, Permission> */
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
private Collection $directPermissions;
```
- `$roles` et `$directPermissions` sont initialises en `ArrayCollection` dans le constructeur, comme toute collection Doctrine.
- `fetch: EAGER` sur les 3 associations : critique pour eviter des `getRoles()` silencieusement tronques pendant un refresh de token JWT (cf. risque section 11).
### Evolution de la table `user` (SQL final apres diff)
La migration introduira :
- `ALTER TABLE "user" ADD COLUMN is_admin BOOLEAN DEFAULT FALSE NOT NULL;`
- Creation de `user_role`, `user_permission` avec FKs `ON DELETE CASCADE`.
- Apres data-migration (section 6), `ALTER TABLE "user" DROP COLUMN roles;`.
Etat final attendu :
- La colonne historique `roles JSON NOT NULL` est supprimee.
- La compatibilite Symfony passe par `User::getRoles()` derivee de `$isAdmin`, plus aucune persistence framework.
- Les 3 associations `User::$roles`, `User::$directPermissions`, `Role::$permissions` sont explicitement EAGER.
## 6. Plan de migration Doctrine
La migration doit etre implementée dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` et executer `up()` dans cet ordre.
**Workflow recommande** :
1. Ecrire d'abord les entites `Permission`, `Role` et la mutation de `User` (section 5).
2. Lancer `bin/console doctrine:migrations:diff` qui genere le squelette SQL de structure (CREATE TABLE + FKs + DROP COLUMN).
3. Editer **manuellement** le fichier genere pour inserer le data-migration step entre la creation des tables et le `DROP COLUMN roles` — sinon les donnees admin sont perdues.
4. Le fichier final vit dans le chemin ci-dessus en respectant le namespace configure dans `doctrine_migrations.yaml`.
### `up()` - ordre recommande apres edition manuelle
1. Creer la colonne `"user".is_admin` avec `DEFAULT FALSE`.
2. Creer les tables `permission`, `"role"`, `role_permission`, `user_role`, `user_permission` et leurs indexes/foreign keys.
3. Inserer par SQL brut les roles systeme `admin` et `user` en s'appuyant sur les codes centralises dans `SystemRoles`.
4. Mettre a jour `"user".is_admin` a `TRUE` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
5. Inserer dans `user_role` le role `admin` pour les utilisateurs dont la colonne JSON `roles` contient `ROLE_ADMIN`.
6. Inserer dans `user_role` le role `user` pour les utilisateurs qui ne portent pas `ROLE_ADMIN`, y compris si `roles` vaut `NULL`, `[]` ou `["ROLE_USER"]`.
7. Verifier que les insertions utilisent `ON CONFLICT DO NOTHING` ou l'equivalent applicable afin de rester robustes face a une base deja partiellement migree sur un environnement de dev.
8. Supprimer la colonne `"user".roles` uniquement apres la migration de donnees.
### SQL de migration de donnees - logique precise
Detection admin :
```sql
UPDATE "user" u
SET is_admin = TRUE
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
);
```
Rattachement du role systeme `admin` :
```sql
INSERT INTO user_role (user_id, role_id)
SELECT u.id, r.id
FROM "user" u
CROSS JOIN "role" r
WHERE r.code = 'admin'
AND EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
)
ON CONFLICT DO NOTHING;
```
Rattachement du role systeme `user` :
```sql
INSERT INTO user_role (user_id, role_id)
SELECT u.id, r.id
FROM "user" u
CROSS JOIN "role" r
WHERE r.code = 'user'
AND NOT EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
)
ON CONFLICT DO NOTHING;
```
Cas couverts explicitement :
- `roles = NULL` : traite comme tableau vide, utilisateur non admin, rattache au role `user`.
- `roles = []` : utilisateur non admin, rattache au role `user`.
- `roles = ["ROLE_USER"]` : utilisateur non admin, rattache au role `user`.
- `roles = ["ROLE_ADMIN"]` : `is_admin = true`, rattache au role `admin`, pas de role `user`.
- `roles = ["ROLE_ADMIN", "ROLE_USER"]` : meme resultat que ci-dessus, sans doublon.
### Caveat : type reel de la colonne `user.roles`
Le mapping Doctrine actuel (`array` PHP → default) peut avoir genere une colonne `JSON` OU `TEXT` selon la version de Symfony/Doctrine. Le cast `::jsonb` fonctionne directement sur `JSON`, mais pas sur `TEXT`. **Avant d'executer la migration en prod**, verifier avec :
```bash
docker exec -it db-coltura psql -U malio -d coltura -c '\d "user"'
```
- Si `roles | json` : le SQL ci-dessus fonctionne tel quel.
- Si `roles | text` : remplacer `u.roles::jsonb` par `u.roles::text::jsonb` dans les 3 requetes.
- Si la colonne est deja `jsonb` (rare) : remplacer `u.roles::jsonb` par `u.roles`.
### `down()` - rollback minimal et coherent
1. Recreer la colonne `"user".roles` en `JSON NOT NULL DEFAULT '[]'`.
2. Rehydrater `"user".roles` avec `["ROLE_ADMIN"]` si `is_admin = true`, sinon `["ROLE_USER"]`.
3. Supprimer les foreign keys et tables de jointure `user_permission`, `user_role`, `role_permission`.
4. Supprimer les tables `permission` et `"role"`.
5. Supprimer la colonne `"user".is_admin`.
Le rollback ne restitue pas la granularite RBAC complete, ce qui est acceptable pour un `down()` technique, mais il doit restituer une application compatible avec le modele historique base sur `ROLE_ADMIN` / `ROLE_USER`.
## 7. Algorithme sync-permissions
La commande `app:sync-permissions` doit vivre dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php` et encapsuler toute l'operation dans une transaction Doctrine unique.
### Source de verite
- Le scan des modules actifs vient de `/home/matthieu/dev_malio/Coltura/config/modules.php`.
- Chaque classe module active peut exposer `public static function permissions(): array`.
- Par compatibilite montante, si une classe module n'expose pas encore `permissions()`, elle est traitee comme retournant `[]`.
### Format de declaration attendu
Chaque entree retournee par `*Module::permissions()` contient STRICTEMENT deux cles :
- `code` — string suivant la convention `module.resource[.sub].action`, par exemple `core.users.view` ou `commercial.quote.line.delete`.
- `label` — string lisible en francais, affichee dans l'UI d'administration des roles.
La cle `module` N'EST PAS dans le payload : elle est **auto-injectee** par la commande de sync a partir de `$moduleClass::ID`. Ainsi on ne peut pas declarer accidentellement une permission d'un autre module.
Garde anti-typo : le sync command verifie que chaque `code` commence obligatoirement par `$moduleClass::ID . '.'`. Si non, la commande echoue avec un message explicite citant la classe module et le code incrimine, sans toucher a la base.
### Pseudocode
```text
begin transaction
load active module classes from /home/matthieu/dev_malio/Coltura/config/modules.php
desired_permissions = empty map keyed by code
for each module class:
if method permissions() exists:
declared_permissions = module class::permissions()
else:
declared_permissions = []
for each declared permission in declared_permissions:
assert array_keys(permission) == ['code', 'label'] // erreur explicite sinon
assert str_starts_with(permission.code, module class::ID + '.') // erreur explicite sinon
normalized = {
code: permission.code,
label: permission.label,
module: module class::ID, // auto-injection
}
desired_permissions[code] = normalized
load existing permissions from database indexed by code
for each code in desired_permissions:
if code exists in database:
update label
update module
set orphan = false
else:
insert permission with orphan = false
for each database permission code not present in desired_permissions:
set orphan = true
flush
commit transaction
```
### Propriétés attendues
- Idempotente : deux executions consecutives sans changement de declarations ne doivent produire aucun delta metier.
- Non destructive : aucune suppression de ligne `permission`; les permissions disparues deviennent `orphan = true`.
- Reversible metier : une permission orpheline redeclaree repasse a `orphan = false` et voit ses metadonnees mises a jour.
- Transactionnelle : pas d'etat intermediaire visible si une validation ou un flush echoue.
## 8. Méthodes clés détaillées
### User
Signature :
```text
public function getRoles(): array
```
Retourne toujours `ROLE_USER`. Ajoute `ROLE_ADMIN` si `is_admin = true`. Ne lit plus aucune colonne JSON. Le resultat doit etre deduplique et stable pour Symfony Security.
Signature :
```text
public function getEffectivePermissions(): array
```
Retourne l'union des codes des permissions issues de `User::$roles[*]->permissions` et de `User::$directPermissions`. Le resultat est un tableau unique de chaines, sans doublon, trie de facon deterministe si possible pour des assertions de test stables.
### Role
Signature :
```text
public function addPermission(Permission $permission): self
```
Ajoute la permission a la collection si absente, sans doublon.
Signature :
```text
public function removePermission(Permission $permission): self
```
Retire la permission de la collection si presente.
Signature :
```text
public function ensureDeletable(): void
```
Leve `SystemRoleDeletionException` si `isSystem = true`. Cette garde doit vivre dans le domaine, meme si la traduction HTTP vers `403` est hors scope de ce ticket.
### Permission
Signature :
```text
public function markOrphan(): self
```
Passe `orphan` a `true` sans detruire la permission.
Signature :
```text
public function revive(string $label, string $module): self
```
Repasse `orphan` a `false` et remet a jour les metadonnees issues de la declaration modulaire.
## 9. Fixtures mises à jour
Le fichier cible reste `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php`.
### Principe cle : decouplage via `is_admin`
Le role `admin` n'a **pas besoin** de contenir "toutes les permissions" pour rendre l'admin techniquement tout-puissant : cette capacite vient du bypass `is_admin = true` dans le futur `PermissionVoter` (ticket #345). Le role `admin` est donc un **conteneur metier semantique** (il represente le bundle "administrateur") mais n'est pas fonctionnellement requis pour que l'admin puisse tout faire.
Consequence directe : les fixtures deviennent auto-suffisantes. Elles ne dependent plus d'un passage prealable de `app:sync-permissions`. `make db-reset && make fixtures` reste un one-shot.
### Jeu de donnees attendu
- Role systeme `admin` (`SystemRoles::ADMIN_CODE`)
- `code = admin`
- `label = Administrateur`
- `description = Role administrateur — bypass complet via is_admin`
- `isSystem = true`
- `permissions = []` — volontairement vide, le bypass fait tout le travail. Une fois `app:sync-permissions` passe, un admin pourra assigner via UI les permissions au role si besoin d'un scenario "quasi-admin sans bypass".
- Role systeme `user` (`SystemRoles::USER_CODE`)
- `code = user`
- `label = Utilisateur`
- `description = Role de base sans permission specifique`
- `isSystem = true`
- `permissions = []`
### Assignations utilisateurs
- `admin` (user) : `is_admin = true`, role `admin`
- `alice` : `is_admin = false`, role `user`
- `bob` : `is_admin = false`, role `user`
- Aucune permission directe (`directPermissions`) n'est prechargee dans ce ticket.
### Autonomie du workflow
`make db-reset && make fixtures` fonctionne sans passer par `app:sync-permissions` au prealable, car aucune fixture ne depend du contenu de la table `permission`. Optionnellement, apres chargement des fixtures, l'utilisateur peut executer `bin/console app:sync-permissions` pour peupler la table `permission` avec les declarations de `CoreModule::permissions()`, mais c'est une etape independante et optionnelle a ce stade.
## 10. Plan de tests PHPUnit
### Unitaires - domaine
- `User::getRoles()` retourne `['ROLE_USER']` quand `is_admin = false`.
- `User::getRoles()` retourne `['ROLE_USER', 'ROLE_ADMIN']` quand `is_admin = true`.
- `User::getEffectivePermissions()` fusionne permissions de roles et permissions directes sans doublon.
- `User::getEffectivePermissions()` retourne un tableau vide pour un utilisateur sans role ni permission directe.
- `Role::addPermission()` n'ajoute pas de doublon.
- `Role::removePermission()` retire correctement une permission existante.
- `Role::ensureDeletable()` leve `SystemRoleDeletionException` pour un role systeme.
- `Permission::markOrphan()` passe `orphan` a `true`.
- `Permission::revive()` remet `orphan` a `false` et met a jour `label` / `module`.
### Integration - persistence et console
- La commande `app:sync-permissions` cree les permissions declarees par `CoreModule::permissions()`.
- Deux executions successives de `app:sync-permissions` sur le meme jeu de modules sont idempotentes.
- Une permission supprimee d'une declaration modulaire n'est pas deletee mais marquee `orphan = true`.
- Une permission redeclaree apres etat orphelin est revivee avec `orphan = false`.
- Les repositories Doctrine chargent bien `User::$roles`, `User::$directPermissions` et `Role::$permissions` sans lazy loading hors EntityManager grace a `fetch=EAGER`.
- Les fixtures chargent les roles systeme et rattachent les utilisateurs attendus.
### Integration - migration
- `up()` sur une base contenant `roles = ["ROLE_ADMIN"]` cree `is_admin = true`, rattache le role `admin` et supprime la colonne JSON.
- `up()` sur une base contenant `roles = ["ROLE_USER"]` rattache le role `user` et laisse `is_admin = false`.
- `up()` sur une base contenant `roles = ["ROLE_ADMIN", "ROLE_USER"]` ne cree aucun doublon et conserve le comportement admin.
- `up()` sur une base contenant `roles = []` ou `NULL` rattache quand meme le role `user`.
- `down()` recree une colonne `roles` JSON exploitable et restaure `ROLE_ADMIN` ou `ROLE_USER` de facon coherente.
### Prerequis d'infrastructure de test
Les tests d'integration migration up/down exigent une base de test dediee avec un outillage pour jouer/rejouer les migrations. Verifier l'etat de l'infra avant d'ecrire ces tests :
- Si `make test` applique deja les migrations sur une base isolee : les tests peuvent etre ecrits en utilisant `KernelTestCase` + `EntityManager` + `MigrationRepository`.
- Sinon, ajouter `DAMADoctrineTestBundle` (transactionne chaque test) ou une recipe dediee `make test-migration` qui monte une base jetable puis lance les migrations.
- **Si l'outillage manque** : ne pas bloquer le ticket. Ecrire a la place un test SQL de bas niveau sur une base transactionnellement reinitialisee (via `BEGIN` / `ROLLBACK` a chaque cas) et poser une TODO explicite dans le ticket suivant pour normaliser l'infra de test migration.
## 11. Risques et points d'attention
- Risque de chargement paresseux pendant refresh JWT, serialisation ou security context hors EntityManager.
- Mitigation : imposer `fetch=EAGER` sur `User::$roles`, `User::$directPermissions` et `Role::$permissions`, puis le verifier par tests d'integration.
- Risque de perte de donnees pendant la suppression de la colonne `user.roles`.
- Mitigation : creer les roles systeme et inserer les jointures `user_role` avant tout `DROP COLUMN`, avec tests de migration sur etats mixtes.
- Risque de divergence entre migration SQL brute et fixtures sur les codes des roles systeme.
- Mitigation : centraliser `admin` et `user` dans `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Security/SystemRoles.php` et documenter que la migration doit reprendre ces valeurs telles quelles.
- Risque d'accumulation de permissions orphelines sur des environnements de dev ou apres refactors de codes.
- Mitigation : conserver `orphan = true` pour la non-destruction, mais ajouter un suivi explicite dans les tests et dans la documentation d'exploitation; une strategie de purge pourra etre traitee plus tard si necessaire.
- Risque de sync incoherente entre dev et prod si un module actif ne declare pas encore `permissions()`.
- Mitigation : traiter l'absence de methode comme `[]` pour la compatibilite immediate et documenter que chaque nouveau module devra ajouter `permissions()` dans les tickets suivants.
- Risque de cout SQL/ORM du `fetch=EAGER` quand un utilisateur porte beaucoup de roles et permissions.
- Mitigation : limiter pour l'instant le perimetre aux trois associations critiques et surveiller les requetes; un ajustement vers des requetes dediees pourra etre etudie si la volumetrie augmente.
- Risque de semantique confuse entre `is_admin` et role systeme `admin`.
- Mitigation : regle gravee a partir de ce ticket. `is_admin` est le SEUL levier technique de bypass — c'est lui qui fait qu'un admin peut tout faire, via le futur `PermissionVoter` (ticket #345). Le role `admin` est un **conteneur metier semantique** : il identifie visuellement les admins dans l'UI et laisse la porte ouverte a un scenario "quasi-admin sans bypass" (admin qui aurait beaucoup de permissions explicites mais pas le bypass). Les fixtures/migrations posent les deux (`is_admin = true` ET rattachement au role `admin`) pour le compte admin, mais la logique d'autorisation ne regarde QUE `is_admin` + les permissions effectives. Ne jamais coder `if ($user->hasRole('admin'))` : toujours `if ($user->isAdmin())` ou `is_granted('permission.code')`.
## 12. Ordre d'exécution recommandé
1. Creer `Permission`, `Role`, `SystemRoleDeletionException` et `SystemRoles`.
2. Creer `PermissionRepositoryInterface`, `RoleRepositoryInterface` et leurs implementations Doctrine.
3. Faire evoluer `/home/matthieu/dev_malio/Coltura/src/Module/Core/Domain/Entity/User.php` avec `is_admin`, `roles`, `directPermissions`, `getRoles()` et `getEffectivePermissions()`.
4. Ajouter `CoreModule::permissions()` et documenter le pattern de declaration statique pour les autres modules.
5. Ajouter la commande `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/SyncPermissionsCommand.php`.
6. Ecrire la migration `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Doctrine/Migrations/Version<timestamp>.php` avec schema + migration de donnees + down().
7. Mettre a jour `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/DataFixtures/AppFixtures.php` et `/home/matthieu/dev_malio/Coltura/src/Module/Core/Infrastructure/Console/CreateUserCommand.php`.
8. Ajouter les alias repository dans `/home/matthieu/dev_malio/Coltura/config/services.yaml`.
9. Ecrire les tests unitaires et d'integration couvrant domaine, sync, fixtures et migration.
## 13. Critères d'acceptation (DoD)
- Les entites `Role` et `Permission` existent dans le module Core avec mappings Doctrine valides et identifiants anglais.
- `User` ne persiste plus de colonne JSON `roles` apres migration, mais expose toujours un `getRoles()` compatible Symfony.
- `User::getEffectivePermissions()` retourne l'union sans doublon des permissions de roles et des permissions directes.
- `CoreModule` expose une methode statique `permissions()` servant de reference au mecanisme de sync.
- La commande `app:sync-permissions` est transactionnelle, idempotente, non destructive et gere correctement `orphan = true` / revival.
- Les roles systeme `admin` et `user` sont crees par la migration et par les fixtures avec `isSystem = true`.
- La migration convertit de facon sure les etats historiques `ROLE_ADMIN`, `ROLE_USER`, tableau vide, `NULL` et combinaisons mixtes sans perte de comptes.
- La suppression d'un role systeme leve `SystemRoleDeletionException` au niveau domaine.
- Les associations `User::$roles`, `User::$directPermissions` et `Role::$permissions` sont explicitement configurees en `fetch=EAGER` et ce point est verifie par tests.
- Les fixtures attribuent `is_admin = true` + role `admin` a l'utilisateur `admin`, et le role `user` aux utilisateurs standards.
- Le spec est compatible avec l'architecture modulaire actuelle basee sur `/home/matthieu/dev_malio/Coltura/config/modules.php` et n'introduit aucune resource API Platform ni voter dans ce ticket.

View File

@@ -0,0 +1,7 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<MalioSidebar
v-model="ui.sidebarCollapsed"
:sections="translatedSections"
>
<template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/>
</template>
<template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template>
</MalioSidebar>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<main
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-8 flex-shrink-0 bg-white sm:h-12"/>
<slot/>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {t} = useI18n()
const ui = useUiStore()
const {sections} = useSidebar()
const route = useRoute()
const translatedSections = computed(() =>
sections.value.map(section => ({
label: t(section.label),
icon: section.icon,
items: section.items.map(item => ({
label: t(item.label),
to: item.to,
})),
}))
)
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
useHead({
titleTemplate: (title) => title || 'Coltura',
})
</script>

View File

@@ -13,4 +13,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
if (isLogin && auth.isAuthenticated) {
return navigateTo('/')
}
if (auth.isAuthenticated) {
const { loaded, loadSidebar } = useSidebar()
if (!loaded.value) {
await loadSidebar()
}
}
})

View File

@@ -0,0 +1,18 @@
export default defineNuxtRouteMiddleware(async (to) => {
const auth = useAuthStore()
// Don't block routes for unauthenticated users — auth middleware handles them first.
if (!auth.isAuthenticated) {
return
}
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
if (!loaded.value) {
await loadSidebar()
}
if (isRouteDisabled(to.path)) {
return navigateTo('/')
}
})

View File

@@ -1,48 +0,0 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
<div class="flex h-full items-center justify-between">
<MalioButtonIcon
icon="mdi:menu"
aria-label="Menu"
variant="ghost"
icon-size="24"
button-class="lg:hidden text-white hover:bg-primary-600"
@click="ui.openMobileSidebar()"
/>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">Coltura</h1>
</div>
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
<div class="group relative flex gap-2 sm:gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-50 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="handleLogout"
>
Deconnexion
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
defineProps<{
user?: UserData | null
}>()
const auth = useAuthStore()
const ui = useUiStore()
async function handleLogout() {
await auth.logout()
await navigateTo('/login')
}
</script>

View File

@@ -1,52 +0,0 @@
<template>
<NuxtLink
:to="to"
class="group/link relative flex items-center transition-colors hover:text-primary-500"
:class="linkClasses"
:active-class="exact ? '' : activeClass"
:exact-active-class="exact ? activeClass : ''"
>
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
<span
v-if="!collapsed"
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
:class="sub ? 'text-sm' : 'text-md'"
>
{{ label }}
</span>
<div
v-if="collapsed"
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
>
{{ label }}
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
to: string
icon: string
label: string
collapsed: boolean
sub?: boolean
exact?: boolean
}>()
const activeClass = computed(() => {
if (props.collapsed) {
return '!text-primary-500 bg-primary-500/10'
}
return '!text-primary-500 bg-tertiary-500'
})
const linkClasses = computed(() => {
if (props.collapsed) {
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
}
if (props.sub) {
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
}
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
})
</script>

View File

@@ -1,203 +0,0 @@
import type { FetchOptions } from 'ofetch'
import { $fetch, FetchError } from 'ofetch'
import { useAuthStore } from '~/stores/auth'
export type AnyObject = Record<string, unknown>
export type ApiClient = {
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
}
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastOn401?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
toastErrorKey?: string
toastSuccessKey?: string
}
let isHandlingUnauthorized = false
export function useApi(): ApiClient {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
te?: (key: string) => boolean
}
| undefined
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') {
return data
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string) ||
(record.detail as string) ||
(record.message as string) ||
(record.error as string) ||
(record.title as string) ||
(record['hydra:title'] as string) ||
''
)
}
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
const methodErrorKeys: Record<string, string> = {
GET: 'errors.http.get',
POST: 'errors.http.post',
PUT: 'errors.http.put',
PATCH: 'errors.http.patch',
DELETE: 'errors.http.delete'
}
const client = $fetch.create({
baseURL,
retry: 0,
credentials: 'include',
onResponse({ options, response }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return
}
if (response?.status && response.status >= 400) {
return
}
const successKey = apiOptions?.toastSuccessKey
const successMessage =
apiOptions?.toastSuccessMessage ||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
if (successMessage) {
toast.success({
title: 'Succes',
message: successMessage
})
}
},
async onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (response?.status === 401) {
const requestUrl = typeof options?.url === 'string' ? options.url : ''
const isLoginCheck = requestUrl.includes('/login_check')
const isLogout = requestUrl.includes('/logout')
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
if (shouldToast401) {
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
auth.clearSession()
await navigateTo('/login')
isHandlingUnauthorized = false
}
}
return
}
if (apiOptions?.toast === false) {
return
}
const method =
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
const defaultKey = methodErrorKeys[method]
const defaultMessage =
defaultKey && te(defaultKey) ? t(defaultKey) : ''
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
defaultMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
})
function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined)
if (!isFormData) {
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
}
return client<T>(url, { ...options, method, headers })
}
return {
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('GET', url, { ...options, query })
},
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('POST', url, { ...options, body })
},
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PUT', url, { ...options, body })
},
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PATCH', url, { ...options, body })
},
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('DELETE', url, { ...options, query })
}
}
}

View File

@@ -0,0 +1,60 @@
import nuxt from '@nuxt/eslint-config'
export default await nuxt(
{
features: {
stylistic: false,
typescript: true,
nuxt: {
sortConfigKeys: false,
},
},
dirs: {
root: ['.', './app'],
},
},
{
name: 'coltura/custom-overrides',
rules: {
// Indentation 4 espaces (convention CLAUDE.md)
'vue/html-indent': ['error', 4],
indent: ['error', 4, { SwitchCase: 1 }],
// Vue — relaxed
'vue/multi-word-component-names': 'off',
'vue/no-multiple-template-root': 'off',
'vue/require-default-prop': 'off',
'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/multiline-html-element-content-newline': 'off',
'vue/attributes-order': 'off',
'vue/v-on-event-hyphenation': 'off',
// Console — allow console.error only
'no-console': ['warn', { allow: ['error'] }],
// Unused vars — warn, ignore underscore-prefixed
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['warn', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
// TypeScript — progressive strictness
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-dynamic-delete': 'off',
'@typescript-eslint/no-invalid-void-type': 'off',
// Formatting — leave to stylistic tools
'require-await': 'off',
'comma-dangle': 'off',
curly: 'off',
semi: 'off',
quotes: 'off',
'no-trailing-spaces': 'off',
'no-multiple-empty-lines': 'off',
'no-irregular-whitespace': 'off',
},
},
)

View File

@@ -12,14 +12,26 @@
"no": "Non",
"actions": "Actions"
},
"nav": {
"dashboard": "Tableau de bord",
"admin": "Administration"
"sidebar": {
"general": {
"section": "Général",
"dashboard": "Tableau de bord",
"admin": "Administration",
"logout": "Déconnexion"
},
"commercial": {
"section": "Commercial",
"suppliers": "Répertoire fournisseurs"
}
},
"dashboard": {
"title": "Tableau de bord",
"welcome": "Bienvenue sur Coltura"
},
"commercial": {
"title": "Commercial",
"welcome": "Module Commercial"
},
"auth": {
"login": "Connexion",
"logout": "Deconnexion",
@@ -29,7 +41,7 @@
"errors": {
"auth": {
"login": "Identifiants invalides",
"session": "Session expir\u00e9e",
"session": "Session expirée",
"logout": "Erreur lors de la deconnexion"
},
"http": {

View File

@@ -1,7 +0,0 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>
</div>
</template>

View File

@@ -1,111 +0,0 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="[
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<span v-if="!sidebarIsCollapsed" class="px-4 py-3 text-lg font-bold text-white">
Coltura
</span>
<span v-else class="px-2 py-3 text-sm font-bold text-white">
C
</span>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<SidebarLink
to="/"
icon="mdi:view-dashboard-outline"
:label="$t('nav.dashboard')"
:collapsed="sidebarIsCollapsed"
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
@click="ui.closeMobileSidebar()"
/>
<SidebarLink
to="/admin"
icon="mdi:cog-outline"
:label="$t('nav.admin')"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
</nav>
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold text-white">v {{ version }}</p>
</div>
<!-- Collapse toggle button -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Reduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
<main 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-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppVersion } from '~/composables/useAppVersion'
const auth = useAuthStore()
const ui = useUiStore()
const {version} = useAppVersion()
const route = useRoute()
const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false
return ui.sidebarCollapsed
})
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
useHead({
titleTemplate: (title) => title || 'Coltura',
})
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1 @@
export default defineNuxtConfig({})

View File

@@ -0,0 +1,12 @@
<template>
<div>
<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>
<script setup lang="ts">
const { t } = useI18n()
useHead({ title: t('commercial.title') })
</script>

View File

@@ -0,0 +1 @@
export default defineNuxtConfig({})

View File

@@ -1,10 +1,10 @@
<template>
<div class="mx-auto w-full max-w-lg">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
<img src="/coltura.png" alt="Logo" class="w-[150px]"/>
</span>
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
<img src="/LOGO_MALIO.png" alt="Logo" class="w-[150px]"/>
</span>
<form
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
@@ -13,22 +13,16 @@
label="Nom d'utilisateur"
autocomplete="username"
group-class="mt-0"
inputClass="w-full"
input-class="w-full"
v-model="username"
/>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<MalioInputPassword
v-model="password"
label="Mot de passe"
autocomplete="current-password"
input-class="w-full"
/>
<MalioButton
label="Se connecter"

View File

@@ -0,0 +1,18 @@
<template>
<div class="flex h-full items-center justify-center">
<p class="text-neutral-500">{{ $t('auth.logout') }}...</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const auth = useAuthStore()
const { resetSidebar } = useSidebar()
onMounted(async () => {
await auth.logout()
resetSidebar()
await navigateTo('/login')
})
</script>

View File

@@ -1,14 +1,29 @@
import { readdirSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
// Auto-detect module layers: every directory under frontend/modules/ becomes a Nuxt layer.
const modulesDir = resolve(__dirname, 'modules')
const moduleLayers = existsSync(modulesDir)
? readdirSync(modulesDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => `./modules/${d.name}`)
: []
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
srcDir: '.',
css: ['~/assets/css/main.css'],
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
},
extends: ['@malio/layer-ui'],
extends: [
'@malio/layer-ui',
...moduleLayers,
],
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
@@ -22,11 +37,22 @@ export default defineNuxtConfig({
}
},
devServer: {
port: 3003,
port: 3004,
},
dir: {
layouts: 'app/layouts',
middleware: 'app/middleware',
},
components: [
{path: '~/components', pathPrefix: false},
{path: '~/shared/components', pathPrefix: false},
],
imports: {
dirs: [
'shared/composables',
'shared/utils',
'shared/stores',
],
},
vite: {
server: {
allowedHosts: true,

29746
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,12 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"dependencies": {
"@malio/layer-ui": "^1.2.2",
"@malio/layer-ui": "^1.2.3",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
@@ -21,5 +23,13 @@
"pinia": "^3.0.4",
"vue": "^3.5.29",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxt/eslint-config": "^1.9.0",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
"vue-eslint-parser": "^10.2.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -0,0 +1,202 @@
import type { FetchOptions , FetchError } from 'ofetch'
import { $fetch } from 'ofetch'
export type AnyObject = Record<string, unknown>
export type ApiClient = {
get<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
post<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
put<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
patch<T>(url: string, body?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
delete<T>(url: string, query?: AnyObject, options?: ApiFetchOptions<'json'>): Promise<T>
}
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastOn401?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
toastErrorKey?: string
toastSuccessKey?: string
}
let isHandlingUnauthorized = false
export function useApi(): ApiClient {
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
const toast = useToast()
const auth = useAuthStore()
const nuxtApp = useNuxtApp()
const i18n = nuxtApp.$i18n as
| {
t: (key: string) => string
te?: (key: string) => boolean
}
| undefined
const t = (key: string) => (i18n?.t ? String(i18n.t(key)) : key)
const te = (key: string) => (i18n?.te ? i18n.te(key) : false)
function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data
if (typeof data === 'string') {
return data
}
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
return (
(record['hydra:description'] as string) ||
(record.detail as string) ||
(record.message as string) ||
(record.error as string) ||
(record.title as string) ||
(record['hydra:title'] as string) ||
''
)
}
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
const methodErrorKeys: Record<string, string> = {
GET: 'errors.http.get',
POST: 'errors.http.post',
PUT: 'errors.http.put',
PATCH: 'errors.http.patch',
DELETE: 'errors.http.delete'
}
const client = $fetch.create({
baseURL,
retry: 0,
credentials: 'include',
onResponse({ options, response }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return
}
if (response?.status && response.status >= 400) {
return
}
const successKey = apiOptions?.toastSuccessKey
const successMessage =
apiOptions?.toastSuccessMessage ||
(successKey ? (te(successKey) ? t(successKey) : successKey) : '')
if (successMessage) {
toast.success({
title: 'Succes',
message: successMessage
})
}
},
async onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (response?.status === 401) {
const requestUrl = typeof options?.url === 'string' ? options.url : ''
const isLoginCheck = requestUrl.includes('/login_check')
const isLogout = requestUrl.includes('/logout')
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
if (shouldToast401) {
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
auth.clearSession()
await navigateTo('/login')
isHandlingUnauthorized = false
}
}
return
}
if (apiOptions?.toast === false) {
return
}
const method =
typeof options?.method === 'string' ? options.method.toUpperCase() : 'GET'
const defaultKey = methodErrorKeys[method]
const defaultMessage =
defaultKey && te(defaultKey) ? t(defaultKey) : ''
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
defaultMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
})
function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
options: ApiFetchOptions<'json'> = {}
) {
const needsJsonBody = method === 'POST' || method === 'PUT'
const needsMergePatch = method === 'PATCH'
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const headers = new Headers(options.headers as HeadersInit | undefined)
if (!isFormData) {
if (needsMergePatch && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/merge-patch+json')
} else if (needsJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
}
return client<T>(url, { ...options, method, headers })
}
return {
get<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('GET', url, { ...options, query })
},
post<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('POST', url, { ...options, body })
},
put<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PUT', url, { ...options, body })
},
patch<T>(url: string, body: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('PATCH', url, { ...options, body })
},
delete<T>(url: string, query: AnyObject = {}, options: ApiFetchOptions<'json'> = {}) {
return request<T>('DELETE', url, { ...options, query })
}
}
}

View File

@@ -0,0 +1,46 @@
import type { SidebarSection } from '~/shared/types'
const sections = ref<SidebarSection[]>([])
const disabledRoutes = ref<string[]>([])
const loaded = ref(false)
export function useSidebar() {
async function loadSidebar() {
try {
const api = useApi()
const data = await api.get<{ sections: SidebarSection[]; disabledRoutes: string[] }>(
'/sidebar',
{},
{ toast: false }
)
sections.value = data.sections ?? []
disabledRoutes.value = data.disabledRoutes ?? []
loaded.value = true
} catch {
sections.value = []
disabledRoutes.value = []
loaded.value = true
}
}
function isRouteDisabled(path: string): boolean {
return disabledRoutes.value.some(
disabled => path === disabled || path.startsWith(disabled + '/')
)
}
function resetSidebar() {
sections.value = []
disabledRoutes.value = []
loaded.value = false
}
return {
sections,
disabledRoutes,
loaded,
loadSidebar,
resetSidebar,
isRouteDisabled,
}
}

View File

@@ -1,4 +1,4 @@
import type { UserData } from './dto/user-data'
import type { UserData } from '~/shared/types/user-data'
export function getCurrentUser() {
const api = useApi()

View File

@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import type { UserData } from '~/services/dto/user-data'
import { getCurrentUser, login, logout } from '~/services/auth'
import type { UserData } from '~/shared/types/user-data'
import { getCurrentUser, login, logout } from '~/shared/services/auth'
export const useAuthStore = defineStore('auth', {
state: () => ({

View File

@@ -0,0 +1,11 @@
export interface SidebarItem {
label: string
to: string
icon: string
}
export interface SidebarSection {
label: string
icon: string
items: SidebarItem[]
}

View File

@@ -5,5 +5,5 @@ APP_USER=www-data
POSTGRES_DB=coltura
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5436
POSTGRES_PORT=5437
XDEBUG_CLIENT_HOST=host.docker.internal

View File

@@ -2,9 +2,7 @@ APP_ENV=prod
APP_DEBUG=0
APP_SECRET=CHANGE_ME_IN_PRODUCTION
POSTGRES_DB=coltura
POSTGRES_USER=coltura
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
DATABASE_URL="postgresql://coltura:CHANGE_ME@host.docker.internal:5432/coltura?serverVersion=16&charset=utf8"
JWT_PASSPHRASE=CHANGE_ME_IN_PRODUCTION
JWT_COOKIE_SECURE=1

View File

@@ -1,88 +1,84 @@
ARG DOCKER_PHP_VERSION=8.4.6
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
FROM php:${DOCKER_PHP_VERSION}-fpm-bullseye AS php-base
ARG DOCKER_NODE_VERSION=24.12.0
ENV DOCKER_NODE_VERSION="${DOCKER_NODE_VERSION}"
# Installer les dépendances et extensions PHP nécessaires
RUN apt-get update && apt-get install -y \
libicu-dev \
libpq-dev \
libpng-dev \
libzip-dev \
libxml2-dev \
ca-certificates \
gnupg \
libbz2-dev \
libgmp-dev \
libldap2-dev \
libonig-dev \
libsodium-dev \
libxslt1-dev \
zlib1g-dev \
libssl-dev \
wget \
git \
unzip \
&& docker-php-ext-install -j$(nproc) \
intl \
zip \
bcmath \
bz2 \
calendar \
exif \
gd \
gettext \
gmp \
ldap \
pcntl \
pdo_pgsql \
soap \
sockets \
sysvsem \
xsl \
&& docker-php-ext-enable opcache \
&& rm -rf /var/lib/apt/lists/* /tmp/*
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# Installation de node
RUN wget -qO- "https://nodejs.org/dist/v${DOCKER_NODE_VERSION}/node-v${DOCKER_NODE_VERSION}-linux-x64.tar.xz" | tar xJC /tmp/ && \
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/bin /usr/ && \
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/include /usr/ && \
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/lib /usr/ && \
cp -r /tmp/node-v${DOCKER_NODE_VERSION}-linux-x64/share /usr/ && \
rm -rf /tmp/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Installation de composer
RUN curl --insecure https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
WORKDIR /var/www/html
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
ENV APP_ENV=prod
RUN composer dump-autoload --optimize --no-dev
# Copier les fichiers projet
COPY . /var/www/html
# --- Stage 2: Build frontend ---
FROM node:22-alpine AS frontend-build
# Installation des dépendances PHP (prod)
RUN composer install --no-dev --optimize-autoloader --no-interaction
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# Génération des clés JWT si absentes
RUN php bin/console lexik:jwt:generate-keypair --skip-if-exists
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# Build du frontend
RUN cd frontend && npm ci && npm run build:dist && rm -rf node_modules
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/coltura.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Maintenance page
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN chown -R www-data:www-data /var/www/html/var /var/www/html/frontend/dist
RUN mkdir -p /var/www/html/var /var/www/html/config/jwt \
&& chown -R www-data:www-data /var/www/html/var
# PHP prod config
COPY infra/prod/php-prod.ini /usr/local/etc/php/php.ini
WORKDIR /var/www/html
EXPOSE 80
EXPOSE 9000
# ── Nginx stage ──
FROM nginx:1.27-alpine AS nginx
COPY infra/prod/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=php-base /var/www/html/public /var/www/html/public
COPY --from=php-base /var/www/html/frontend/dist /var/www/html/frontend/dist
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

39
infra/prod/deploy.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export COLTURA_IMAGE_TAG="$TAG"
echo "==> Deploying coltura:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
sudo docker compose pull
echo "==> Starting container..."
sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Extracting maintenance page..."
mkdir -p public
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
sudo chown -R www-data:www-data public
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -1,42 +1,16 @@
services:
php:
container_name: php-coltura-fpm
build:
context: ../../
dockerfile: infra/prod/Dockerfile
target: php-base
environment:
APP_ENV: prod
APP_DEBUG: 0
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
volumes:
- uploads_data:/var/www/html/var/uploads
depends_on:
- db
restart: unless-stopped
nginx:
container_name: nginx-coltura
build:
context: ../../
dockerfile: infra/prod/Dockerfile
target: nginx
depends_on:
- php
app:
image: gitea.malio.fr/malio-dev/coltura:${COLTURA_IMAGE_TAG:-latest}
container_name: coltura-app
env_file: .env
ports:
- "8086:80"
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pg_data:/var/lib/postgresql/data
- ./config/jwt:/var/www/html/config/jwt:ro
- coltura_logs:/var/www/html/var/log
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
volumes:
pg_data:
uploads_data:
coltura_logs:

View File

@@ -21,7 +21,7 @@ server {
}
location / {
proxy_pass http://127.0.0.1:8083;
proxy_pass http://127.0.0.1:8086;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -2,16 +2,24 @@ server {
listen 80;
server_name _;
root /var/www/html/frontend/dist;
root /var/www/html/frontend/.output/public;
index index.html;
client_max_body_size 55m;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
@@ -19,19 +27,15 @@ server {
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass php:9000;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
fastcgi_pass 127.0.0.1:9000;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass php:9000;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

View File

@@ -53,6 +53,12 @@ build-nuxtJS:
dev-nuxt:
$(EXEC_PHP) sh -c "cd frontend && npm run dev"
nuxt-lint:
$(EXEC_PHP) sh -c "cd frontend && npm run lint"
nuxt-lint-fix:
$(EXEC_PHP) sh -c "cd frontend && npm run lint:fix"
delete_built_dir:
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Migration RBAC - Task 5.
*
* Ce que fait cette migration :
* - Cree les tables du modele RBAC relationnel : permission, role,
* role_permission, user_role et user_permission.
* - Ajoute la colonne "user".is_admin (booleen, defaut false) qui sert de
* bypass complet pour les super-administrateurs.
* - Migre les donnees existantes depuis la colonne JSON "user".roles vers
* le nouveau modele relationnel : les users ayant ROLE_ADMIN passent a
* is_admin = TRUE et sont rattaches au role systeme 'admin', les autres
* sont rattaches au role systeme 'user'.
* - Supprime enfin la colonne "user".roles devenue obsolete.
*
* Ordonnancement critique :
* Les INSERT de data-migration DOIVENT s'executer AVANT le DROP COLUMN
* "user".roles, sinon l'information d'appartenance a ROLE_ADMIN est perdue.
* L'ordre respecte donc strictement : creation des tables -> ADD is_admin
* -> seed des roles systeme -> migration des donnees -> DROP roles.
*
* Dependance avec Task 6 (fixtures) :
* Les fixtures applicatives reposent sur l'existence des roles systeme
* 'admin' et 'user' seedes ici par SQL brut. Cette migration est donc
* auto-suffisante et n'a pas besoin que les fixtures soient executees.
*/
final class Version20260414150034 extends AbstractMigration
{
public function getDescription(): string
{
return 'RBAC : tables permission/role + jointures + is_admin + migration des donnees depuis user.roles';
}
public function up(Schema $schema): void
{
// 1) Creation des tables RBAC (permission, role, jointures).
$this->addSql(<<<'SQL'
CREATE TABLE permission (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(255) NOT NULL,
label VARCHAR(255) NOT NULL,
module VARCHAR(100) NOT NULL,
orphan BOOLEAN DEFAULT false NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE INDEX idx_permission_module ON permission (module)');
$this->addSql('CREATE INDEX idx_permission_orphan ON permission (orphan)');
$this->addSql('CREATE UNIQUE INDEX uniq_permission_code ON permission (code)');
$this->addSql(<<<'SQL'
CREATE TABLE "role" (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
code VARCHAR(100) NOT NULL,
label VARCHAR(255) NOT NULL,
description TEXT DEFAULT NULL,
is_system BOOLEAN DEFAULT false NOT NULL,
PRIMARY KEY (id)
)
SQL);
$this->addSql('CREATE INDEX idx_role_is_system ON "role" (is_system)');
$this->addSql('CREATE UNIQUE INDEX uniq_role_code ON "role" (code)');
$this->addSql(<<<'SQL'
CREATE TABLE role_permission (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id)
)
SQL);
$this->addSql('CREATE INDEX IDX_6F7DF886D60322AC ON role_permission (role_id)');
$this->addSql('CREATE INDEX IDX_6F7DF886FED90CCA ON role_permission (permission_id)');
$this->addSql(<<<'SQL'
CREATE TABLE user_role (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id)
)
SQL);
$this->addSql('CREATE INDEX IDX_2DE8C6A3A76ED395 ON user_role (user_id)');
$this->addSql('CREATE INDEX IDX_2DE8C6A3D60322AC ON user_role (role_id)');
$this->addSql(<<<'SQL'
CREATE TABLE user_permission (
user_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (user_id, permission_id)
)
SQL);
$this->addSql('CREATE INDEX IDX_472E5446A76ED395 ON user_permission (user_id)');
$this->addSql('CREATE INDEX IDX_472E5446FED90CCA ON user_permission (permission_id)');
$this->addSql(<<<'SQL'
ALTER TABLE
role_permission
ADD
CONSTRAINT FK_6F7DF886D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
role_permission
ADD
CONSTRAINT FK_6F7DF886FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
user_role
ADD
CONSTRAINT FK_2DE8C6A3A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
user_role
ADD
CONSTRAINT FK_2DE8C6A3D60322AC FOREIGN KEY (role_id) REFERENCES "role" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
user_permission
ADD
CONSTRAINT FK_472E5446A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE
user_permission
ADD
CONSTRAINT FK_472E5446FED90CCA FOREIGN KEY (permission_id) REFERENCES permission (id) ON DELETE CASCADE
SQL);
// 2) Ajout de la colonne is_admin sur "user" (avant la data-migration
// qui en a besoin pour marquer les super-admins).
$this->addSql('ALTER TABLE "user" ADD is_admin BOOLEAN DEFAULT false NOT NULL');
// 3) Seed des roles systeme avant toute migration de donnees utilisateurs.
// Les codes sont centralises dans App\Module\Core\Domain\Security\SystemRoles
// mais dupliques ici volontairement pour que la migration reste auto-suffisante.
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('admin', 'Administrateur', 'Role administrateur - bypass complet via is_admin', TRUE)");
$this->addSql("INSERT INTO \"role\" (code, label, description, is_system) VALUES ('user', 'Utilisateur', 'Role de base sans permission specifique', TRUE)");
// 4) Bascule is_admin a TRUE pour tout user dont le JSON roles contient ROLE_ADMIN.
$this->addSql(<<<'SQL'
UPDATE "user" u
SET is_admin = TRUE
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
)
SQL);
// 5) Rattachement des admins au role systeme 'admin'.
$this->addSql(<<<'SQL'
INSERT INTO user_role (user_id, role_id)
SELECT u.id, r.id
FROM "user" u
CROSS JOIN "role" r
WHERE r.code = 'admin'
AND EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
)
ON CONFLICT DO NOTHING
SQL);
// 6) Rattachement des autres users (y compris roles vide ou NULL) au role 'user'.
$this->addSql(<<<'SQL'
INSERT INTO user_role (user_id, role_id)
SELECT u.id, r.id
FROM "user" u
CROSS JOIN "role" r
WHERE r.code = 'user'
AND NOT EXISTS (
SELECT 1
FROM jsonb_array_elements_text(COALESCE(u.roles::jsonb, '[]'::jsonb)) AS role_code
WHERE role_code = 'ROLE_ADMIN'
)
ON CONFLICT DO NOTHING
SQL);
// 7) Drop de la colonne "user".roles (DOIT etre la derniere instruction,
// apres la migration des donnees qui la lit).
$this->addSql('ALTER TABLE "user" DROP roles');
}
public function down(Schema $schema): void
{
// 1) Recreation de la colonne roles (avec un defaut pour permettre le
// backfill). Le NOT NULL est conserve comme dans le schema d'origine.
$this->addSql('ALTER TABLE "user" ADD roles JSON NOT NULL DEFAULT \'[]\'');
// 2) Rehydratation du JSON roles depuis is_admin avant de perdre la colonne.
$this->addSql(<<<'SQL'
UPDATE "user"
SET roles = CASE
WHEN is_admin THEN '["ROLE_ADMIN"]'::json
ELSE '["ROLE_USER"]'::json
END
SQL);
// 3) Drop des FK puis des tables de jointure (enfants d'abord).
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886D60322AC');
$this->addSql('ALTER TABLE role_permission DROP CONSTRAINT FK_6F7DF886FED90CCA');
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3A76ED395');
$this->addSql('ALTER TABLE user_role DROP CONSTRAINT FK_2DE8C6A3D60322AC');
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446A76ED395');
$this->addSql('ALTER TABLE user_permission DROP CONSTRAINT FK_472E5446FED90CCA');
$this->addSql('DROP TABLE user_permission');
$this->addSql('DROP TABLE user_role');
$this->addSql('DROP TABLE role_permission');
// 4) Drop des tables parentes permission et role.
$this->addSql('DROP TABLE permission');
$this->addSql('DROP TABLE "role"');
// 5) Drop de is_admin, la colonne roles ayant ete rehydratee en amont.
$this->addSql('ALTER TABLE "user" DROP is_admin');
}
}

View File

@@ -24,6 +24,16 @@ else
fi
echo "--- php-cs-fixer pre commit hook finish---"
echo "--- eslint pre commit hook start ---"
make nuxt-lint
ESLINT_RESULT=$?
if [ $ESLINT_RESULT -ne 0 ]; then
echo "ESLint failed. Aborting commit."
exit 1
fi
echo "--- eslint pre commit hook finished ---"
echo "--- phpunit pre commit hook start ---"
make test
PHPUNIT_RESULT=$?

View File

@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
public function load(ObjectManager $manager): void
{
$admin = new User();
$admin->setUsername('admin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setRoles(['ROLE_USER']);
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setRoles(['ROLE_USER']);
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$manager->persist($bob);
$manager->flush();
}
}

View File

View File

@@ -1,153 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Api\Auth\State\MeProvider;
use App\Api\Auth\State\UserPasswordHasherProcessor;
use App\Repository\UserRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/me',
provider: MeProvider::class,
normalizationContext: ['groups' => ['me:read']],
),
new Get(
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
normalizationContext: ['groups' => ['user:list']],
),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
denormalizationContext: ['groups' => ['user:write']],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null;
/** @var list<string> */
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private array $roles = [];
#[ORM\Column]
private ?string $password = null;
#[Groups(['user:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): static
{
$this->username = $username;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->username;
}
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
}
/** @param list<string> $roles */
public function setRoles(array $roles): static
{
$this->roles = $roles;
return $this;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Commercial;
final class CommercialModule
{
public const string ID = 'commercial';
public const string LABEL = 'Commercial';
public const bool REQUIRED = false;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\DTO;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
final readonly class UserOutput
{
public function __construct(
public ?int $id,
public string $username,
/** @var list<string> */
public array $roles,
public ?DateTimeImmutable $createdAt,
) {}
public static function fromEntity(User $user): self
{
return new self(
id: $user->getId(),
username: $user->getUsername(),
roles: $user->getRoles(),
createdAt: $user->getCreatedAt(),
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Module\Core;
final class CoreModule
{
public const string ID = 'core';
public const string LABEL = 'Core';
public const bool REQUIRED = true;
/**
* Liste declarative des permissions RBAC exposees par le module Core.
*
* Consommee par la commande `app:sync-permissions` (SyncPermissionsCommand)
* qui se charge d'upserter ces entrees dans la table `permission`, de
* reactiver les codes precedemment marques orphelins et de marquer comme
* orphelins ceux qui ont disparu du code source.
*
* La cle `module` est auto-injectee par le sync command a partir de
* `self::ID`, il est donc inutile de la repeter dans chaque entree.
*
* Convention de nommage des codes : `module.resource[.sub].action` en
* snake_case, le prefixe module devant correspondre exactement a
* `self::ID` (verifie par la commande de synchronisation).
*
* @return array<int, array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gerer les utilisateurs (creer, editer, supprimer)'],
['code' => 'core.roles.manage', 'label' => 'Gerer les roles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Voir la liste des permissions'],
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(length: 100)]
private string $module;
#[ORM\Column(options: ['default' => false])]
private bool $orphan = false;
/**
* Invariants : une permission doit avoir un code non vide respectant la
* convention "module.resource[.sub].action" (donc contenir au moins un
* point), un libelle non vide et un module proprietaire non vide. Ces
* garde-fous evitent la persistence de lignes incoherentes si un appelant
* (fixture, commande de synchro, import) oublie un champ ou passe une
* chaine vide.
*/
public function __construct(string $code, string $label, string $module)
{
if ('' === $code) {
throw new InvalidArgumentException('Le code de permission ne peut pas etre vide.');
}
if (!str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Le code de permission "%s" ne respecte pas la convention "module.resource[.sub].action".', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libelle de permission ne peut pas etre vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module proprietaire de la permission ne peut pas etre vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
/**
* Marque la permission comme orpheline : son code n'est plus declare par
* aucun module. Elle reste en base pour preserver les assignations et
* permettre une reactivation ulterieure, mais doit etre ignoree par les
* verifications d'autorisation.
*/
public function markOrphan(): static
{
$this->orphan = true;
return $this;
}
/**
* Reactive une permission precedemment orpheline : son code reapparait
* dans le code source d'un module. Equivaut a updateMetadata() suivi d'un
* clearing du flag orphan ; on delegue a updateMetadata() pour ne pas
* dupliquer la logique d'affectation des metadonnees.
*/
public function revive(string $label, string $module): static
{
$this->updateMetadata($label, $module);
$this->orphan = false;
return $this;
}
/**
* Met a jour les metadonnees d'une permission active sans toucher a son
* statut d'orphelin. Utilise par la commande de synchronisation lorsque
* seul le libelle ou le module proprietaire a change cote code.
*/
public function updateMetadata(string $label, string $module): static
{
$this->label = $label;
$this->module = $module;
return $this;
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
/**
* Role RBAC : groupe nomme de permissions assignable a un utilisateur.
*
* Un role peut etre "systeme" (cree et protege par la plateforme) ou
* "personnalise" (cree par un administrateur). Seuls les roles personnalises
* peuvent etre supprimes.
*/
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 100)]
private string $code;
#[ORM\Column(length: 255)]
private string $label;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
#[ORM\Column(name: 'is_system', options: ['default' => false])]
private bool $isSystem = false;
/** @var Collection<int, Permission> */
// Choix deliberé de fetch: 'EAGER' (durcissement, pas oubli de perf) :
// - Evite un lazy-load silencieux pendant un refresh de token JWT ou une
// serialisation hors contexte EntityManager (voir ticket #343, section
// 11 risque #1) ou la collection serait inaccessible et provoquerait
// une erreur opaque.
// - Compromis accepte : surcout SQL volontaire, acceptable a l'echelle
// d'un CRM/ERP PME ou un role porte quelques dizaines de permissions.
// - Si la volumetrie augmente significativement : revoir vers une
// projection cachee (ticket a ouvrir a ce moment-la).
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
private Collection $permissions;
public function __construct(string $code, string $label, bool $isSystem = false, ?string $description = null)
{
$this->code = $code;
$this->label = $label;
$this->isSystem = $isSystem;
$this->description = $description;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function isSystem(): bool
{
return $this->isSystem;
}
/** @return Collection<int, Permission> */
public function getPermissions(): Collection
{
return $this->permissions;
}
/**
* Met a jour le libelle affichable du role. Le code reste immuable pour
* garantir la stabilite des references cote fixtures et migrations.
*/
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
/**
* Met a jour la description libre du role (champ documentaire).
*/
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
/**
* Ajoute une permission au role. Idempotent : ajouter deux fois la meme
* permission n'entraine pas de doublon dans la collection.
*/
public function addPermission(Permission $permission): static
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
return $this;
}
/**
* Retire une permission du role. Idempotent : retirer une permission
* absente est un no-op silencieux.
*/
public function removePermission(Permission $permission): static
{
$this->permissions->removeElement($permission);
return $this;
}
/**
* Garde domaine : refuse la suppression d'un role marque comme systeme.
* La traduction HTTP (403) est faite au niveau application / API Platform.
*/
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw SystemRoleDeletionException::forRole($this);
}
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/me',
provider: MeProvider::class,
normalizationContext: ['groups' => ['me:read']],
),
new Get(
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
normalizationContext: ['groups' => ['user:list']],
),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
denormalizationContext: ['groups' => ['user:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'user:list'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $username = null;
#[ORM\Column(name: 'is_admin', options: ['default' => false])]
#[Groups(['me:read', 'user:list'])]
private bool $isAdmin = false;
/**
* Les roles RBAC metier rattaches a l'utilisateur.
*
* Le fetch EAGER est delibere : evite un lazy-load silencieux pendant
* un refresh de token JWT ou une serialisation hors contexte EntityManager
* (cf. docs/rbac/ticket-343-spec.md section 11 risque 1). Le surcout SQL est
* accepte a l'echelle d'un CRM/ERP PME ; a revoir si la volumetrie augmente.
*
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['me:read', 'user:list'])]
private Collection $roles;
/**
* Les permissions directes accordees hors des roles.
*
* Meme justification EAGER que pour $roles : garantie que
* getEffectivePermissions() fonctionne dans tous les contextes de chargement.
*
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['me:read', 'user:list'])]
private Collection $directPermissions;
#[ORM\Column]
private ?string $password = null;
#[Groups(['user:write'])]
private ?string $plainPassword = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->roles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): static
{
$this->username = $username;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->username;
}
/**
* Retourne les roles Symfony Security, derives de $isAdmin.
*
* ROLE_USER est toujours present pour que Symfony accepte l'authentification.
* ROLE_ADMIN est ajoute si l'utilisateur porte le flag is_admin — c'est le
* SEUL levier technique de bypass RBAC (cf. section 11 du spec).
*
* Important : ne JAMAIS iterer $this->roles (la Collection de Role) ici.
* Cette methode peut etre appelee pendant un refresh JWT, moment ou la
* Collection peut ne pas etre hydratee. On se contente d'un calcul base
* sur un scalaire.
*
* @return list<string>
*/
public function getRoles(): array
{
$roles = ['ROLE_USER'];
if ($this->isAdmin) {
$roles[] = 'ROLE_ADMIN';
}
return $roles;
}
public function isAdmin(): bool
{
return $this->isAdmin;
}
public function setIsAdmin(bool $isAdmin): static
{
$this->isAdmin = $isAdmin;
return $this;
}
/**
* Retourne la collection de roles RBAC rattaches a l'utilisateur.
*
* NE PAS confondre avec getRoles() qui renvoie les roles Symfony scalaires.
*
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->roles;
}
public function addRbacRole(Role $role): static
{
if (!$this->roles->contains($role)) {
$this->roles->add($role);
}
return $this;
}
public function removeRbacRole(Role $role): static
{
$this->roles->removeElement($role);
return $this;
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): static
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
return $this;
}
public function removeDirectPermission(Permission $permission): static
{
$this->directPermissions->removeElement($permission);
return $this;
}
/**
* Retourne l'union dedupliquee des codes de permissions effectives.
*
* Agrege les permissions venant des roles RBAC et les permissions directes.
* Utilisee par le PermissionVoter (ticket #345) et exposee via /api/me
* apres l'evolution du MeProvider (aussi ticket #345).
*
* Ne PAS appeler dans getRoles() : voir commentaire sur cette derniere
* methode pour le piege de chargement au refresh JWT.
*
* @return list<string>
*/
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->roles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
public function getPassword(): ?string
{
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): static
{
$this->plainPassword = $plainPassword;
return $this;
}
public function eraseCredentials(): void
{
$this->plainPassword = null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Event;
use App\Shared\Domain\Event\DomainEventInterface;
use DateTimeImmutable;
final readonly class UserCreated implements DomainEventInterface
{
public function __construct(
public int $userId,
public string $username,
private DateTimeImmutable $occurredAt = new DateTimeImmutable(),
) {}
public function occurredAt(): DateTimeImmutable
{
return $this->occurredAt;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use App\Module\Core\Domain\Entity\Role;
use DomainException;
/**
* Levee lorsqu'une tentative de suppression vise un role marque comme systeme.
*
* Les roles systeme (ex : admin, user) sont proteges au niveau du domaine
* pour garantir qu'ils ne peuvent jamais etre retires par un administrateur,
* une commande ou un processus d'import. La traduction HTTP (403) est faite
* ailleurs, cette exception reste purement domaine.
*/
final class SystemRoleDeletionException extends DomainException
{
/**
* Construit l'exception a partir du role refuse a la suppression.
*/
public static function forRole(Role $role): self
{
return new self(sprintf('Le role systeme "%s" ne peut pas etre supprime.', $role->getCode()));
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
/**
* Contrat du catalogue de permissions RBAC.
*
* Utilise par la commande de synchronisation (app:sync-permissions), les
* fixtures, et — a terme (ticket #345) — par le PermissionVoter pour valider
* que les codes verifies existent bien dans le catalogue.
*/
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/**
* @return array<int, Permission>
*/
public function findAll(): array;
/**
* @return array<int, string> liste des codes connus, pour la commande de sync et le futur voter
*/
public function findAllCodes(): array;
public function save(Permission $permission): void;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
/**
* Contrat des roles RBAC.
*
* Utilise par les fixtures, la future API d'administration (ticket #344) et
* le PermissionVoter pour resoudre les permissions effectives d'un role.
*/
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/**
* @return array<int, Role>
*/
public function findAll(): array;
public function save(Role $role): void;
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\User;
interface UserRepositoryInterface
{
public function findById(int $id): ?User;
public function findByUsername(string $username): ?User;
public function save(User $user): void;
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
/**
* Source de verite unique pour les codes des roles systeme RBAC.
*
* Ces constantes sont partagees entre les fixtures applicatives et les
* migrations Doctrine (qui inserent les memes codes en SQL brut). Toute
* modification ici doit etre repercutee dans la migration correspondante.
*/
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
/**
* Empeche l'instanciation : cette classe est un simple porteur de constantes.
*/
private function __construct() {}
}

View File

@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\Api\Auth\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

View File

@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\Api\Auth\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* @implements ProviderInterface<\App\Entity\User>
* @implements ProviderInterface<object>
*/
class MeProvider implements ProviderInterface
{

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:create-user',
description: 'Cree un utilisateur rattache au role systeme admin ou user.',
)]
final class CreateUserCommand extends Command
{
public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('username', InputArgument::REQUIRED, 'Nom d\'utilisateur')
->addArgument('password', InputArgument::REQUIRED, 'Mot de passe en clair')
->addOption('admin', null, InputOption::VALUE_NONE, 'Rattache au role systeme admin + active is_admin')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$username = $input->getArgument('username');
$plainPassword = $input->getArgument('password');
$isAdmin = (bool) $input->getOption('admin');
$roleCode = $isAdmin ? SystemRoles::ADMIN_CODE : SystemRoles::USER_CODE;
$role = $this->roleRepository->findByCode($roleCode);
if (null === $role) {
$io->error(sprintf(
'Le role systeme "%s" est introuvable. Lance "bin/console doctrine:migrations:migrate" pour le seeder.',
$roleCode,
));
return Command::FAILURE;
}
$user = new User();
$user->setUsername($username);
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setIsAdmin($isAdmin);
$user->addRbacRole($role);
$this->userRepository->save($user);
$io->success(sprintf(
'Utilisateur "%s" cree, rattache au role systeme "%s"%s.',
$username,
$roleCode,
$isAdmin ? ' (bypass is_admin actif)' : '',
));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
use function count;
#[AsCommand(
name: 'app:sync-permissions',
description: 'Synchronise les permissions RBAC declarees par les modules actifs.',
)]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissionRepository,
#[Autowire(param: 'kernel.project_dir')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
// Etape 1 : scan + validation stricte des modules actifs AVANT
// tout acces en ecriture a la base, afin qu'une erreur de
// declaration laisse la table `permission` intacte.
$desiredPermissions = $this->collectDesiredPermissions();
} catch (InvalidArgumentException $e) {
$io->error($e->getMessage());
return Command::FAILURE;
}
// Etape 2 : upsert transactionnel non destructif.
$this->em->beginTransaction();
try {
// Indexation des permissions existantes par code pour un acces O(1).
$existingByCode = [];
foreach ($this->permissionRepository->findAll() as $permission) {
$existingByCode[$permission->getCode()] = $permission;
}
$added = 0;
$updated = 0;
$orphans = 0;
// Upsert : chaque entree desiree est creee, revivee ou mise a jour.
foreach ($desiredPermissions as $code => $entry) {
$label = $entry['label'];
$module = $entry['module'];
if (isset($existingByCode[$code])) {
$existing = $existingByCode[$code];
if ($existing->isOrphan()) {
// Revival : le code reapparait dans le source, on
// rafraichit ses metadonnees et on retire le flag.
$existing->revive($label, $module);
++$updated;
} elseif ($existing->getLabel() !== $label || $existing->getModule() !== $module) {
// Mise a jour des metadonnees sans toucher au flag orphan.
$existing->updateMetadata($label, $module);
++$updated;
}
// Sinon : strictement identique, no-op.
} else {
// Creation : on persiste directement via l'EM pour ne
// pas declencher un flush par appel (cf. save() repo).
$permission = new Permission($code, $label, $module);
$this->em->persist($permission);
++$added;
}
}
// Etape 3 : marquage orphelin des permissions absentes du source.
foreach ($existingByCode as $code => $existing) {
if (isset($desiredPermissions[$code])) {
continue;
}
if (!$existing->isOrphan()) {
$existing->markOrphan();
++$orphans;
}
}
// Un unique flush regroupe toutes les mutations de la transaction.
$this->em->flush();
$this->em->commit();
} catch (Throwable $e) {
$this->em->rollback();
$io->error(sprintf('Echec de la synchronisation des permissions : %s', $e->getMessage()));
return Command::FAILURE;
}
$totalInDb = count($this->permissionRepository->findAll());
$io->success('Synchronisation des permissions RBAC terminee.');
$io->table(
['Indicateur', 'Valeur'],
[
['Permissions ajoutees', (string) $added],
['Permissions mises a jour ou revivees', (string) $updated],
['Permissions marquees orphelines', (string) $orphans],
['Total en base apres sync', (string) $totalInDb],
],
);
return Command::SUCCESS;
}
/**
* Parcourt la liste des modules actifs declares dans `config/modules.php`,
* extrait leurs permissions statiques, valide strictement chaque entree
* puis renvoie une map indexee par code.
*
* Regles de validation appliquees :
* - chaque entree doit posseder exactement les cles `code` et `label`
* - le `code` doit etre prefixe par `<ModuleClass>::ID . '.'`
* - `code` et `label` ne peuvent pas etre des chaines vides
*
* Les modules ne definissant pas de methode statique `permissions()` sont
* ignores silencieusement (compat ascendante pour les modules legacy).
*
* @return array<string, array{code: string, label: string, module: string}>
*/
private function collectDesiredPermissions(): array
{
/** @var array<int, class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
$desired = [];
foreach ($moduleClasses as $moduleClass) {
if (!method_exists($moduleClass, 'permissions')) {
continue;
}
/** @var array<int, array<string, string>> $entries */
$entries = $moduleClass::permissions();
$moduleId = $moduleClass::ID;
foreach ($entries as $entry) {
$keys = array_keys($entry);
sort($keys);
if (['code', 'label'] !== $keys) {
throw new InvalidArgumentException(sprintf(
'Permission malformee declaree par %s : chaque entree doit contenir exactement les cles [code, label], recu [%s].',
$moduleClass,
implode(', ', array_keys($entry)),
));
}
$code = $entry['code'];
$label = $entry['label'];
if ('' === $code) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code ne peut pas etre vide.',
$moduleClass,
));
}
if ('' === $label) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s (code "%s") : le libelle ne peut pas etre vide.',
$moduleClass,
$code,
));
}
$expectedPrefix = $moduleId.'.';
if (!str_starts_with($code, $expectedPrefix)) {
throw new InvalidArgumentException(sprintf(
'Permission invalide declaree par %s : le code "%s" doit etre prefixe par "%s" (ID du module).',
$moduleClass,
$code,
$expectedPrefix,
));
}
$desired[$code] = [
'code' => $code,
'label' => $label,
'module' => $moduleId,
];
}
}
return $desired;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\DataFixtures;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*/
class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
) {}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
$manager,
SystemRoles::ADMIN_CODE,
'Administrateur',
'Role administrateur - bypass complet via is_admin',
);
$userRole = $this->ensureSystemRole(
$manager,
SystemRoles::USER_CODE,
'Utilisateur',
'Role de base sans permission specifique',
);
$admin = new User();
$admin->setUsername('admin');
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
$manager->persist($bob);
$manager->flush();
}
/**
* Retourne le role systeme correspondant au code donne, en le creant
* s'il n'existe pas encore (le purger Doctrine a pu vider la table role).
*
* La description est recopiee depuis la migration RBAC pour que les
* deux chemins (migration prod, fixtures dev) produisent un etat
* identique.
*/
private function ensureSystemRole(
ObjectManager $manager,
string $code,
string $label,
string $description,
): Role {
$role = $this->roleRepository->findByCode($code);
if (null !== $role) {
return $role;
}
$role = new Role($code, $label, isSystem: true, description: $description);
$manager->persist($role);
return $role;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Permission>
*/
public function findAll(): array
{
return parent::findAll();
}
/**
* @return array<int, string>
*/
public function findAllCodes(): array
{
// Requete legere : on ne selectionne que la colonne code (pas d'hydratation
// d'entites Permission) car findAllCodes() est appelee par la commande de
// sync et le futur voter qui n'ont besoin que des chaines.
$rows = $this->createQueryBuilder('p')
->select('p.code')
->getQuery()
->getArrayResult()
;
return array_column($rows, 'code');
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/**
* @return array<int, Role>
*/
public function findAll(): array
{
return parent::findAll();
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
$this->getEntityManager()->flush();
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function findById(int $id): ?User
{
return $this->find($id);
}
public function findByUsername(string $username): ?User
{
return $this->findOneBy(['username' => $username]);
}
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
}

View File

@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Application\Bus;
interface CommandBusInterface
{
public function dispatch(object $command): void;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Application\Bus;
interface QueryBusInterface
{
public function ask(object $query): mixed;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface TenantAwareInterface
{
public function getTenantId(): ?string;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface UserResolverInterface
{
public function resolve(int $id): ?object;
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Event;
use DateTimeImmutable;
interface DomainEventInterface
{
public function occurredAt(): DateTimeImmutable;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\ValueObject;
use InvalidArgumentException;
final readonly class Email
{
public readonly string $value;
public function __construct(string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid email address.', $value));
}
$this->value = $value;
}
public function __toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}

View File

@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\Api\Shared\Resource;
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Api\Shared\State\AppVersionProvider;
use App\Shared\Infrastructure\ApiPlatform\State\AppVersionProvider;
#[ApiResource(
operations: [

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\ModulesProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/modules',
provider: ModulesProvider::class,
),
],
)]
class ModulesResource
{
/** @var list<string> */
public array $modules = [];
/** @param list<string> $modules */
public function __construct(array $modules = [])
{
$this->modules = $modules;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\SidebarProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/sidebar',
provider: SidebarProvider::class,
),
],
)]
class SidebarResource
{
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string}>}> */
public array $sections = [];
/** @var list<string> */
public array $disabledRoutes = [];
/**
* @param list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string}>}> $sections
* @param list<string> $disabledRoutes
*/
public function __construct(array $sections = [], array $disabledRoutes = [])
{
$this->sections = $sections;
$this->disabledRoutes = $disabledRoutes;
}
}

View File

@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\Api\Shared\State;
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Api\Shared\Resource\AppVersion;
use App\Shared\Infrastructure\ApiPlatform\Resource\AppVersion;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Infrastructure\ApiPlatform\Resource\ModulesResource;
/**
* @implements ProviderInterface<object>
*/
class ModulesProvider implements ProviderInterface
{
/** @var list<string> */
private readonly array $activeModuleIds;
public function __construct()
{
$configPath = dirname(__DIR__, 5).'/config/modules.php';
$moduleClasses = file_exists($configPath) ? require $configPath : [];
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (defined($moduleClass.'::ID')) {
$ids[] = $moduleClass::ID;
}
}
$this->activeModuleIds = $ids;
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object
{
return new ModulesResource($this->activeModuleIds);
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
/**
* @implements ProviderInterface<object>
*/
class SidebarProvider implements ProviderInterface
{
/** @var list<string> */
private readonly array $activeModuleIds;
/** @var list<array{label: string, icon: string, items: list<array{label: string, to: string, icon: string, module: string}>}> */
private readonly array $sidebarConfig;
public function __construct()
{
$configDir = dirname(__DIR__, 5).'/config';
// Load active modules
$modulesFile = $configDir.'/modules.php';
$moduleClasses = file_exists($modulesFile) ? require $modulesFile : [];
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (defined($moduleClass.'::ID')) {
$ids[] = $moduleClass::ID;
}
}
$this->activeModuleIds = $ids;
// Load sidebar config
$sidebarFile = $configDir.'/sidebar.php';
$this->sidebarConfig = file_exists($sidebarFile) ? require $sidebarFile : [];
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object
{
$sections = [];
$disabledRoutes = [];
foreach ($this->sidebarConfig as $section) {
$items = [];
foreach ($section['items'] ?? [] as $item) {
$isActive = in_array($item['module'] ?? null, $this->activeModuleIds, true);
if (!$isActive) {
if (isset($item['to'])) {
$disabledRoutes[] = $item['to'];
}
continue;
}
$items[] = [
'label' => $item['label'],
'to' => $item['to'],
'icon' => $item['icon'],
];
}
if ([] === $items) {
continue;
}
$sections[] = [
'label' => $section['label'],
'icon' => $section['icon'],
'items' => $items,
];
}
return new SidebarResource($sections, array_values(array_unique($disabledRoutes)));
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class PermissionTest extends TestCase
{
public function testConstructorInitialState(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
self::assertNull($permission->getId());
self::assertSame('core.users.view', $permission->getCode());
self::assertSame('Voir les utilisateurs', $permission->getLabel());
self::assertSame('core', $permission->getModule());
self::assertFalse($permission->isOrphan());
}
public function testMarkOrphanSetsFlag(): void
{
$permission = new Permission('core.users.view', 'Voir les utilisateurs', 'core');
$permission->markOrphan();
self::assertTrue($permission->isOrphan());
}
public function testReviveResetsOrphanAndUpdatesMetadata(): void
{
$permission = new Permission('core.users.view', 'Old label', 'core');
$permission->markOrphan();
$permission->revive('New label', 'commercial');
self::assertFalse($permission->isOrphan());
self::assertSame('New label', $permission->getLabel());
self::assertSame('commercial', $permission->getModule());
}
public function testUpdateMetadataDoesNotTouchOrphan(): void
{
$permission = new Permission('core.users.view', 'Old', 'core');
$permission->markOrphan();
$permission->updateMetadata('Lbl', 'core');
self::assertTrue($permission->isOrphan());
self::assertSame('Lbl', $permission->getLabel());
}
public function testConstructorRejectsEmptyCode(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('', 'Libelle', 'core');
}
public function testConstructorRejectsCodeWithoutDot(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('invalid_format');
new Permission('invalid_format', 'Libelle', 'core');
}
public function testConstructorRejectsEmptyLabel(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', '', 'core');
}
public function testConstructorRejectsEmptyModule(): void
{
$this->expectException(InvalidArgumentException::class);
new Permission('core.users.view', 'Libelle', '');
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class RoleTest extends TestCase
{
public function testConstructorInitialState(): void
{
$role = new Role('custom', 'Custom');
self::assertNull($role->getId());
self::assertSame('custom', $role->getCode());
self::assertSame('Custom', $role->getLabel());
self::assertNull($role->getDescription());
self::assertFalse($role->isSystem());
self::assertTrue($role->getPermissions()->isEmpty());
}
public function testAddPermissionAddsOnce(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->addPermission($permission);
$role->addPermission($permission);
self::assertSame(1, $role->getPermissions()->count());
}
public function testAddPermissionAddsMultipleDistinct(): void
{
$role = new Role('custom', 'Custom');
$permissionView = new Permission('core.users.view', 'Voir', 'core');
$permissionEdit = new Permission('core.users.edit', 'Editer', 'core');
$role->addPermission($permissionView);
$role->addPermission($permissionEdit);
self::assertSame(2, $role->getPermissions()->count());
}
public function testRemovePermissionRemovesWhenPresent(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->addPermission($permission);
$role->removePermission($permission);
self::assertSame(0, $role->getPermissions()->count());
}
public function testRemovePermissionIsNoOpWhenAbsent(): void
{
$role = new Role('custom', 'Custom');
$permission = new Permission('core.users.view', 'Voir', 'core');
$role->removePermission($permission);
self::assertSame(0, $role->getPermissions()->count());
}
public function testEnsureDeletableAllowsNonSystemRole(): void
{
$role = new Role('custom', 'Custom', false);
$role->ensureDeletable();
$this->expectNotToPerformAssertions();
}
public function testEnsureDeletableThrowsForSystemRole(): void
{
$role = new Role('admin', 'Admin', true);
$this->expectException(SystemRoleDeletionException::class);
$this->expectExceptionMessage('admin');
$role->ensureDeletable();
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Domain\Entity;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class UserTest extends TestCase
{
public function testGetRolesReturnsRoleUserByDefault(): void
{
$user = new User();
self::assertSame(['ROLE_USER'], $user->getRoles());
}
public function testGetRolesIncludesRoleAdminWhenIsAdminTrue(): void
{
$user = new User();
$user->setIsAdmin(true);
self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles());
}
public function testIsAdminDefaultsToFalse(): void
{
$user = new User();
self::assertFalse($user->isAdmin());
}
public function testGetEffectivePermissionsIsEmptyByDefault(): void
{
$user = new User();
self::assertSame([], $user->getEffectivePermissions());
}
public function testGetEffectivePermissionsUnionsRolesAndDirects(): void
{
$perm1 = new Permission('core.users.view', 'View users', 'core');
$perm2 = new Permission('core.users.edit', 'Edit users', 'core');
$perm3 = new Permission('core.users.delete', 'Delete users', 'core');
$role = new Role('manager', 'Manager');
$role->addPermission($perm1);
$role->addPermission($perm2);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm3);
self::assertSame(
['core.users.delete', 'core.users.edit', 'core.users.view'],
$user->getEffectivePermissions(),
);
}
public function testGetEffectivePermissionsDeduplicatesAcrossRolesAndDirects(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$role = new Role('viewer', 'Viewer');
$role->addPermission($perm);
$user = new User();
$user->addRbacRole($role);
$user->addDirectPermission($perm);
$result = $user->getEffectivePermissions();
self::assertCount(1, $result);
self::assertSame(['core.users.view'], $result);
}
public function testAddRbacRoleIsIdempotent(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->addRbacRole($role);
self::assertSame(1, $user->getRbacRoles()->count());
}
public function testAddDirectPermissionIsIdempotent(): void
{
$perm = new Permission('core.users.view', 'View users', 'core');
$user = new User();
$user->addDirectPermission($perm);
$user->addDirectPermission($perm);
self::assertSame(1, $user->getDirectPermissions()->count());
}
public function testRemoveRbacRole(): void
{
$role = new Role('manager', 'Manager');
$user = new User();
$user->addRbacRole($role);
$user->removeRbacRole($role);
self::assertSame(0, $user->getRbacRoles()->count());
}
public function testGetEffectivePermissionsOutputIsSorted(): void
{
$permZ = new Permission('core.z.action', 'Z', 'core');
$permA = new Permission('core.a.action', 'A', 'core');
$permM = new Permission('core.m.action', 'M', 'core');
$user = new User();
$user->addDirectPermission($permZ);
$user->addDirectPermission($permA);
$user->addDirectPermission($permM);
self::assertSame(
['core.a.action', 'core.m.action', 'core.z.action'],
$user->getEffectivePermissions(),
);
}
}