Merge branch 'feature/ERP-7-mise-en-place-du-modular-monolith' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
# Conflicts: # docker-compose.yml
This commit is contained in:
208
.claude/skills/create-module/SKILL.md
Normal file
208
.claude/skills/create-module/SKILL.md
Normal 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
30
CHANGELOG.md
Normal 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
|
||||
262
CLAUDE.md
262
CLAUDE.md
@@ -1,112 +1,162 @@
|
||||
# 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/`
|
||||
|
||||
**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 +166,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 +178,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,10 +201,28 @@ 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
|
||||
- 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
|
||||
@@ -163,9 +237,11 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- TypeScript strict
|
||||
- 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 +253,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`
|
||||
|
||||
102
README.md
102
README.md
@@ -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
10
config/modules.php
Normal 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,
|
||||
];
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -15,3 +15,6 @@ services:
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface:
|
||||
alias: App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository
|
||||
|
||||
55
config/sidebar.php
Normal file
55
config/sidebar.php
Normal 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',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
364
doc/architecture-modulaire-malio.md
Normal file
364
doc/architecture-modulaire-malio.md
Normal 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.
|
||||
267
doc/review-PR1-ERP7-modular-monolith.md
Normal file
267
doc/review-PR1-ERP7-modular-monolith.md
Normal 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.
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "3004:3003"
|
||||
- "3004:3004"
|
||||
restart: unless-stopped
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
|
||||
7
frontend/app/layouts/auth.vue
Normal file
7
frontend/app/layouts/auth.vue
Normal 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>
|
||||
53
frontend/app/layouts/default.vue
Normal file
53
frontend/app/layouts/default.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
18
frontend/app/middleware/modules.global.ts
Normal file
18
frontend/app/middleware/modules.global.ts
Normal 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('/')
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
60
frontend/eslint.config.mjs
Normal file
60
frontend/eslint.config.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
1
frontend/modules/commercial/nuxt.config.ts
Normal file
1
frontend/modules/commercial/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
12
frontend/modules/commercial/pages/commercial.vue
Normal file
12
frontend/modules/commercial/pages/commercial.vue
Normal 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>
|
||||
1
frontend/modules/core/nuxt.config.ts
Normal file
1
frontend/modules/core/nuxt.config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -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"
|
||||
18
frontend/modules/core/pages/logout.vue
Normal file
18
frontend/modules/core/pages/logout.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
3275
frontend/package-lock.json
generated
3275
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
frontend/public/LOGO_MALIO.png
Normal file
BIN
frontend/public/LOGO_MALIO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/public/LOGO_MALIO_COLLAPSED.png
Normal file
BIN
frontend/public/LOGO_MALIO_COLLAPSED.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.9 KiB |
202
frontend/shared/composables/useApi.ts
Normal file
202
frontend/shared/composables/useApi.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
46
frontend/shared/composables/useSidebar.ts
Normal file
46
frontend/shared/composables/useSidebar.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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: () => ({
|
||||
11
frontend/shared/types/index.ts
Normal file
11
frontend/shared/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface SidebarItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
label: string
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,10 @@ services:
|
||||
- "8086:80"
|
||||
volumes:
|
||||
- ./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:
|
||||
coltura_logs:
|
||||
|
||||
6
makefile
6
makefile
@@ -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/
|
||||
|
||||
10
pre-commit
10
pre-commit
@@ -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=$?
|
||||
|
||||
12
src/Module/Commercial/CommercialModule.php
Normal file
12
src/Module/Commercial/CommercialModule.php
Normal 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;
|
||||
}
|
||||
29
src/Module/Core/Application/DTO/UserOutput.php
Normal file
29
src/Module/Core/Application/DTO/UserOutput.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/Module/Core/CoreModule.php
Normal file
12
src/Module/Core/CoreModule.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
@@ -10,9 +10,9 @@ 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 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\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
@@ -38,7 +38,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
22
src/Module/Core/Domain/Event/UserCreated.php
Normal file
22
src/Module/Core/Domain/Event/UserCreated.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
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
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly \Symfony\Bundle\SecurityBundle\Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
namespace App\Module\Core\Infrastructure\Console;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -22,7 +22,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
class CreateUserCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
@@ -52,8 +52,7 @@ class CreateUserCommand extends Command
|
||||
$user->setRoles(['ROLE_ADMIN']);
|
||||
}
|
||||
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$this->userRepository->save($user);
|
||||
|
||||
$io->success(sprintf('User "%s" created%s.', $username, $input->getOption('admin') ? ' with ROLE_ADMIN' : ''));
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DataFixtures;
|
||||
namespace App\Module\Core\Infrastructure\DataFixtures;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
10
src/Shared/Application/Bus/CommandBusInterface.php
Normal file
10
src/Shared/Application/Bus/CommandBusInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Bus;
|
||||
|
||||
interface CommandBusInterface
|
||||
{
|
||||
public function dispatch(object $command): void;
|
||||
}
|
||||
10
src/Shared/Application/Bus/QueryBusInterface.php
Normal file
10
src/Shared/Application/Bus/QueryBusInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Application\Bus;
|
||||
|
||||
interface QueryBusInterface
|
||||
{
|
||||
public function ask(object $query): mixed;
|
||||
}
|
||||
10
src/Shared/Domain/Contract/TenantAwareInterface.php
Normal file
10
src/Shared/Domain/Contract/TenantAwareInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
interface TenantAwareInterface
|
||||
{
|
||||
public function getTenantId(): ?string;
|
||||
}
|
||||
10
src/Shared/Domain/Contract/UserResolverInterface.php
Normal file
10
src/Shared/Domain/Contract/UserResolverInterface.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
interface UserResolverInterface
|
||||
{
|
||||
public function resolve(int $id): ?object;
|
||||
}
|
||||
12
src/Shared/Domain/Event/DomainEventInterface.php
Normal file
12
src/Shared/Domain/Event/DomainEventInterface.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Event;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface DomainEventInterface
|
||||
{
|
||||
public function occurredAt(): DateTimeImmutable;
|
||||
}
|
||||
31
src/Shared/Domain/ValueObject/Email.php
Normal file
31
src/Shared/Domain/ValueObject/Email.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user