Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fbdda1983 | |||
| b301c543bb | |||
| 3053c09522 | |||
| 52399b35d9 | |||
| 748289b61a | |||
| 2d0e9de155 | |||
| a510b2ca73 |
@@ -73,97 +73,8 @@
|
||||
### Time tracking
|
||||
- Le sous-agent a stoppé lui-même le timer d'implémentation (id 1005, 35 min) — garder le time-tracking sur la session principale pour rester maître du chrono si un sous-agent a accès aux tools MCP lesstime.
|
||||
|
||||
## Session 2026-06-19 (LST-62 / 0.2 — Socle front : shell + auto-détection layers Nuxt)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-62-socle-front.md`), 7 tasks. Exécution en 3 sous-agents (Task 1 back ; Tasks 2-4 fondations front ; Tasks 5-7 middlewares/layout/i18n), pilotage chrono/MCP/vérif sur la session principale.
|
||||
- 7 commits + 1 commit doc de correction du plan. Back : 115 tests verts (110 + 5 nouveaux cas gate rôle).
|
||||
|
||||
### Patterns
|
||||
- **Gate de rôle additif dans la sidebar** : clé `roles` optionnelle sur section/item dans `config/sidebar.php` ; `SidebarFilter::filter($sections, $activeModuleIds, $activeRoles = [])` masque sans polluer `disabledRoutes` (réservé au filtrage par module). `SidebarProvider` injecte `Symfony\Bundle\SecurityBundle\Security` et passe `array_values($user->getRoles())`. ROLE_ADMIN seulement (pas le RBAC fin, qui viendra en 1.1/1.2).
|
||||
- **Layout front aligné Starseed** (vérifié dans le code Starseed) : `srcDir: '.'`, `dir.layouts/middleware → app/`, code transverse auto-importé sous `shared/{composables,stores,utils}` via `imports.dirs` EXPLICITE, scan `readdirSync('modules/')` → `extends` + dossiers `modules/*/composables` ajoutés dynamiquement à `imports.dirs`. `useApi`/`auth`/`ui` déplacés par `git mv` (historique préservé) ; `timer.ts`/`mail.ts` restent dans `stores/` (métier non migré).
|
||||
- **Singletons module-level** : `useSidebar`/`useModules` portent leur état en `ref` au niveau module ; reset explicite au logout depuis `auth.global.ts` (l'approche Starseed via callback `onAuthSessionCleared()` est une alternative non retenue ici).
|
||||
|
||||
### Gotchas
|
||||
- **`nuxt typecheck` n'est PAS un gate vert sur ce stack** : le baseline Lesstime est rouge (~230 lignes `error TS`) et la RÉFÉRENCE Starseed (même Nuxt 4.3.1, même layout) ship en prod avec **325 erreurs**. Classes structurelles tolérées : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'…` dans `shared/` (Nuxt 4 type `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les expose au RUNTIME — vérifié dans `.nuxt/imports.d.ts`), erreurs `nuxt.config.ts` (`node:fs`/`process`/`__dirname`, pas de `@types/node`, compilé au runtime par Nuxt), `useApi.ts` 'Property url'. **Le vrai gate** = zéro `Cannot find module '~/shared/…'` (= vrai import cassé) + auto-imports présents dans `.nuxt/imports.d.ts` + smoke runtime. Un sous-agent consciencieux s'est arrêté à tort sur ces erreurs ("bloqueur irréductible") → toujours vérifier le gate contre la réf Starseed avant de conclure à un blocage.
|
||||
- **Vérif backend live > typecheck front** : le gate de rôle a été prouvé via curl réel (`/api/login_check` → cookie BEARER → `GET /api/sidebar`) : `alice` (ROLE_USER) n'a que la section générale, `admin` (ROLE_ADMIN) a Administration, non-auth = 401. Plus fiable que le typecheck sur ce stack.
|
||||
- **i18n `fr.json`** : une clé racine `sidebar` préexistait (avec un `myTasks` orphelin) → fusionner les sous-namespaces plutôt que dupliquer la clé racine (JSON invalide sinon).
|
||||
|
||||
### Statut / time tracking
|
||||
- Ticket laissé en **"En attente de validation" (4)**, pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du **délta cosmétique d'ordre de sidebar** (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
|
||||
- Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils `mcp__lesstime__*`) — respecté.
|
||||
|
||||
## Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-63-module-core.md`, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
|
||||
- 5 commits impl (`6ca91cb` A, `f8fc4d6`+`d70925b` B, `0b4874e` C, `f1a9b42` D, `a98ea3d` E, `117c2ff` F) + plan `8865bf5`. Tests : 110→120 verts. Timer impl 1012 = 43 min.
|
||||
|
||||
### Patterns
|
||||
- **Move d'entité « strangler » sans migration** : `git mv` `src/Entity/User.php` → `src/Module/Core/Domain/Entity/User.php` (table + colonnes + backticks VERBATIM) ; mapping Doctrine `Core` ajouté (dir `src/Module/Core/Domain/Entity`, prefix `App\Module\Core\Domain\Entity`) à côté de `App` ; `resolve_target_entities: UserInterface → Core\User`. `migrations:diff` reste vide (hors dérive préexistante `messenger_messages`) → AUCUNE migration. Idem Notification en Phase D.
|
||||
- **Alias temporaire pour découpler le move des relations** : Phase B pose un `class_alias(App\Entity\User::class → Core\User)` (fichier `_compat_user_alias.php` en `autoload.files`, exclu de l'autowiring `App\:` via `exclude` services.yaml + `notPath` php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT `UserInterface::class` (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand `grep App\Entity\User` est vide.
|
||||
- **Règle contrat-vs-concret pour migrer les consommateurs** (Phase C, ~50 fichiers) : type-hint `App\Shared\Domain\Contract\UserInterface` si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret `App\Module\Core\Domain\Entity\User` si besoin de getters HR, `apiToken`, `avatarFileName`, setters, `new User()`. Les deux éliminent `App\Entity\User`. Collision de nom avec `Symfony\...\UserInterface` → aliaser en `SharedUserInterface`.
|
||||
- **Notifier (Phase D)** : `NotifierInterface` (Shared) = API publique inter-modules ; impl `Notifier` (Core) persiste + flush. `TaskNotificationListener` appelle `notify()` UNIQUEMENT en `postFlush` (jamais `onFlush` — le flush interne y est dangereux). Comportement identique conservé.
|
||||
- **Layer front d'un module (Phase F)** : `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`) + `git mv` des pages d'identité sous `modules/core/pages/`. Les imports `~/...` (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (`/login`, `/profile`) restent identiques (fusion auto des `pages/` de layers).
|
||||
|
||||
### Gotchas
|
||||
- **`admin.vue` = shell admin MULTI-domaines** (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet `AdminUserTab`) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de `admin.vue` viendra avec les modules respectifs.
|
||||
- **Vérifier la résolution des routes d'un layer Nuxt en SPA** : `ssr:false` → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un `curl /login` = 200 ne prouve RIEN (testé : `/route-bidon-xyz` = 200 aussi). `nuxt prepare` ne génère pas le manifeste de routes. **Preuve déterministe** = `npx nuxt build` puis `grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs` (+ chunk CSS `profile.*.css` généré). Ne pas perturber un dev server déjà lancé (config `extends`/`imports.dirs` figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
|
||||
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
|
||||
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
|
||||
|
||||
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
|
||||
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
|
||||
|
||||
### Décision d'architecture majeure (actée, à valider PO)
|
||||
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
|
||||
|
||||
### Patterns
|
||||
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
|
||||
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
|
||||
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
|
||||
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
|
||||
|
||||
### Gotchas
|
||||
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
|
||||
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
|
||||
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
|
||||
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
|
||||
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
|
||||
|
||||
### MR / Git
|
||||
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62→#56, #63→#62, #57→#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
|
||||
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
|
||||
|
||||
## Meta-learnings
|
||||
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
||||
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
|
||||
- **Gate de vérif fourni par le plan**: si un plan fixe un seuil (ex "typecheck 0 erreur"), le confronter à la réalité du projet/réf AVANT de bloquer dessus ; corriger le plan si le seuil est faux.
|
||||
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
||||
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
||||
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
||||
|
||||
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
|
||||
|
||||
### Contexte
|
||||
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
|
||||
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
|
||||
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
|
||||
|
||||
### Patterns
|
||||
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
|
||||
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
|
||||
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>` → `core.User`).
|
||||
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
|
||||
|
||||
### Gotchas
|
||||
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
|
||||
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
|
||||
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
|
||||
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
|
||||
|
||||
### Time-tracking / orchestration
|
||||
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
|
||||
|
||||
@@ -91,20 +91,6 @@ ENCRYPTION_KEY=change_me_in_env_local
|
||||
# POSTGRES_PORT=5435
|
||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||
|
||||
# ===========================================================================
|
||||
# Error tracking — GlitchTip (compatible SDK Sentry)
|
||||
# ===========================================================================
|
||||
|
||||
# DSN du projet GlitchTip "lesstime-api" (BACKEND, runtime).
|
||||
# Actif uniquement en prod (bundle prod-only). Vide/absent => Sentry inerte.
|
||||
# A definir dans infra/prod/.env (pas en dev). Ex : http://<cle>@glitchtip.interne:<port>/<id>
|
||||
# SENTRY_DSN=
|
||||
|
||||
# NB : le DSN FRONT (lesstime-front) et l'upload des source maps sont fournis
|
||||
# au BUILD de l'image, pas au runtime. Voir infra/prod/Dockerfile (ARG) et la
|
||||
# CI .gitea/workflows/build-docker.yml (build-args depuis les secrets Gitea) :
|
||||
# NUXT_PUBLIC_SENTRY_DSN, SENTRY_URL, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN
|
||||
|
||||
# ===========================================================================
|
||||
# Frontend (frontend/.env)
|
||||
# ===========================================================================
|
||||
|
||||
@@ -20,11 +20,6 @@ jobs:
|
||||
run: |
|
||||
docker build \
|
||||
-f infra/prod/Dockerfile \
|
||||
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.SENTRY_FRONT_DSN }}" \
|
||||
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
|
||||
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
|
||||
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_FRONT_PROJECT }}" \
|
||||
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||
.
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
name: Pull Request — Quality gate
|
||||
|
||||
# Lance les tests back + le build front sur chaque PR ciblant develop.
|
||||
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
|
||||
# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile".
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# Annule les runs obsoletes quand on repush sur la meme PR.
|
||||
concurrency:
|
||||
group: pr-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend (PHP CS + PHPUnit)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
# Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le
|
||||
# suffixe `_test` automatiquement en APP_ENV=test (when@test
|
||||
# dbname_suffix) → la base reellement utilisee est `app_test`.
|
||||
POSTGRES_USER: app
|
||||
POSTGRES_PASSWORD: '!ChangeMe!'
|
||||
POSTGRES_DB: app
|
||||
# Pas de `ports:` host mapping : les jobs Gitea Actions tournent en
|
||||
# container sur un reseau Docker dedie, le service est joignable via
|
||||
# son nom (`postgres`), pas via 127.0.0.1.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U app"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_SECRET: ci-secret-not-used
|
||||
APP_DEBUG: 0
|
||||
DEFAULT_URI: http://localhost/
|
||||
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
|
||||
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
|
||||
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
|
||||
JWT_PASSPHRASE: ci-passphrase
|
||||
# Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que
|
||||
# les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test
|
||||
# alignee sur phpunit.dist.xml.
|
||||
ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP 8.4
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
# zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par
|
||||
# le chiffrement des secrets, ctype/iconv par le require de composer.json.
|
||||
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install PHP dependencies
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Generate JWT keypair
|
||||
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
|
||||
|
||||
- name: PHP CS Fixer (dry-run)
|
||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||
|
||||
- name: Bootstrap test database
|
||||
# Miroir de la cible `db-reset` du makefile (create + migrate + fixtures),
|
||||
# en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ;
|
||||
# sync-permissions complete le catalogue de permissions comme en install reelle.
|
||||
run: |
|
||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||
php bin/console app:sync-permissions --env=test --no-interaction
|
||||
|
||||
- name: Run PHPUnit
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
|
||||
frontend:
|
||||
name: Frontend (build)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node 24
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
# `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/).
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
|
||||
# (SPA), le prerender n'apporte rien a une quality gate — on valide seulement
|
||||
# que le bundle compile.
|
||||
- name: Build production (nuxt build)
|
||||
run: npm run build
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
||||
}
|
||||
},
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
|
||||
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
||||
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||
- Après modif nginx : `docker restart nginx-lesstime`
|
||||
|
||||
## Déploiement (prod Docker)
|
||||
|
||||
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
|
||||
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
|
||||
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
|
||||
|
||||
## Fixtures
|
||||
|
||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||
|
||||
@@ -23,7 +23,6 @@ Application de gestion de projet avec suivi du temps et portail client.
|
||||
- Intégration Gitea (issues, repos)
|
||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||
- Serveur MCP pour assistants IA
|
||||
- Error tracking centralisé back + front (GlitchTip / SDK Sentry, prod uniquement — voir « Error tracking »)
|
||||
- Multi-langue (i18n)
|
||||
|
||||
## Prérequis
|
||||
@@ -75,7 +74,6 @@ peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles
|
||||
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
||||
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
||||
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||
| `SENTRY_DSN` | Error tracking **backend** → GlitchTip (projet `lesstime-api`) | _(vide)_ | ⚪ optionnel — active le tracking (voir « Error tracking ») |
|
||||
|
||||
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
||||
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||
@@ -257,65 +255,6 @@ Le script active la maintenance, pull l'image, redémarre le container, lance le
|
||||
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||
**`doc/deployment-docker.md`**.
|
||||
|
||||
## Error tracking (GlitchTip)
|
||||
|
||||
Les erreurs **backend** et **frontend** sont remontées vers **GlitchTip** (instance auto-hébergée
|
||||
interne, compatible SDK Sentry) qui les **groupe par projet** et compte les occurrences. Activé
|
||||
**uniquement en prod** : en dev, sans DSN, le SDK est inerte (zéro impact). Ticket de référence :
|
||||
INFRA #146.
|
||||
|
||||
### Pourquoi back et front se configurent différemment
|
||||
|
||||
| | Backend (Symfony) | Frontend (Nuxt SPA) |
|
||||
|---|---|---|
|
||||
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
|
||||
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
|
||||
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI |
|
||||
|
||||
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
|
||||
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
|
||||
> pourrait lire une variable d'environnement).
|
||||
|
||||
### Variables
|
||||
|
||||
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) :
|
||||
```env
|
||||
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
|
||||
```
|
||||
|
||||
**Frontend — secrets Gitea** (repo → Settings → Actions → Secrets), consommés par
|
||||
`.gitea/workflows/build-docker.yml` :
|
||||
|
||||
| Secret Gitea | Rôle |
|
||||
|---|---|
|
||||
| `SENTRY_FRONT_DSN` | DSN du projet `lesstime-front` (public, baké dans le JS) |
|
||||
| `SENTRY_URL` | URL de l'instance GlitchTip |
|
||||
| `SENTRY_ORG` | slug de l'organisation GlitchTip |
|
||||
| `SENTRY_FRONT_PROJECT` | slug du projet front |
|
||||
| `SENTRY_AUTH_TOKEN` | token d'upload des **source maps** (vrai secret) |
|
||||
|
||||
> Sans source maps, seul `SENTRY_FRONT_DSN` est requis (les stacktraces front seront sur du JS
|
||||
> minifié). Le build n'échoue pas si les autres secrets sont absents.
|
||||
|
||||
### Fichiers concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `config/packages/sentry.yaml` | conf backend (prod-only, exceptions, 4xx ignorés, release = `app.version`) |
|
||||
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
|
||||
| `frontend/nuxt.config.ts` | module Sentry chargé **uniquement si DSN présent** + upload source maps |
|
||||
| `frontend/sentry.client.config.ts` | init du SDK client (no-op si DSN vide) |
|
||||
| `infra/prod/Dockerfile` | build-args front (`NUXT_PUBLIC_SENTRY_DSN`, `SENTRY_*`) |
|
||||
| `.gitea/workflows/build-docker.yml` | injection des secrets Gitea en build-args |
|
||||
|
||||
### Activation (résumé)
|
||||
|
||||
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
|
||||
(+ un auth token pour les source maps).
|
||||
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur.
|
||||
3. Frontend : ajouter les secrets Gitea ci-dessus.
|
||||
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
|
||||
|
||||
## Licence
|
||||
|
||||
Propriétaire — Tous droits réservés.
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"phpoffice/phpspreadsheet": "^5.5",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"sabre/vobject": "^4.5",
|
||||
"sentry/sentry-symfony": "^5.10",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/doctrine-messenger": "^8.0",
|
||||
|
||||
Generated
+1
-419
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "106755bef51fd069316cd7f3a7e1a0b6",
|
||||
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2508,125 +2508,6 @@
|
||||
},
|
||||
"time": "2026-02-08T16:21:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.12.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"ralouphie/getallheaders": "^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.25"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"http-interop/http-factory-tests": "1.1.0",
|
||||
"jshttp/mime-db": "1.54.0.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
},
|
||||
{
|
||||
"name": "George Mponos",
|
||||
"email": "gmponos@gmail.com",
|
||||
"homepage": "https://github.com/gmponos"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com",
|
||||
"homepage": "https://github.com/Nyholm"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://github.com/sagikazarmark"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Schultze",
|
||||
"email": "webmaster@tubo-world.de",
|
||||
"homepage": "https://github.com/Tobion"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://sagikazarmark.hu"
|
||||
}
|
||||
],
|
||||
"description": "PSR-7 message implementation that also provides common utility methods",
|
||||
"keywords": [
|
||||
"http",
|
||||
"message",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response",
|
||||
"stream",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Nyholm",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-23T15:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "icewind/smb",
|
||||
"version": "3.8.1",
|
||||
@@ -3079,66 +2960,6 @@
|
||||
},
|
||||
"time": "2026-05-04T12:34:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
@@ -5118,50 +4939,6 @@
|
||||
},
|
||||
"time": "2021-10-29T13:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ralouphie/getallheaders",
|
||||
"version": "3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ralouphie/getallheaders.git",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^2.1",
|
||||
"phpunit/phpunit": "^5 || ^6.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/getallheaders.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ralph Khattar",
|
||||
"email": "ralph.khattar@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A polyfill for getallheaders.",
|
||||
"support": {
|
||||
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
||||
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
||||
},
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/uri",
|
||||
"version": "3.0.2",
|
||||
@@ -5395,201 +5172,6 @@
|
||||
},
|
||||
"time": "2024-09-06T08:00:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.28.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"carthage-software/mago": "1.30.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/promises": "^2.0.3",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.0",
|
||||
"open-telemetry/exporter-otlp": "^1.0",
|
||||
"open-telemetry/sdk": "^1.0",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-11T12:22:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-symfony",
|
||||
"version": "5.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-symfony.git",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/psr7": "^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5||^2.0",
|
||||
"php": "^7.2||^8.0",
|
||||
"sentry/sentry": "^4.23.0",
|
||||
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
||||
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/polyfill-php80": "^1.22",
|
||||
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
|
||||
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
||||
"doctrine/doctrine-bundle": "^2.6||^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
||||
"masterminds/html5": "^2.8",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "1.12.5",
|
||||
"phpstan/phpstan-phpunit": "1.4.0",
|
||||
"phpstan/phpstan-symfony": "1.4.10",
|
||||
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
||||
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/monolog-bundle": "^3.4||^4.0",
|
||||
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
|
||||
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"vimeo/psalm": "^4.3||^5.16.0"
|
||||
},
|
||||
"suggest": {
|
||||
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
||||
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
||||
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/aliases.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\SentryBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
||||
"homepage": "http://getsentry.com",
|
||||
"keywords": [
|
||||
"errors",
|
||||
"logging",
|
||||
"sentry",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
||||
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T14:50:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.6",
|
||||
|
||||
@@ -8,7 +8,6 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
@@ -25,5 +24,4 @@ return [
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
SentryBundle::class => ['prod' => true],
|
||||
];
|
||||
|
||||
+1
-18
@@ -6,23 +6,6 @@ declare(strict_types=1);
|
||||
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
|
||||
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
|
||||
*/
|
||||
|
||||
use App\Module\Absence\AbsenceModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Directory\DirectoryModule;
|
||||
use App\Module\Integration\IntegrationModule;
|
||||
use App\Module\Mail\MailModule;
|
||||
use App\Module\ProjectManagement\ProjectManagementModule;
|
||||
use App\Module\Reporting\ReportingModule;
|
||||
use App\Module\TimeTracking\TimeTrackingModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
TimeTrackingModule::class,
|
||||
ProjectManagementModule::class,
|
||||
AbsenceModule::class,
|
||||
DirectoryModule::class,
|
||||
MailModule::class,
|
||||
IntegrationModule::class,
|
||||
ReportingModule::class,
|
||||
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
|
||||
];
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
api_platform:
|
||||
title: Lesstime API
|
||||
version: 1.0.0
|
||||
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
|
||||
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
|
||||
# discovered via service autoconfiguration, but #[ApiFilter] services are only
|
||||
# registered for classes found in these paths — without them, every filter is
|
||||
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
|
||||
mapping:
|
||||
paths:
|
||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
|
||||
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
|
||||
formats:
|
||||
jsonld: ['application/ld+json']
|
||||
json: ['application/json']
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
default_connection: default
|
||||
connections:
|
||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
||||
# service) to write outside the ORM transaction so audit rows survive
|
||||
# an application-side rollback and avoid transactional entanglement.
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
||||
# from schema comparison so migrations:diff / schema:validate stay
|
||||
# clean. Creation/teardown stay driven by migrations.
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
@@ -21,60 +14,22 @@ doctrine:
|
||||
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
|
||||
auto_mapping: true
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
|
||||
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
|
||||
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
|
||||
App\Shared\Domain\Contract\UserInterface: App\Entity\User
|
||||
mappings:
|
||||
Core:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
TimeTracking:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
||||
prefix: 'App\Module\TimeTracking\Domain\Entity'
|
||||
ProjectManagement:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
||||
prefix: 'App\Module\ProjectManagement\Domain\Entity'
|
||||
Absence:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
|
||||
prefix: 'App\Module\Absence\Domain\Entity'
|
||||
Directory:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
|
||||
prefix: 'App\Module\Directory\Domain\Entity'
|
||||
Mail:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
|
||||
prefix: 'App\Module\Mail\Domain\Entity'
|
||||
Integration:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
|
||||
prefix: 'App\Module\Integration\Domain\Entity'
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# Propagate the _test suffix to BOTH connections: the audit
|
||||
# connection must write to the test DB, not the dev DB.
|
||||
connections:
|
||||
default:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
audit:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -23,7 +23,7 @@ framework:
|
||||
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||
'App\Module\Mail\Application\Message\MailSyncRequested': sync
|
||||
'App\Message\MailSyncRequested': sync
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -10,7 +10,7 @@ security:
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Module\Core\Domain\Entity\User
|
||||
class: App\Entity\User
|
||||
property: username
|
||||
|
||||
firewalls:
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||
# Actif uniquement en prod (bundle enregistre seulement pour prod dans bundles.php).
|
||||
# Si SENTRY_DSN est vide/non defini, le SDK est inerte (rien n'est envoye).
|
||||
when@prod:
|
||||
parameters:
|
||||
# Valeur par defaut : DSN vide => Sentry desactive tant qu'il n'est pas fourni.
|
||||
env(SENTRY_DSN): ''
|
||||
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
# Capture les exceptions levees par le kernel (comportement par defaut).
|
||||
register_error_listener: true
|
||||
register_error_handler: true
|
||||
options:
|
||||
environment: '%env(APP_ENV)%'
|
||||
release: '%app.version%'
|
||||
# Pas d'APM/tracing (DuckDB hors perimetre du ticket #146).
|
||||
traces_sample_rate: 0.0
|
||||
# Ne pas remonter les 4xx HTTP comme des erreurs (bruit).
|
||||
ignore_exceptions:
|
||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||
@@ -1752,90 +1752,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* }>,
|
||||
* }
|
||||
* @psalm-type SentryConfig = array{
|
||||
* dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events.
|
||||
* register_error_listener?: bool|Param, // Default: true
|
||||
* register_error_handler?: bool|Param, // Default: true
|
||||
* logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null
|
||||
* options?: array{
|
||||
* integrations?: mixed, // Default: []
|
||||
* default_integrations?: bool|Param,
|
||||
* prefixes?: list<scalar|Param|null>,
|
||||
* sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events.
|
||||
* enable_tracing?: bool|Param,
|
||||
* traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions.
|
||||
* traces_sampler?: scalar|Param|null,
|
||||
* profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate
|
||||
* enable_logs?: bool|Param,
|
||||
* log_flush_threshold?: mixed, // Default: null
|
||||
* enable_metrics?: bool|Param, // Default: true
|
||||
* attach_stacktrace?: bool|Param,
|
||||
* attach_metric_code_locations?: bool|Param,
|
||||
* context_lines?: int|Param,
|
||||
* environment?: scalar|Param|null, // Default: "%kernel.environment%"
|
||||
* logger?: scalar|Param|null,
|
||||
* spotlight?: bool|Param,
|
||||
* spotlight_url?: scalar|Param|null,
|
||||
* release?: scalar|Param|null, // Default: "%env(default::SENTRY_RELEASE)%"
|
||||
* org_id?: int|Param,
|
||||
* server_name?: scalar|Param|null,
|
||||
* ignore_exceptions?: list<scalar|Param|null>,
|
||||
* ignore_transactions?: list<scalar|Param|null>,
|
||||
* before_send?: scalar|Param|null,
|
||||
* before_send_transaction?: scalar|Param|null,
|
||||
* before_send_check_in?: scalar|Param|null,
|
||||
* before_send_metrics?: scalar|Param|null,
|
||||
* before_send_log?: scalar|Param|null,
|
||||
* before_send_metric?: scalar|Param|null,
|
||||
* trace_propagation_targets?: mixed,
|
||||
* strict_trace_continuation?: bool|Param,
|
||||
* tags?: array<string, scalar|Param|null>,
|
||||
* error_types?: scalar|Param|null,
|
||||
* max_breadcrumbs?: int|Param,
|
||||
* before_breadcrumb?: mixed,
|
||||
* in_app_exclude?: list<scalar|Param|null>,
|
||||
* in_app_include?: list<scalar|Param|null>,
|
||||
* send_default_pii?: bool|Param,
|
||||
* max_value_length?: int|Param,
|
||||
* transport?: scalar|Param|null,
|
||||
* http_client?: scalar|Param|null,
|
||||
* http_proxy?: scalar|Param|null,
|
||||
* http_proxy_authentication?: scalar|Param|null,
|
||||
* http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport.
|
||||
* http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport.
|
||||
* http_ssl_verify_peer?: bool|Param,
|
||||
* http_compression?: bool|Param,
|
||||
* capture_silenced_errors?: bool|Param,
|
||||
* max_request_body_size?: "none"|"never"|"small"|"medium"|"always"|Param,
|
||||
* class_serializers?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* capture_soft_fails?: bool|Param, // Default: true
|
||||
* isolate_breadcrumbs_by_message?: bool|Param, // Default: false
|
||||
* isolate_context_by_message?: bool|Param, // Default: false
|
||||
* },
|
||||
* tracing?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* dbal?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* ignore_prepare_spans?: bool|Param, // Default: false
|
||||
* connections?: list<scalar|Param|null>,
|
||||
* },
|
||||
* twig?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* cache?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* http_client?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* console?: array{
|
||||
* excluded_commands?: list<scalar|Param|null>,
|
||||
* },
|
||||
* },
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1876,7 +1792,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* monolog?: MonologConfig,
|
||||
* sentry?: SentryConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
+9
-93
@@ -31,122 +31,38 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
|
||||
App\EventListener\TaskDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
|
||||
App\State\TaskDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
App\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\Core\Infrastructure\Controller\UserAvatarController:
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
|
||||
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
|
||||
|
||||
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
|
||||
|
||||
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
|
||||
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
||||
|
||||
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
|
||||
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
|
||||
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
|
||||
|
||||
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
||||
|
||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\Directory\Infrastructure\Controller\ReportDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
|
||||
|
||||
App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailFolderRepository'
|
||||
|
||||
App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailMessageRepository'
|
||||
|
||||
App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineTaskMailLinkRepository'
|
||||
|
||||
App\Module\Mail\Domain\Provider\MailProviderInterface: '@App\Module\Mail\Infrastructure\Imap\ImapMailProvider'
|
||||
|
||||
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
||||
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||
|
||||
+9
-31
@@ -3,18 +3,8 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||
* Filtrée par SidebarFilter :
|
||||
* - `module` : route ajoutée à disabledRoutes si module inactif ;
|
||||
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ;
|
||||
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
|
||||
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
|
||||
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
|
||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
|
||||
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
|
||||
* pour éviter le doublon.
|
||||
* Définition de la sidebar (sections + items). Filtrée par SidebarFilter selon les modules actifs.
|
||||
* Un item porte une clé `module` quand il appartient à un module activable ; sans clé, il est toujours visible.
|
||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||
*/
|
||||
return [
|
||||
@@ -23,29 +13,17 @@ return [
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:checkbox-marked-circle-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-multiple-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:clock-outline'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.tools.section',
|
||||
'icon' => 'mdi:tools',
|
||||
'label' => 'sidebar.hr.section',
|
||||
'icon' => 'mdi:calendar-account-outline',
|
||||
'items' => [
|
||||
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
||||
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.admin.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||
['label' => 'sidebar.hr.absences', 'to' => '/absences', 'icon' => 'mdi:calendar-remove-outline'],
|
||||
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group-outline'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.42'
|
||||
app.version: '0.4.30'
|
||||
|
||||
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Seeding RBAC system roles (idempotent)..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
||||
|
||||
echo "==> Syncing RBAC permissions catalog..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
@@ -300,31 +294,7 @@ cd /var/www/lesstime
|
||||
./deploy.sh v0.3.13 # deploie une version specifique
|
||||
```
|
||||
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
|
||||
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
## RBAC : roles & permissions (post-deploiement)
|
||||
|
||||
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
|
||||
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
|
||||
les peuplent, integrees au `deploy.sh` :
|
||||
|
||||
| Commande | Effet |
|
||||
|----------|-------|
|
||||
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
|
||||
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
|
||||
|
||||
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
|
||||
|
||||
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
|
||||
```
|
||||
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,706 +0,0 @@
|
||||
# LST-61 (1.3) · Audit log — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
|
||||
|
||||
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
|
||||
|
||||
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
|
||||
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
|
||||
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
|
||||
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
|
||||
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
|
||||
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
|
||||
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
|
||||
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Créés :**
|
||||
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
|
||||
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
|
||||
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
|
||||
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
|
||||
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
||||
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
|
||||
- `tests/Functional/Module/Core/AuditListenerTest.php`
|
||||
- `tests/Functional/Module/Core/AuditLogApiTest.php`
|
||||
- `frontend/modules/core/services/audit-logs.ts`
|
||||
- `frontend/components/admin/AdminAuditTab.vue`
|
||||
|
||||
**Modifiés :**
|
||||
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
|
||||
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
|
||||
- `src/Module/Core/Domain/Entity/User.php` — `#[Auditable]` + `#[AuditIgnore]` password/apiToken
|
||||
- `src/Module/Core/Domain/Entity/Role.php` — `#[Auditable]`
|
||||
- `src/Module/Core/Domain/Entity/Permission.php` — `#[Auditable]`
|
||||
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
|
||||
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
|
||||
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
|
||||
|
||||
---
|
||||
|
||||
## Task A: Marquage + table + connexion DBAL audit
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
|
||||
- Create: `migrations/Version20260619XXXXXX.php`
|
||||
- Modify: `config/packages/doctrine.yaml`
|
||||
|
||||
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
|
||||
|
||||
- [ ] **Step A1: Attributs** — créer les deux fichiers :
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Shared/Domain/Attribute/Auditable.php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marker placed on a Doctrine entity to enable audit tracking.
|
||||
*
|
||||
* Located in Shared (not Core) so every module can use it without a
|
||||
* circular dependency on Core. Any migrated business entity that should be
|
||||
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_CLASS)]
|
||||
final class Auditable
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
<?php
|
||||
// src/Shared/Domain/Attribute/AuditIgnore.php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Attribute;
|
||||
|
||||
use Attribute;
|
||||
|
||||
/**
|
||||
* Marker placed on an entity property to exclude it from audit tracking.
|
||||
*
|
||||
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
|
||||
* carries an exact-match blacklist on the most dangerous names as
|
||||
* defense-in-depth, but the base rule is to annotate explicitly here.
|
||||
*/
|
||||
#[Attribute(Attribute::TARGET_PROPERTY)]
|
||||
final class AuditIgnore
|
||||
{
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Audit log (LST-61) : append-only `audit_log` table.
|
||||
*
|
||||
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
|
||||
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
|
||||
* flushes from the Doctrine listener. Columns are lowercase snake_case.
|
||||
* Additive only — no DROP/ALTER on existing tables.
|
||||
*/
|
||||
final class Version20260619XXXXXX extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Audit log: create append-only audit_log table + indexes (additive)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE audit_log (
|
||||
id uuid NOT NULL,
|
||||
entity_type VARCHAR(100) NOT NULL,
|
||||
entity_id VARCHAR(64) NOT NULL,
|
||||
action VARCHAR(10) NOT NULL,
|
||||
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
performed_by VARCHAR(100) NOT NULL,
|
||||
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
|
||||
ip_address VARCHAR(45) DEFAULT NULL,
|
||||
request_id VARCHAR(36) DEFAULT NULL,
|
||||
PRIMARY KEY(id)
|
||||
)
|
||||
SQL);
|
||||
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
|
||||
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE audit_log');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
|
||||
|
||||
Remplacer :
|
||||
```yaml
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
```
|
||||
par :
|
||||
```yaml
|
||||
dbal:
|
||||
default_connection: default
|
||||
connections:
|
||||
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
|
||||
# service) to write outside the ORM transaction so audit rows survive
|
||||
# an application-side rollback and avoid transactional entanglement.
|
||||
default:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
# audit_log has no ORM entity (written via raw DBAL). Exclude it
|
||||
# from schema comparison so migrations:diff / schema:validate stay
|
||||
# clean. Creation/teardown stay driven by migrations.
|
||||
schema_filter: '~^(?!audit_log$).+~'
|
||||
audit:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
```
|
||||
|
||||
Et remplacer le bloc `when@test` :
|
||||
```yaml
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
```
|
||||
par :
|
||||
```yaml
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# Propagate the _test suffix to BOTH connections: the audit
|
||||
# connection must write to the test DB, not the dev DB.
|
||||
connections:
|
||||
default:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
audit:
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
```
|
||||
|
||||
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
|
||||
|
||||
- [ ] **Step A5: Appliquer la migration (dev + test)** :
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
|
||||
```
|
||||
Expected: table créée, `audit_log` absente de tout diff généré.
|
||||
|
||||
- [ ] **Step A6: Commit**
|
||||
```bash
|
||||
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
|
||||
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task B: AuditLogWriter + RequestIdProvider
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
|
||||
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
|
||||
|
||||
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
|
||||
|
||||
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Provides an HTTP request identifier (UUID v4) shared by every audit row
|
||||
* produced during a single main request. Null in CLI (fixtures, batch).
|
||||
*/
|
||||
final class RequestIdProvider
|
||||
{
|
||||
private ?string $requestId = null;
|
||||
|
||||
#[AsEventListener(event: 'kernel.request')]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requestId = Uuid::v4()->toRfc4122();
|
||||
}
|
||||
|
||||
public function getRequestId(): ?string
|
||||
{
|
||||
return $this->requestId;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Audit;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Low-level service responsible for writing into the `audit_log` table.
|
||||
*
|
||||
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
|
||||
* outside the ORM transaction: audit rows survive an application-side
|
||||
* rollback and avoid transactional entanglement in batch (fixtures).
|
||||
*
|
||||
* Sensitive keys are stripped in defense-in-depth even when entities already
|
||||
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
|
||||
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
|
||||
* business flow.
|
||||
*/
|
||||
final class AuditLogWriter
|
||||
{
|
||||
/** @var list<string> keys always stripped from the `changes` payload */
|
||||
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
|
||||
|
||||
public function __construct(
|
||||
#[Autowire(service: 'doctrine.dbal.audit_connection')]
|
||||
private readonly Connection $connection,
|
||||
private readonly Security $security,
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly RequestIdProvider $requestIdProvider,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $entityType Format "module.Entity" (e.g. "core.User")
|
||||
* @param string $entityId Entity id (int or serialized UUID)
|
||||
* @param string $action create|update|delete
|
||||
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
|
||||
*/
|
||||
public function log(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $action,
|
||||
array $changes,
|
||||
): void {
|
||||
$filteredChanges = $this->stripSensitive($changes);
|
||||
|
||||
$this->connection->insert('audit_log', [
|
||||
'id' => Uuid::v7()->toRfc4122(),
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'action' => $action,
|
||||
'changes' => $filteredChanges,
|
||||
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
|
||||
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
|
||||
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
|
||||
'request_id' => $this->requestIdProvider->getRequestId(),
|
||||
], [
|
||||
'id' => Types::GUID,
|
||||
'changes' => Types::JSON,
|
||||
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively removes sensitive keys from the payload.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function stripSensitive(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, self::SENSITIVE_KEYS, true)) {
|
||||
unset($data[$key]);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$data[$key] = $this->stripSensitive($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
|
||||
```
|
||||
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
|
||||
|
||||
- [ ] **Step B4: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/Infrastructure/Audit/
|
||||
git commit -m "feat(core) : add audit log writer and request id provider"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task C: AuditListener + marquage des entités Core
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
|
||||
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
|
||||
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
|
||||
|
||||
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
|
||||
|
||||
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)** — `tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AuditListenerTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $auditConnection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$container = self::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
|
||||
// Clean slate for deterministic assertions.
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
}
|
||||
|
||||
public function testCreateUserIsAudited(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_create_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('create', $rows[0]['action']);
|
||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
||||
self::assertArrayHasKey('username', $changes);
|
||||
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
|
||||
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
|
||||
}
|
||||
|
||||
public function testUpdateUserIsAuditedWithDiff(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_update_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
|
||||
$user->setFirstName('Changed');
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', (string) $user->getId());
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('update', $rows[0]['action']);
|
||||
$changes = json_decode((string) $rows[0]['changes'], true);
|
||||
self::assertArrayHasKey('firstName', $changes);
|
||||
self::assertSame('Changed', $changes['firstName']['new']);
|
||||
}
|
||||
|
||||
public function testDeleteUserIsAudited(): void
|
||||
{
|
||||
$user = $this->makeUser('audit_delete_user');
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
$id = (string) $user->getId();
|
||||
$this->auditConnection->executeStatement('DELETE FROM audit_log');
|
||||
|
||||
$this->em->remove($user);
|
||||
$this->em->flush();
|
||||
|
||||
$rows = $this->fetchLogs('core.User', $id);
|
||||
self::assertCount(1, $rows);
|
||||
self::assertSame('delete', $rows[0]['action']);
|
||||
}
|
||||
|
||||
private function makeUser(string $username): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername($username);
|
||||
$user->setPassword('hashed-secret');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function fetchLogs(string $entityType, string $entityId): array
|
||||
{
|
||||
return $this->auditConnection->fetchAllAssociative(
|
||||
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
|
||||
['t' => $entityType, 'id' => $entityId],
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
unset($this->em, $this->auditConnection);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
|
||||
|
||||
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
||||
```
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
|
||||
|
||||
- [ ] **Step C4: Marquer les entités Core.**
|
||||
|
||||
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
|
||||
```php
|
||||
use App\Shared\Domain\Attribute\Auditable;
|
||||
use App\Shared\Domain\Attribute\AuditIgnore;
|
||||
```
|
||||
```php
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
class User implements ...
|
||||
```
|
||||
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
|
||||
|
||||
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
|
||||
|
||||
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
|
||||
|
||||
- [ ] **Step C5: Run le test → succès** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
|
||||
```
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step C6: Suite complète + cs-fixer** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
Expected: tout vert.
|
||||
|
||||
- [ ] **Step C7: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
|
||||
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task D: API de lecture `/api/audit-logs` + permission
|
||||
|
||||
**Files:**
|
||||
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
|
||||
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
|
||||
|
||||
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
|
||||
|
||||
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
|
||||
```php
|
||||
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
|
||||
```
|
||||
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
|
||||
|
||||
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
|
||||
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
|
||||
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
|
||||
|
||||
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
|
||||
|
||||
- [ ] **Step D3: Écrire le test API (échec attendu)** — `tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
|
||||
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
|
||||
- filtre `?action=update` → ne renvoie que des updates.
|
||||
- filtre `?entity_type=core.User`.
|
||||
- `?action=bogus` → 400.
|
||||
- utilisateur sans permission (alice) : 403.
|
||||
- non authentifié : 401.
|
||||
|
||||
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
|
||||
|
||||
- [ ] **Step D4: Run → échec, puis vérifier la route** :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
|
||||
```
|
||||
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
|
||||
|
||||
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
|
||||
```
|
||||
|
||||
- [ ] **Step D6: Suite complète + cs-fixer**
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step D7: Commit**
|
||||
```bash
|
||||
git add src/Module/Core/ tests/
|
||||
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task E: Front — page consultation gated RBAC
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
|
||||
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
|
||||
|
||||
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
|
||||
|
||||
- [ ] **Step E1: Service** — `frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
|
||||
|
||||
- [ ] **Step E2: Composant onglet** — `frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
|
||||
|
||||
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
|
||||
|
||||
- [ ] **Step E4: i18n** — `frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
|
||||
|
||||
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
|
||||
```bash
|
||||
cd frontend && npx nuxt build 2>&1 | tail -5
|
||||
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
|
||||
```
|
||||
Expected: build OK (la page admin reste enregistrée).
|
||||
|
||||
- [ ] **Step E6: Commit**
|
||||
```bash
|
||||
git add frontend/
|
||||
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task F: Validation finale + statut
|
||||
|
||||
- [ ] **Step F1: Suite complète verte + login fumée**
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
|
||||
|
||||
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
|
||||
```
|
||||
Expected: 0.
|
||||
|
||||
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
|
||||
|
||||
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
|
||||
|
||||
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (couverture spec)
|
||||
|
||||
| Critère d'acceptation | Tâche |
|
||||
|---|---|
|
||||
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
|
||||
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
|
||||
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
|
||||
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
|
||||
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
|
||||
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
|
||||
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
|
||||
|
||||
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
|
||||
@@ -1,976 +0,0 @@
|
||||
# LST-62 (0.2) — Socle front : shell + auto-détection des layers Nuxt — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Poser l'ossature frontend modulaire (shell `app/`, code partagé `shared/`, auto-détection des layers `modules/*/`, sidebar dynamique alimentée par `/api/sidebar`, redirection des routes désactivées) **sans déplacer aucune page métier** — l'app reste « plate » et la navigation ne régresse pas.
|
||||
|
||||
**Architecture:** On s'aligne sur le pattern Starseed : `srcDir: '.'`, layouts/middleware sous `frontend/app/`, composables/stores transverses sous `frontend/shared/` (auto-importés via `imports.dirs`), et un scan `readdirSync('modules/')` qui ajoute chaque `modules/*/` à `extends`. Le backend `/api/modules` + `/api/sidebar` existe déjà (LST-56). On ajoute un **gate de rôle minimal** côté `SidebarProvider`/`SidebarFilter` (ROLE_ADMIN) pour préserver la visibilité de l'Administration sans attendre le RBAC fin (#1.2). Les items **contextuels** (Kanban/Groupes/Archives), **feature-flag** (Documents, Mail) et **user-flag** (Mes absences) restent rendus côté layout, hors `/api/sidebar`.
|
||||
|
||||
**Tech Stack:** Nuxt 4.3, Vue 3.5, Pinia 3, @malio/layer-ui 1.7, @nuxtjs/i18n 10, @nuxt/icon — côté back PHP 8.4 / Symfony 8 / API Platform 4 / PHPUnit 13.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Aucune page métier déplacée** : `frontend/pages/` reste tel quel ; on ne crée AUCUN `frontend/modules/<x>/pages/` peuplé en 0.2 (le dossier `modules/` est créé vide pour le scan).
|
||||
- **Zéro régression de navigation** : tous les liens actuels restent atteignables et correctement gardés (admin reste admin-only).
|
||||
- **Auto-import Nuxt** : les composants/pages référencent les composables/stores **par nom** (`useApi()`, `useAuthStore()`), jamais par chemin → déplacer un fichier entre deux dossiers auto-scannés est transparent. Toujours le vérifier par un `typecheck` après déplacement.
|
||||
- **Commits** : format `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic (message, body, trailers).
|
||||
- **PHP** : `declare(strict_types=1);` en tête ; tests via `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit …`.
|
||||
- **TS** : strict, 4 espaces d'indentation, pas de `any`.
|
||||
- **Pas de migration BDD** dans ce lot (aucune entité touchée).
|
||||
|
||||
## Décisions de conception (actées avec le PO)
|
||||
|
||||
1. **Gate de rôle minimal côté back** : les items/sections réservés (`/team-absences`, `/admin`) portent une clé `roles` dans `config/sidebar.php` ; `SidebarProvider` passe les rôles de l'utilisateur courant à `SidebarFilter` qui masque ce qui n'est pas autorisé. Ce n'est **pas** le RBAC fin (#1.2) — juste ROLE_ADMIN/ROLE_USER.
|
||||
2. **Items contextuels / feature-flag / user-flag hors `/api/sidebar`** : Kanban/Groupes/Archives (contexte `currentProjectId`), Documents (`shareEnabled`), Mail (+ badge non lus), Mes absences (`isEmployee`) restent rendus par le layout comme aujourd'hui.
|
||||
3. **Délta cosmétique assumé** : la sidebar dynamique regroupe le Tableau de bord avec « Mes tâches / Projets / Suivi de temps » sous un même en-tête, et le bloc statique (contextuel/flag/Mes absences) s'insère après cette première section. Léger réordonnancement visuel, **à valider**, harmonisé en #60 (Finition Malio). Aucun lien perdu.
|
||||
|
||||
## Vérification (pas de runner de tests JS dans ce projet)
|
||||
|
||||
- **Back (Task 1)** : vraie TDD PHPUnit.
|
||||
- **Front (Tasks 2-7)** : la verif = `typecheck` Nuxt (en LECTURE différentielle, cf. ci-dessous) + smoke test runtime. Commandes :
|
||||
- Typecheck : `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
- Runtime : dev server `make dev-nuxt` (port 3002, proxy `/api` → nginx) ; vérifier manuellement la navigation + `curl` des endpoints via nginx (`http://localhost:8082/api/...`). Les containers sont up.
|
||||
|
||||
> **⚠️ `nuxt typecheck` n'est PAS un gate vert sur ce projet (constat 2026-06-19).** Le baseline Lesstime est déjà rouge (~230 lignes `error TS`), et le projet de référence **Starseed (même Nuxt 4.3.1, même layout `shared/` + `srcDir: '.'`) ship en prod avec 325 erreurs `error TS`**. Ces erreurs sont des classes structurelles attendues, pas des régressions :
|
||||
> - dans `shared/composables/*` et `shared/stores/*` : `Cannot find name 'ref'/'useApi'/'useRoute'/'navigateTo'/'defineStore'/'useToast'/'useNuxtApp'…` — Nuxt 4 type le dossier `shared/` sous un `tsconfig.shared.json` isolé sans les globals d'auto-import, alors que `imports.dirs` les rend bien disponibles au RUNTIME (vérifié dans `.nuxt/imports.d.ts`). Starseed a exactement ces 15 erreurs et fonctionne.
|
||||
> - dans `nuxt.config.ts` : `node:fs`/`node:path`/`__dirname`/`process` (pas de `@types/node` — comme Starseed) ; ce fichier est compilé par Nuxt au runtime, pas par `tsc`.
|
||||
> - dans `useApi.ts` : `Property 'url' does not exist…` (préexistant, code forké de Starseed).
|
||||
>
|
||||
> **Le vrai gate front** = (1) **ZÉRO erreur `Cannot find module '~/shared/…'` / chemin cassé** (sinon un import a vraiment été cassé par un déplacement) ; (2) les auto-imports attendus présents dans `.nuxt/imports.d.ts` ; (3) smoke runtime sur le dev server. Ne JAMAIS s'arrêter sur les classes d'erreurs structurelles ci-dessus — elles sont identiques à la référence Starseed.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — gate de rôle dans la sidebar (`roles`) + config complète
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Shared/Domain/Sidebar/SidebarFilter.php` (signature + gate `roles`)
|
||||
- Modify: `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` (injecter `Security`, passer les rôles)
|
||||
- Modify: `config/sidebar.php` (navigation globale + section Administration gated ROLE_ADMIN ; retrait de `/absences` qui reste client-side)
|
||||
- Modify: `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` (adapter à la nouvelle signature + cas `roles`)
|
||||
- Modify: `tests/Functional/Shared/SidebarEndpointTest.php` (vérifier le gate admin)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : `SidebarFilter::filter(array $sections, array $activeModuleIds, array $activeRoles = []): array`. Règles ajoutées : une **section** ou un **item** portant une clé `roles` (non vide) n'est conservé que si `$activeRoles` contient au moins un des rôles listés ; sinon la section/l'item est retiré (les `to` des items retirés **par rôle** ne sont PAS ajoutés à `disabledRoutes` — `disabledRoutes` reste réservé au filtrage **par module**, qui pilote la redirection front). Les clés internes `module` et `roles` sont retirées de la sortie.
|
||||
- Consumes : `Symfony\Bundle\SecurityBundle\Security` (rôles via `getUser()`).
|
||||
|
||||
- [ ] **Step 1: Adapter le test unitaire existant + ajouter les cas `roles`**
|
||||
|
||||
Remplace INTÉGRALEMENT `tests/Unit/Shared/Sidebar/SidebarFilterTest.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Shared\Sidebar;
|
||||
|
||||
use App\Shared\Domain\Sidebar\SidebarFilter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SidebarFilterTest extends TestCase
|
||||
{
|
||||
public function testItemWithoutModuleIsAlwaysVisible(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.core.section', 'icon' => 'mdi:home', 'items' => [
|
||||
['label' => 'sidebar.core.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
self::assertArrayNotHasKey('module', $result['sections'][0]['items'][0]);
|
||||
}
|
||||
|
||||
public function testItemWithInactiveModuleIsHiddenAndRouteDisabled(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
|
||||
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertSame([], $result['sections']);
|
||||
self::assertSame(['/time-tracking'], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testItemWithActiveModuleIsVisible(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.tt.section', 'icon' => 'mdi:clock', 'items' => [
|
||||
['label' => 'sidebar.tt.timesheet', 'to' => '/time-tracking', 'icon' => 'mdi:clock', 'module' => 'time_tracking'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, ['time_tracking'], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/time-tracking', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testSectionWithRolesIsHiddenWhenRoleMissing(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
|
||||
['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertSame([], $result['sections']);
|
||||
// Filtrage par rôle => PAS de disabledRoutes (réservé au filtrage par module).
|
||||
self::assertSame([], $result['disabledRoutes']);
|
||||
}
|
||||
|
||||
public function testSectionWithRolesIsVisibleWhenRolePresent(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.admin.section', 'icon' => 'mdi:cog', 'roles' => ['ROLE_ADMIN'], 'items' => [
|
||||
['label' => 'sidebar.admin.admin', 'to' => '/admin', 'icon' => 'mdi:cog'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertSame('/admin', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertArrayNotHasKey('roles', $result['sections'][0]);
|
||||
}
|
||||
|
||||
public function testItemWithRolesIsHiddenWhenRoleMissing(): void
|
||||
{
|
||||
$sections = [
|
||||
['label' => 'sidebar.hr.section', 'icon' => 'mdi:calendar', 'items' => [
|
||||
['label' => 'sidebar.hr.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:account-group', 'roles' => ['ROLE_ADMIN']],
|
||||
['label' => 'sidebar.hr.x', 'to' => '/x', 'icon' => 'mdi:x'],
|
||||
]],
|
||||
];
|
||||
|
||||
$result = SidebarFilter::filter($sections, [], ['ROLE_USER']);
|
||||
|
||||
self::assertCount(1, $result['sections']);
|
||||
self::assertCount(1, $result['sections'][0]['items']);
|
||||
self::assertSame('/x', $result['sections'][0]['items'][0]['to']);
|
||||
self::assertArrayNotHasKey('roles', $result['sections'][0]['items'][0]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
|
||||
Expected: FAIL — `filter()` actuel n'accepte que 2 args / ne gère pas `roles` (erreur d'arité ou assertions rouges).
|
||||
|
||||
- [ ] **Step 3: Étendre `SidebarFilter`**
|
||||
|
||||
Remplace INTÉGRALEMENT `src/Shared/Domain/Sidebar/SidebarFilter.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Sidebar;
|
||||
|
||||
final class SidebarFilter
|
||||
{
|
||||
/**
|
||||
* @param list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sections
|
||||
* @param list<string> $activeModuleIds
|
||||
* @param list<string> $activeRoles
|
||||
*
|
||||
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
|
||||
*/
|
||||
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = []): array
|
||||
{
|
||||
$outSections = [];
|
||||
$disabledRoutes = [];
|
||||
|
||||
foreach ($sections as $section) {
|
||||
// Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module).
|
||||
if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach ($section['items'] as $item) {
|
||||
// Gate de rôle au niveau item.
|
||||
if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
|
||||
$module = $item['module'] ?? null;
|
||||
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
|
||||
$disabledRoutes[] = $item['to'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
|
||||
}
|
||||
|
||||
if ([] !== $items) {
|
||||
$outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
|
||||
}
|
||||
}
|
||||
|
||||
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string>|null $required
|
||||
* @param list<string> $activeRoles
|
||||
*/
|
||||
private static function rolesSatisfied(?array $required, array $activeRoles): bool
|
||||
{
|
||||
if (null === $required || [] === $required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($required as $role) {
|
||||
if (in_array($role, $activeRoles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lancer le test unitaire, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Shared/Sidebar/SidebarFilterTest.php`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Injecter les rôles dans `SidebarProvider`**
|
||||
|
||||
Remplace INTÉGRALEMENT `src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Shared\Domain\Module\ModuleRegistry;
|
||||
use App\Shared\Domain\Sidebar\SidebarFilter;
|
||||
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
final readonly class SidebarProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private string $projectDir,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
|
||||
{
|
||||
/** @var list<class-string> $moduleClasses */
|
||||
$moduleClasses = require $this->projectDir.'/config/modules.php';
|
||||
|
||||
/** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sidebar */
|
||||
$sidebar = require $this->projectDir.'/config/sidebar.php';
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$roles = null !== $user ? $user->getRoles() : [];
|
||||
|
||||
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles));
|
||||
|
||||
$dto = new SidebarResource();
|
||||
$dto->sections = $filtered['sections'];
|
||||
$dto->disabledRoutes = $filtered['disabledRoutes'];
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Compléter `config/sidebar.php`**
|
||||
|
||||
Remplace INTÉGRALEMENT `config/sidebar.php` par (icônes alignées sur le layout actuel ; `/absences` retiré car gardé client-side via `isEmployee`) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
|
||||
* Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
|
||||
* `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
|
||||
* le RBAC fin par permission arrive en #1.2).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
|
||||
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
|
||||
*/
|
||||
return [
|
||||
[
|
||||
'label' => 'sidebar.general.section',
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.admin.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Renforcer le test fonctionnel sidebar (gate admin)**
|
||||
|
||||
Remplace INTÉGRALEMENT `tests/Functional/Shared/SidebarEndpointTest.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Shared;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class SidebarEndpointTest extends WebTestCase
|
||||
{
|
||||
public function testSidebarRequiresAuthentication(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$client->request('GET', '/api/sidebar');
|
||||
|
||||
self::assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testSidebarReturnsSectionsForAuthenticatedUser(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertArrayHasKey('sections', $data);
|
||||
self::assertArrayHasKey('disabledRoutes', $data);
|
||||
self::assertNotEmpty($data['sections']);
|
||||
}
|
||||
|
||||
public function testAdminSectionHiddenForNonAdmin(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // ROLE_USER
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$labels = array_column($data['sections'], 'label');
|
||||
|
||||
self::assertNotContains('sidebar.admin.section', $labels);
|
||||
}
|
||||
|
||||
public function testAdminSectionVisibleForAdmin(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
$em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); // ROLE_ADMIN
|
||||
$client->loginUser($user);
|
||||
|
||||
$client->request('GET', '/api/sidebar');
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$labels = array_column($data['sections'], 'label');
|
||||
|
||||
self::assertContains('sidebar.admin.section', $labels);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Lancer la suite complète, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (les 110 tests précédents adaptés + nouveaux cas). Si `admin`/`alice` n'existent pas en base de test, vérifier les fixtures (`admin`/`admin`, `alice`/`alice` d'après CLAUDE.md).
|
||||
|
||||
- [ ] **Step 9: php-cs-fixer + commit**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Shared/Domain/Sidebar/SidebarFilter.php src/Shared/Infrastructure/ApiPlatform/State/SidebarProvider.php config/sidebar.php tests/Unit/Shared/Sidebar/SidebarFilterTest.php tests/Functional/Shared/SidebarEndpointTest.php
|
||||
git commit -m "feat(sidebar) : add role gate to sidebar provider and global nav config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — types + composables partagés (`useModules`, `useSidebar`)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/shared/types/sidebar.ts`
|
||||
- Create: `frontend/shared/composables/useModules.ts`
|
||||
- Create: `frontend/shared/composables/useSidebar.ts`
|
||||
|
||||
> Note : à cette étape `shared/` n'est pas encore dans `imports.dirs` (fait en Task 4). Ces fichiers sont créés ici mais référencés/auto-importés seulement après Task 4 ; le typecheck final de validation se fait donc en fin de Task 4. Cette task se termine sans verif runtime (pur ajout de fichiers).
|
||||
|
||||
**Interfaces:**
|
||||
- Produces :
|
||||
- `useModules(): { activeModuleIds: Ref<string[]>, loaded: Ref<boolean>, loadModules(): Promise<void>, isModuleActive(id: string): boolean, resetModules(): void }`
|
||||
- `useSidebar(): { sections: Ref<SidebarSection[]>, disabledRoutes: Ref<string[]>, loaded: Ref<boolean>, loadSidebar(): Promise<void>, isRouteDisabled(path: string): boolean, resetSidebar(): void }`
|
||||
- `SidebarSection`, `SidebarItem` (types).
|
||||
- Consumes : `useApi()` (auto-importé, déplacé en Task 3 — toujours appelé par nom).
|
||||
|
||||
- [ ] **Step 1: Créer les types**
|
||||
|
||||
`frontend/shared/types/sidebar.ts` :
|
||||
|
||||
```ts
|
||||
export type SidebarItem = {
|
||||
label: string
|
||||
to: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export type SidebarSection = {
|
||||
label: string
|
||||
icon: string
|
||||
items: SidebarItem[]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Créer `useModules`**
|
||||
|
||||
`frontend/shared/composables/useModules.ts` (état singleton au niveau module) :
|
||||
|
||||
```ts
|
||||
const activeModuleIds = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useModules() {
|
||||
async function loadModules(): Promise<void> {
|
||||
const api = useApi()
|
||||
const data = await api.get<{ modules: string[] }>('/modules', {}, { toast: false })
|
||||
activeModuleIds.value = data.modules ?? []
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function isModuleActive(id: string): boolean {
|
||||
return activeModuleIds.value.includes(id)
|
||||
}
|
||||
|
||||
function resetModules(): void {
|
||||
activeModuleIds.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { activeModuleIds, loaded, loadModules, isModuleActive, resetModules }
|
||||
}
|
||||
```
|
||||
|
||||
> Vérifier la signature réelle de `useApi().get` (Task 3 / source actuelle) : `get<T>(url, query?, options?)`. L'option `{ toast: false }` doit exister dans `ApiFetchOptions` ; si la clé diffère (ex. `toastSuccessKey`/`toast`), aligner sur la signature réelle de `useApi.ts`. Si aucune option « silencieux » n'existe, passer `{}`.
|
||||
|
||||
- [ ] **Step 3: Créer `useSidebar`**
|
||||
|
||||
`frontend/shared/composables/useSidebar.ts` :
|
||||
|
||||
```ts
|
||||
import type { SidebarSection } from '~/shared/types/sidebar'
|
||||
|
||||
const sections = ref<SidebarSection[]>([])
|
||||
const disabledRoutes = ref<string[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
export function useSidebar() {
|
||||
async function loadSidebar(): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
function isRouteDisabled(path: string): boolean {
|
||||
return disabledRoutes.value.some(
|
||||
(disabled) => path === disabled || path.startsWith(disabled + '/'),
|
||||
)
|
||||
}
|
||||
|
||||
function resetSidebar(): void {
|
||||
sections.value = []
|
||||
disabledRoutes.value = []
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return { sections, disabledRoutes, loaded, loadSidebar, isRouteDisabled, resetSidebar }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/shared/types/sidebar.ts frontend/shared/composables/useModules.ts frontend/shared/composables/useSidebar.ts
|
||||
git commit -m "feat(front) : add shared useModules/useSidebar composables and sidebar types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — déplacer `useApi` et les stores transverses vers `shared/`
|
||||
|
||||
**Files:**
|
||||
- Move: `frontend/composables/useApi.ts` → `frontend/shared/composables/useApi.ts`
|
||||
- Move: `frontend/stores/auth.ts` → `frontend/shared/stores/auth.ts`
|
||||
- Move: `frontend/stores/ui.ts` → `frontend/shared/stores/ui.ts`
|
||||
|
||||
> `timer.ts` et `mail.ts` **restent** dans `frontend/stores/` (domaines métier non encore migrés en module). On ne déplace que les deux stores transverses (auth, ui) + `useApi`. La résolution effective (auto-import depuis `shared/`) est activée en Task 4 ; cette task fait les `git mv` et termine par un commit. Le typecheck de validation est en Task 4 (après config).
|
||||
|
||||
- [ ] **Step 1: Déplacer les fichiers (git mv pour préserver l'historique)**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p shared/stores
|
||||
git mv composables/useApi.ts shared/composables/useApi.ts
|
||||
git mv stores/auth.ts shared/stores/auth.ts
|
||||
git mv stores/ui.ts shared/stores/ui.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier qu'aucun import par CHEMIN ne pointe vers les anciens emplacements**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "composables/useApi\|stores/auth\|stores/ui" --include=*.ts --include=*.vue . | grep -v node_modules | grep -v "shared/"`
|
||||
Expected: aucun résultat (tout passe par auto-import). Si un import explicite existe (ex. `from '~/composables/useApi'`), le corriger en `from '~/shared/composables/useApi'` ou retirer l'import (auto-import). Noter chaque correction.
|
||||
|
||||
> `layouts/default.vue` importe actuellement `useAppVersion` depuis `~/composables/useAppVersion` (NON déplacé) — ne pas y toucher ici.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "refactor(front) : move useApi and shared stores (auth, ui) to shared/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — `nuxt.config.ts` (srcDir, dossiers `app/`, scan des layers, auto-imports)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/nuxt.config.ts`
|
||||
- Create: `frontend/modules/.gitkeep` (dossier vide prêt pour le scan)
|
||||
- Move: `frontend/layouts/` → `frontend/app/layouts/` (default.vue, auth.vue)
|
||||
- Move: `frontend/middleware/` → `frontend/app/middleware/` (auth.global.ts, admin.ts, employee.ts)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : structure `app/{layouts,middleware}`, `modules/` scannable, `shared/*` auto-importé.
|
||||
|
||||
- [ ] **Step 1: Déplacer layouts et middleware sous `app/`**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p app modules
|
||||
git mv layouts app/layouts
|
||||
git mv middleware app/middleware
|
||||
touch modules/.gitkeep
|
||||
git add modules/.gitkeep
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Réécrire `nuxt.config.ts`**
|
||||
|
||||
Remplace INTÉGRALEMENT `frontend/nuxt.config.ts` par (conserve `vite`/`toast` existants — repris depuis la version actuelle) :
|
||||
|
||||
```ts
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const modulesDir = resolve(__dirname, 'modules')
|
||||
const moduleDirs = existsSync(modulesDir)
|
||||
? readdirSync(modulesDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
: []
|
||||
const moduleLayers = moduleDirs.map((name) => `./modules/${name}`)
|
||||
const moduleComposableDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/composables`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
const moduleStoreDirs = moduleDirs
|
||||
.map((name) => `modules/${name}/stores`)
|
||||
.filter((path) => existsSync(resolve(__dirname, path)))
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
ssr: false,
|
||||
srcDir: '.',
|
||||
css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
|
||||
app: {
|
||||
baseURL: process.env.NODE_ENV === 'production'
|
||||
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
|
||||
: '/',
|
||||
},
|
||||
extends: ['@malio/layer-ui', ...moduleLayers],
|
||||
modules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
'@pinia/nuxt',
|
||||
'nuxt-toast',
|
||||
'@nuxtjs/i18n',
|
||||
'@nuxt/icon',
|
||||
],
|
||||
dir: {
|
||||
layouts: 'app/layouts',
|
||||
middleware: 'app/middleware',
|
||||
},
|
||||
imports: {
|
||||
dirs: [
|
||||
'shared/composables',
|
||||
'shared/stores',
|
||||
'shared/utils',
|
||||
'composables',
|
||||
'stores',
|
||||
'utils',
|
||||
...moduleComposableDirs,
|
||||
...moduleStoreDirs,
|
||||
],
|
||||
},
|
||||
pinia: {
|
||||
storesDirs: ['shared/stores/**', 'stores/**', 'modules/*/stores/**'],
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||
},
|
||||
},
|
||||
devServer: {
|
||||
port: 3002,
|
||||
},
|
||||
components: [
|
||||
{ path: '~/components', pathPrefix: false },
|
||||
],
|
||||
// ⬇️ Reprendre VERBATIM les blocs `vite: {...}`, `toast: {...}`, `i18n: {...}`,
|
||||
// `typescript: {...}`, `build: {...}` de l'ancien nuxt.config.ts (inchangés).
|
||||
typescript: { strict: true },
|
||||
build: { transpile: ['@vuepic/vue-datepicker'] },
|
||||
})
|
||||
```
|
||||
|
||||
> ⚠️ Les blocs `vite`, `toast`, `i18n` de l'ancienne config ne sont pas réécrits ici : **les recopier à l'identique** depuis la version d'origine (récupérable via `git show HEAD~1:frontend/nuxt.config.ts` après les déplacements). Le `i18n.langDir: 'locales'` reste résolu depuis `i18n/`.
|
||||
|
||||
- [ ] **Step 3: Typecheck complet (valide Tasks 2, 3 et 4)**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur. Pièges probables :
|
||||
- Store non trouvé → vérifier `pinia.storesDirs` inclut bien `shared/stores/**`.
|
||||
- Composable non auto-importé → vérifier `imports.dirs` inclut `shared/composables`.
|
||||
- `~/composables/useApi` cassé → un import explicite a survécu (corriger comme Task 3 Step 2).
|
||||
|
||||
- [ ] **Step 4: Smoke test runtime — l'app boote et la nav existante fonctionne**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime && make dev-nuxt` (ou rebuild SPA selon le workflow). Ouvrir l'app, se connecter (`alice`/`alice`), vérifier que la sidebar **statique actuelle** s'affiche encore et que la navigation marche (le layout n'est pas encore dynamisé — c'est normal). Aucun écran blanc / erreur console bloquante.
|
||||
Expected: app fonctionnelle, identique à avant (les déplacements sont transparents).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — middlewares (`auth.global.ts` étendu + `modules.global.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/middleware/auth.global.ts` (charge sidebar + modules après login ; reset au logout)
|
||||
- Create: `frontend/app/middleware/modules.global.ts` (redirige les routes désactivées)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : `useAuthStore()`, `useSidebar()`, `useModules()` (auto-importés).
|
||||
|
||||
- [ ] **Step 1: Étendre `auth.global.ts`**
|
||||
|
||||
Remplace INTÉGRALEMENT `frontend/app/middleware/auth.global.ts` par :
|
||||
|
||||
```ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
} else {
|
||||
// Logout / session expirée : purge l'état partagé pour le prochain login.
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Créer `modules.global.ts`**
|
||||
|
||||
`frontend/app/middleware/modules.global.ts` :
|
||||
|
||||
```ts
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
|
||||
if (isRouteDisabled(to.path)) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
> Ordre des middlewares globaux : Nuxt les exécute par ordre alphabétique de nom de fichier → `auth.global.ts` puis `modules.global.ts`. C'est l'ordre voulu (auth charge la sidebar avant que modules teste les routes désactivées).
|
||||
|
||||
- [ ] **Step 3: Typecheck**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur.
|
||||
|
||||
- [ ] **Step 4: Smoke test — chargement sidebar/modules + redirection**
|
||||
|
||||
Avec le dev server : se connecter (`alice`), ouvrir l'onglet Réseau → confirmer un `GET /api/sidebar` et `GET /api/modules` après login. Vérifier la redirection : ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'` (module inactif) et un `'to' => '/demo-disabled'`, recharger, confirmer que `/demo-disabled` apparaît dans `disabledRoutes` (réponse `/api/sidebar`) et qu'y naviguer redirige vers `/`. **Puis retirer l'item de démo** (ne pas committer ce stub).
|
||||
Expected: appels présents, redirection effective.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/middleware/auth.global.ts frontend/app/middleware/modules.global.ts
|
||||
git commit -m "feat(front) : load sidebar/modules after login and redirect disabled routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — layout `default.vue` : sidebar dynamique + items conservés
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/layouts/default.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : `useSidebar()` (sections dynamiques traduites), `useUiStore()`, `useAuthStore()`, `useI18n()`, + le reste de la logique existante (timer, mail, refData) conservée VERBATIM.
|
||||
|
||||
> Stratégie : on remplace le bloc statique des items **globaux** (Tableau de bord, Mes tâches, Projets, Suivi de temps, Absences équipe, Administration) par un rendu **dynamique** issu de `useSidebar()`. On **conserve** les `SidebarLink` des items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail + badge) et user-flag (Mes absences) tels quels. Tout le `<script setup>` non lié à la sidebar (timer, drawer, head, mail polling, refData) est conservé à l'identique.
|
||||
|
||||
- [ ] **Step 1: Réécrire le bloc `<nav>` et l'en-tête du `<script setup>` de `frontend/app/layouts/default.vue`**
|
||||
|
||||
Dans le `<template>`, remplace le contenu de `<nav class="flex-1 overflow-hidden" …>…</nav>` (lignes ~40-167 de l'original) par :
|
||||
|
||||
```vue
|
||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
||||
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
|
||||
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
{{ section.label }}
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
|
||||
<template v-if="sIndex === 0">
|
||||
<!-- Contextuel projet -->
|
||||
<template v-if="currentProjectId">
|
||||
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
<!-- Feature-flag : Documents -->
|
||||
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<!-- Feature-flag : Mail + badge -->
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
|
||||
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
```
|
||||
|
||||
Dans le `<script setup lang="ts">`, **ajoute** en tête (après les `useXxxStore()` existants) :
|
||||
|
||||
```ts
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
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,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
```
|
||||
|
||||
**Conserve** tout le reste du `<script setup>` (`isAdmin`, `isEmployee`, `isMailVisible`, `isDocumentsVisible`, `currentProjectId`, `sidebarIsCollapsed`, timer/drawer/head/mail/refData…) et le `<style scoped>` à l'identique. `isAdmin`/`isAbsenceSectionVisible` deviennent inutilisés pour la sidebar (l'admin est gated côté serveur) — si le typecheck signale une variable inutilisée, retirer `isAbsenceSectionVisible` ; garder `isAdmin` s'il sert ailleurs, sinon le retirer aussi.
|
||||
|
||||
- [ ] **Step 2: Typecheck**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Expected: 0 erreur (corriger toute variable / tout import inutilisé signalé).
|
||||
|
||||
- [ ] **Step 3: Smoke test visuel — non-régression de navigation**
|
||||
|
||||
Dev server. Se connecter successivement :
|
||||
- `alice` (ROLE_USER) : sidebar affiche Tableau de bord / Mes tâches / Projets / Suivi de temps (dynamiques), + Documents/Mail si visibles, + Mes absences si employé ; **PAS** de section Administration ni Absences équipe.
|
||||
- `admin` (ROLE_ADMIN) : en plus, section **Administration** avec Absences équipe + Administration.
|
||||
- Entrer dans un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent (contextuel conservé).
|
||||
Expected: tous les liens d'avant atteignables ; gating admin respecté. Noter tout délta visuel (ordre) pour validation PO (cf. décision 3).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/layouts/default.vue
|
||||
git commit -m "feat(front) : render dynamic sidebar from /api/sidebar in default layout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Frontend — clés i18n `sidebar.*` + vérification bout-en-bout
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json` (ajouter le namespace `sidebar`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : les labels renvoyés par `/api/sidebar` (`sidebar.general.*`, `sidebar.admin.*`) traduits par `t()` dans `translatedSections`.
|
||||
|
||||
- [ ] **Step 1: Repérer la structure du fichier i18n**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && head -20 i18n/locales/fr.json`
|
||||
Objectif : connaître l'indentation et confirmer que c'est un objet JSON imbriqué (ajouter une clé racine `sidebar`).
|
||||
|
||||
- [ ] **Step 2: Ajouter le namespace `sidebar`**
|
||||
|
||||
Ajoute (à la racine de l'objet JSON, en respectant l'indentation existante) :
|
||||
|
||||
```json
|
||||
"sidebar": {
|
||||
"general": {
|
||||
"section": "Gestion de projet",
|
||||
"dashboard": "Tableau de bord",
|
||||
"myTasks": "Mes tâches",
|
||||
"projects": "Projets",
|
||||
"timeTracking": "Suivi de temps"
|
||||
},
|
||||
"admin": {
|
||||
"section": "Administration",
|
||||
"teamAbsences": "Absences équipe",
|
||||
"administration": "Administration"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Les libellés reprennent ceux du layout actuel. `sidebar.general.section` = « Gestion de projet » (regroupe désormais le Tableau de bord — délta cosmétique acté, décision 3).
|
||||
|
||||
- [ ] **Step 3: Typecheck + smoke i18n**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck`
|
||||
Dev server : confirmer que les en-têtes/labels de sidebar s'affichent **traduits** (pas les clés brutes `sidebar.general.*`).
|
||||
Expected: libellés FR corrects.
|
||||
|
||||
- [ ] **Step 4: Vérification bout-en-bout de l'activation/désactivation (AC)**
|
||||
|
||||
Test manuel documenté (aucun module réel en 0.2) :
|
||||
1. Ajouter TEMPORAIREMENT dans `config/sidebar.php` un item avec `'module' => 'demo'`, `'to' => '/projects'` (route existante) dans une section visible.
|
||||
2. `config/modules.php` reste vide (module `demo` inactif) → `GET /api/sidebar` doit lister `/projects` dans `disabledRoutes` et masquer l'item ; naviguer vers `/projects` doit rediriger vers `/`.
|
||||
3. Ajouter une classe `DemoModule implements ModuleInterface { id()='demo' … }` + `config/modules.php` = `[DemoModule::class]` → l'item réapparaît, `/projects` n'est plus dans `disabledRoutes`, la navigation fonctionne.
|
||||
4. **Tout retirer** (item démo + DemoModule + entrée modules.php). Confirmer l'état initial.
|
||||
Documenter le résultat dans le message de fin. **Ne rien committer de ce stub.**
|
||||
|
||||
- [ ] **Step 5: Suite back + cs-fixer (non-régression globale) + commit**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: vert (inchangé vs Task 1).
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && npx nuxt typecheck` → 0 erreur.
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(front) : add sidebar i18n labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance check (après toutes les tasks)
|
||||
|
||||
- [ ] `frontend/app/{layouts,middleware}`, `frontend/shared/{composables,stores,types}`, `frontend/modules/` (vide) en place ; `nuxt.config.ts` scanne `modules/*/`.
|
||||
- [ ] Sidebar **dynamique** alimentée par `/api/sidebar` pour la nav globale ; gate ROLE_ADMIN effectif (admin-only invisible pour `alice`).
|
||||
- [ ] Route d'un module désactivé → **redirigée** vers `/` (vérifié via le stub démo).
|
||||
- [ ] **Aucune page métier déplacée** ; `frontend/pages/` intact ; tous les liens actuels atteignables.
|
||||
- [ ] `npx nuxt typecheck` = 0 erreur ; suite PHPUnit verte ; aucune migration BDD.
|
||||
- [ ] Délta cosmétique d'ordre de sidebar présenté au PO pour validation.
|
||||
|
||||
## Notes pour le ticket suivant (1.1 — Module Core)
|
||||
|
||||
Le 1.1 migrera `User`/Auth dans `src/Module/Core/`, re-pointera `resolve_target_entities` vers `Module\Core\User`, déclarera `CoreModule` (REQUIRED) dans `config/modules.php`, et créera le premier vrai layer front `frontend/modules/core/` (login, profile, admin users) — c'est là que le scan de layers et `useModules`/`useSidebar` prennent tout leur sens (premier item de sidebar avec une clé `module` réelle).
|
||||
@@ -1,732 +0,0 @@
|
||||
# LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & Notifications — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Migrer l'identité (`User` + Auth/JWT + password hashing + `MeProvider`) et les notifications dans `src/Module/Core/`, exposer le contrat `UserInterface` enrichi + `NotifierInterface`, déclarer `CoreModule` (REQUIRED), et créer le premier vrai layer front `modules/core/` — **sans aucune migration destructive et sans casser le login à aucune étape**.
|
||||
|
||||
**Architecture:** Strangler 100 % additif, phasé. On déplace physiquement la classe `User` vers `App\Module\Core\Domain\Entity\User` (table `user` inchangée → zéro migration), on re-pointe `resolve_target_entities` et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret `App\Entity\User` vers le **contrat** `App\Shared\Domain\Contract\UserInterface` (enrichi des accessors réellement utilisés). Les notifications passent par un `NotifierInterface` (impl Core). Chaque phase laisse `make test` vert ET le login JWT fonctionnel (re-vérifié par curl).
|
||||
|
||||
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **`declare(strict_types=1);`** en tête de chaque fichier PHP.
|
||||
- **Zéro migration destructive** : le déplacement de namespace ne change ni la table (`user`) ni les colonnes → `doctrine:migrations:diff` doit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration.
|
||||
- **Login JWT fonctionnel à chaque phase** : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant `User`/sécurité.
|
||||
- **AC ticket** : (1) login/JWT OK via le module ; (2) aucun `use App\Entity\User;` hors `src/Module/Core/` ; (3) `make test` vert, aucune migration destructive.
|
||||
- **Commits** : `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic.
|
||||
- **`config/reference.php`** : auto-généré, **jamais committé** (apparaît modifié dans `git status`).
|
||||
- **Tests** : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. Baseline avant ce ticket : **115 tests, 227 assertions** (16 PHPUnit Notices préexistantes, non bloquantes).
|
||||
- **Front** : `nuxt typecheck` n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro `Cannot find module`, auto-imports présents dans `.nuxt/imports.d.ts`, smoke runtime.
|
||||
- **PostgreSQL** : noms de colonnes en minuscules dans le SQL brut.
|
||||
|
||||
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
|
||||
|
||||
```bash
|
||||
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
|
||||
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
|
||||
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
|
||||
-o /dev/null -w "login http=%{http_code}\n"
|
||||
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
|
||||
# MCP apiToken (ApiTokenAuthenticator) — admin
|
||||
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
|
||||
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
|
||||
```
|
||||
Attendu : `login http=204`, `me http=200` avec le JSON de l'utilisateur (`username`, `roles`), MCP répond (200). **Si l'un casse, arrêter la phase et corriger avant de committer.**
|
||||
|
||||
## Décisions de conception (actées, à valider PO a posteriori)
|
||||
|
||||
1. **`UserInterface` enrichi (contrat de lecture)** — plutôt que de garder `App\Entity\User` partout, on enrichit `App\Shared\Domain\Contract\UserInterface` des accessors **réellement consommés** hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.
|
||||
2. **Move physique, table inchangée** — `User` change de namespace mais garde `#[ORM\Table(name: '`user`')]` et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mapping `Core`).
|
||||
3. **Relations via le contrat** — les 8 entités passent à `targetEntity: UserInterface::class` + type `?UserInterface`, résolu par `resolve_target_entities → Core\User`. C'est le pattern Starseed.
|
||||
4. **Notification dans Core + `NotifierInterface`** — `Notification` migre dans Core (couplée à l'identité) ; la création de notif passe par `NotifierInterface` (impl Core), `TaskNotificationListener` (qui reste legacy en Phase D) en dépend par contrat. L'API REST `/api/notifications` est préservée à l'identique.
|
||||
5. **Front layer `modules/core/`** — login, profile, admin users **déplacés** de `frontend/pages/` vers `frontend/modules/core/pages/` (premier layer réel ; le scan `readdirSync('modules/')` de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Squelette Core + contrats (100 % additif, app inchangée)
|
||||
|
||||
### Task 1: `CoreModule` + `UserRepositoryInterface` + `NotifierInterface` + contrat `UserInterface` enrichi
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Module/Core/CoreModule.php`
|
||||
- Create: `src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
|
||||
- Create: `src/Shared/Domain/Contract/NotifierInterface.php`
|
||||
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (enrichir)
|
||||
- Create: `tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces :
|
||||
- `App\Module\Core\CoreModule implements ModuleInterface` : `id()='core'`, `label()='Core'`, `isRequired()=true`, `permissions()` (stub pour 1.2, voir code).
|
||||
- `App\Module\Core\Domain\Repository\UserRepositoryInterface` : `findByRole(string $role): array`, `findActiveEmployees(\DateTimeInterface $date): array`, `findOneByUsername(string $username): ?UserInterface`.
|
||||
- `App\Shared\Domain\Contract\NotifierInterface` : `notify(UserInterface $user, string $type, string $title, string $message): void`.
|
||||
- `UserInterface` enrichi (lecture) : `getId(): ?int`, `getUserIdentifier(): string`, `getUsername(): string`, `getRoles(): array`, `getFirstName(): ?string`, `getLastName(): ?string`, `getAvatarUrl(): ?string`, `isEmployee(): bool`.
|
||||
- Consumes : `App\Shared\Domain\Module\ModuleInterface` (existant).
|
||||
|
||||
- [ ] **Step 1: Écrire le test unitaire `CoreModule`**
|
||||
|
||||
`tests/Unit/Module/Core/CoreModuleTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Module\Core;
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class CoreModuleTest extends TestCase
|
||||
{
|
||||
public function testItIsAModule(): void
|
||||
{
|
||||
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
|
||||
}
|
||||
|
||||
public function testIdentity(): void
|
||||
{
|
||||
self::assertSame('core', CoreModule::id());
|
||||
self::assertTrue(CoreModule::isRequired());
|
||||
self::assertNotSame('', CoreModule::label());
|
||||
}
|
||||
|
||||
public function testPermissionsAreWellFormed(): void
|
||||
{
|
||||
foreach (CoreModule::permissions() as $permission) {
|
||||
self::assertArrayHasKey('code', $permission);
|
||||
self::assertArrayHasKey('label', $permission);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer le test, vérifier l'échec**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
Expected: FAIL (classe `CoreModule` inexistante).
|
||||
|
||||
- [ ] **Step 3: Créer `CoreModule`**
|
||||
|
||||
`src/Module/Core/CoreModule.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
final class CoreModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'core';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Core';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
|
||||
*
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
|
||||
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
|
||||
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Confirmer la signature EXACTE de `ModuleInterface` (`src/Shared/Domain/Module/ModuleInterface.php`) avant d'écrire : la cartographie indique `id()`, `label()`, `isRequired()`, `permissions()` statiques. Si une méthode diffère (ex. non statique), aligner `CoreModule` ET le test dessus.
|
||||
|
||||
- [ ] **Step 4: Lancer le test, vérifier le vert**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Enrichir le contrat `UserInterface`**
|
||||
|
||||
Remplace `src/Shared/Domain/Contract/UserInterface.php` par :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
/**
|
||||
* Contrat de LECTURE de l'identité, consommé hors du module Core.
|
||||
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
|
||||
*/
|
||||
interface UserInterface
|
||||
{
|
||||
public function getId(): ?int;
|
||||
|
||||
public function getUserIdentifier(): string;
|
||||
|
||||
public function getUsername(): string;
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array;
|
||||
|
||||
public function getFirstName(): ?string;
|
||||
|
||||
public function getLastName(): ?string;
|
||||
|
||||
public function getAvatarUrl(): ?string;
|
||||
|
||||
public function isEmployee(): bool;
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité `User` (la cartographie confirme `getId`, `getUserIdentifier`, `getUsername`, `getRoles`, `getFirstName`, `getLastName`, `getAvatarUrl`, `isEmployee`). Si une signature diffère (ex. `getAvatarUrl(): string` non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de `User`.
|
||||
|
||||
- [ ] **Step 6: Créer `UserRepositoryInterface`**
|
||||
|
||||
`src/Module/Core/Domain/Repository/UserRepositoryInterface.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Repository;
|
||||
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
|
||||
interface UserRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return list<UserInterface>
|
||||
*/
|
||||
public function findByRole(string $role): array;
|
||||
|
||||
/**
|
||||
* @return list<UserInterface>
|
||||
*/
|
||||
public function findActiveEmployees(\DateTimeInterface $date): array;
|
||||
|
||||
public function findOneByUsername(string $username): ?UserInterface;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Créer `NotifierInterface`**
|
||||
|
||||
`src/Shared/Domain/Contract/NotifierInterface.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Shared\Domain\Contract;
|
||||
|
||||
interface NotifierInterface
|
||||
{
|
||||
public function notify(UserInterface $user, string $type, string $title, string $message): void;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Suite complète + commit**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité `User` implémente déjà ces méthodes ; `resolve_target_entities` pointe encore `App\Entity\User`).
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
|
||||
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Déplacer `User` + Auth dans Core (re-pointage, zéro migration)
|
||||
|
||||
### Task 2: Déplacer la classe `User` vers Core + mapping Doctrine + provider sécurité
|
||||
|
||||
**Files:**
|
||||
- Move: `src/Entity/User.php` → `src/Module/Core/Domain/Entity/User.php` (namespace `App\Module\Core\Domain\Entity`)
|
||||
- Modify: `config/packages/doctrine.yaml` (mapping `Core` + `resolve_target_entities`)
|
||||
- Modify: `config/packages/security.yaml` (`app_user_provider.entity.class`)
|
||||
- Modify: `config/packages/api_platform.yaml` (mapping paths : ajouter le dir entité Core)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : entité `App\Module\Core\Domain\Entity\User` (table `user` inchangée), résolue par `resolve_target_entities`.
|
||||
|
||||
- [ ] **Step 1: Déplacer le fichier (git mv) et changer le namespace**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime
|
||||
mkdir -p src/Module/Core/Domain/Entity
|
||||
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
|
||||
```
|
||||
Puis éditer `src/Module/Core/Domain/Entity/User.php` :
|
||||
- `namespace App\Entity;` → `namespace App\Module\Core\Domain\Entity;`
|
||||
- Adapter les `use` internes devenus nécessaires (l'entité référençait `UserRepository`, `MeProvider`, `UserPasswordHasherProcessor`, l'enum `ContractType`, le contrat `UserInterface as SharedUserInterface`). Mettre les `use` complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers `App\Repository\UserRepository`, `App\State\MeProvider`, `App\State\UserPasswordHasherProcessor`, `App\Entity\Enum\ContractType` ou l'emplacement réel — vérifier les `use` d'origine et les conserver tels quels tant que ces classes n'ont pas bougé).
|
||||
- Garder VERBATIM : tous les attributs `#[ORM\...]` (dont `#[ORM\Table(name: '`user`')]`), `#[ApiResource(...)]`, `#[ApiProperty(...)]`, toutes les propriétés/méthodes, `implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface`.
|
||||
|
||||
> ⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les `use`. Ne changer QUE le `namespace` et, si besoin, garder les `use` pointant vers les emplacements actuels des classes non encore déplacées.
|
||||
|
||||
- [ ] **Step 2: Mapping Doctrine + resolve_target_entities**
|
||||
|
||||
Dans `config/packages/doctrine.yaml`, sous `orm:` :
|
||||
- `resolve_target_entities` :
|
||||
```yaml
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
```
|
||||
- Ajouter un mapping pour les entités Core (en plus du mapping `App` existant qui scanne `src/Entity`) :
|
||||
```yaml
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
```
|
||||
|
||||
> Le mapping `App` (src/Entity) ne contient plus `User.php` (déplacé) → cohérent. Aucune entité orpheline.
|
||||
|
||||
- [ ] **Step 3: Provider de sécurité**
|
||||
|
||||
Dans `config/packages/security.yaml` :
|
||||
```yaml
|
||||
providers:
|
||||
app_user_provider:
|
||||
entity:
|
||||
class: App\Module\Core\Domain\Entity\User
|
||||
property: username
|
||||
```
|
||||
|
||||
- [ ] **Step 4: API Platform mapping paths**
|
||||
|
||||
Dans `config/packages/api_platform.yaml`, ajouter au `mapping.paths` le dossier entité Core (l'`#[ApiResource]` est porté par l'entité `User` déplacée) :
|
||||
```yaml
|
||||
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
```
|
||||
> Conserver tous les paths existants. Si `api_platform.yaml` n'a pas de `mapping.paths` explicite (auto-discovery), vérifier que les Resources sous `src/Module/...` sont bien découvertes (comme `src/Shared/...` l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
|
||||
|
||||
- [ ] **Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire**
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
|
||||
```
|
||||
Expected : schema VALID (mapping ok, sync DB ok). Le `diff` doit annoncer **« No changes detected »** (table/colonnes identiques). **Si une migration est générée, la SUPPRIMER** (`git status` → retirer le fichier sous `migrations/`) : un diff non vide = mapping mal recopié, corriger l'entité.
|
||||
|
||||
- [ ] **Step 6: Vérif login + suite complète**
|
||||
|
||||
Exécuter le bloc « Vérification login » (curl) → `login http=204`, `me http=200`, MCP 200.
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (118). Les consommateurs importent encore `App\Entity\User` → **ERREUR attendue** : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). **Voir note ci-dessous.**
|
||||
|
||||
> 🔴 **Note d'ordonnancement** : déplacer `User` casse les 26 `use App\Entity\User;`. Pour garder l'app bootable entre Task 2 et Task 3, **ajouter un alias de compatibilité TEMPORAIRE** au tout début de Task 2 et le retirer en fin de Task 3 :
|
||||
> Créer `src/Module/Core/_compat_user_alias.php` (chargé via `composer.json` `autoload.files`) :
|
||||
> ```php
|
||||
> <?php
|
||||
> declare(strict_types=1);
|
||||
> if (!class_exists(\App\Entity\User::class, false)) {
|
||||
> class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class);
|
||||
> }
|
||||
> ```
|
||||
> Ajouter `"files": ["src/Module/Core/_compat_user_alias.php"]` sous `autoload` dans `composer.json`, puis `composer dump-autoload`. Cela garde les 26 consommateurs fonctionnels (et Doctrine `targetEntity: User::class` résolu via l'alias) le temps de la Task 3. **L'alias est SUPPRIMÉ en Task 3 Step final** (avec le retrait du fichier, l'entrée composer et un nouveau `dump-autoload`) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
|
||||
|
||||
- [ ] **Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
|
||||
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
|
||||
|
||||
### Task 3: Relations d'entités → `UserInterface::class`
|
||||
|
||||
**Files (8 entités):**
|
||||
- Modify: `src/Entity/Task.php` (assignee ManyToOne, collaborators ManyToMany)
|
||||
- Modify: `src/Entity/TimeEntry.php` (user)
|
||||
- Modify: `src/Entity/AbsenceRequest.php` (user)
|
||||
- Modify: `src/Entity/AbsenceBalance.php` (user)
|
||||
- Modify: `src/Entity/TaskDocument.php` (user)
|
||||
- Modify: `src/Entity/TaskMailLink.php` (user)
|
||||
- Modify: `src/Module/Core/Domain/Entity/Notification.php` (user) — **après son déplacement en Phase D** ; en Phase C, `Notification` est encore `src/Entity/Notification.php`, la traiter ici aussi.
|
||||
|
||||
> Pour CHAQUE relation vers User : remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;`, le `targetEntity: User::class` par `targetEntity: UserInterface::class`, et le type de propriété/param `?User` → `?UserInterface` (idem getters/setters). Doctrine résout via `resolve_target_entities`. La colonne FK et son nom restent identiques → **aucune migration**.
|
||||
|
||||
- [ ] **Step 1: Modifier les relations (entité par entité)**
|
||||
|
||||
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple `Task.php` (assignee) :
|
||||
```php
|
||||
// avant
|
||||
use App\Entity\User;
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private ?User $assignee = null;
|
||||
public function getAssignee(): ?User { return $this->assignee; }
|
||||
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
|
||||
// collaborators
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
private Collection $collaborators;
|
||||
|
||||
// après
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
private ?UserInterface $assignee = null;
|
||||
public function getAssignee(): ?UserInterface { return $this->assignee; }
|
||||
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
|
||||
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
|
||||
private Collection $collaborators;
|
||||
```
|
||||
> ⚠️ Conserver tous les autres attributs de relation (`inversedBy`, `joinTable`, `joinColumn`, `nullable`, `onDelete`, Groups…) VERBATIM. Ne changer que le type et `targetEntity`.
|
||||
|
||||
- [ ] **Step 2: Valider le schéma (toujours zéro migration)**
|
||||
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
|
||||
```
|
||||
Expected : « No changes detected ». Sinon corriger (un `joinColumn`/`onDelete` a été perdu).
|
||||
|
||||
### Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
|
||||
|
||||
**Files:** les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
|
||||
- Move: `src/Repository/UserRepository.php` → `src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` (implémente `UserRepositoryInterface`, namespace `App\Module\Core\Infrastructure\Doctrine`)
|
||||
- Move: `src/State/MeProvider.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php`
|
||||
- Move: `src/State/UserPasswordHasherProcessor.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php`
|
||||
- Modify: l'`#[ApiResource]` de l'entité `User` (les `provider:`/`processor:` pointent vers les nouveaux FQCN Core).
|
||||
- Delete (en fin de task): `src/Module/Core/_compat_user_alias.php` + entrée `composer.json`.
|
||||
|
||||
- [ ] **Step 1: Déplacer le repository et l'aligner sur l'interface**
|
||||
|
||||
```bash
|
||||
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
|
||||
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
|
||||
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
|
||||
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
|
||||
```
|
||||
Éditer `DoctrineUserRepository.php` : `namespace App\Module\Core\Infrastructure\Doctrine;`, `class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface`, `use App\Module\Core\Domain\Entity\User;`, `use App\Module\Core\Domain\Repository\UserRepositoryInterface;`, et passer `User::class` au constructeur parent. Ajouter `findOneByUsername()` si absent (`return $this->findOneBy(['username' => $username]);`). Conserver `findByRole()` (SQL natif `roles::text LIKE`) et `findActiveEmployees()`.
|
||||
Éditer `User.php` : `#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]` avec le bon `use`.
|
||||
Éditer `MeProvider.php` / `UserPasswordHasherProcessor.php` : nouveaux namespaces ; `use App\Module\Core\Domain\Entity\User;` (le processor manipule le concret — c'est dans Core, autorisé).
|
||||
Mettre à jour les `provider:`/`processor:` dans l'`#[ApiResource]` de `User` vers les nouveaux FQCN.
|
||||
|
||||
- [ ] **Step 2: Lier l'interface repository au service Doctrine**
|
||||
|
||||
Dans `config/services.yaml`, alias pour l'injection par interface :
|
||||
```yaml
|
||||
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Basculer les 25 autres consommateurs sur le contrat**
|
||||
|
||||
Pour chaque fichier important `App\Entity\User` (hors Core), remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;` et le type-hint `User` par `UserInterface` (params, retours, propriétés, `@var`, expressions). Cas particuliers :
|
||||
- `src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php` : les signatures `countUnreadByUser(User $user)` etc. → `UserInterface`. Ne pas changer la logique DQL (`n.user = :user` fonctionne avec l'instance).
|
||||
- `src/State/Absence*`, `TaskDocumentProvider`, `src/Service/AbsenceBalanceService`, `src/Security/MailAccessChecker`, `src/EventListener/TaskNotificationListener` (sera retravaillé en Phase D mais peut déjà passer au contrat ici), `src/Controller/*` (7), `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool`, `src/Mcp/Tool/Serializer` : remplacer le type-hint.
|
||||
- `src/DataFixtures/AppFixtures.php` : **garde le concret** `App\Module\Core\Domain\Entity\User` (les fixtures INSTANCIENT `new User()` et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors `src/Module/Core/` mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
|
||||
|
||||
> Liste de contrôle : après cette step, `grep -rn "use App\\\\Entity\\\\User;" src/` ne doit retourner QUE `src/DataFixtures/AppFixtures.php` (qui importe désormais le FQCN Core, donc 0 occurrence de `App\Entity\User`). Viser **0 occurrence de `App\Entity\User`** dans tout `src/`.
|
||||
|
||||
- [ ] **Step 4: Retirer l'alias de compatibilité**
|
||||
|
||||
```bash
|
||||
git rm src/Module/Core/_compat_user_alias.php
|
||||
```
|
||||
Retirer l'entrée `"files": [...]` ajoutée sous `autoload` dans `composer.json` (Task 2), puis :
|
||||
```bash
|
||||
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
|
||||
```
|
||||
|
||||
- [ ] **Step 5: `grep` de garde (AC 2) + schéma + tests + login**
|
||||
|
||||
```bash
|
||||
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
|
||||
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
|
||||
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
|
||||
```
|
||||
Expected : grep VIDE, schéma valide, « No changes detected », **118 tests verts**. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
|
||||
|
||||
- [ ] **Step 6: php-cs-fixer + commit (Phase C)**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add -A -- src config composer.json composer.lock
|
||||
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
|
||||
```
|
||||
> ⚠️ NE PAS `git add config/reference.php`. Vérifier `git status` avant le commit ; si `reference.php` est listé, l'exclure du `git add` (stager explicitement les fichiers voulus).
|
||||
|
||||
---
|
||||
|
||||
## Phase D — Notifications via `NotifierInterface` (impl Core)
|
||||
|
||||
### Task 5: Déplacer `Notification` dans Core + `Notifier` (impl) + recâbler le listener
|
||||
|
||||
**Files:**
|
||||
- Move: `src/Entity/Notification.php` → `src/Module/Core/Domain/Entity/Notification.php`
|
||||
- Move: `src/Repository/NotificationRepository.php` → `src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php`
|
||||
- Move: `src/State/NotificationProvider.php` → `src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php`
|
||||
- Create: `src/Module/Core/Infrastructure/Notifier.php` (implements `NotifierInterface`)
|
||||
- Modify: `src/EventListener/TaskNotificationListener.php` (dépend de `NotifierInterface`)
|
||||
- Modify: `config/packages/doctrine.yaml` (le mapping `Core` couvre déjà `Domain/Entity` → Notification incluse automatiquement)
|
||||
- Modify: `tests/` — ajouter `tests/Unit/Module/Core/NotifierTest.php` (ou Functional) si testable unitairement.
|
||||
|
||||
- [ ] **Step 1: Écrire un test du `Notifier`**
|
||||
|
||||
`tests/Functional/Module/Core/NotifierTest.php` (crée une notif et vérifie la persistance) :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Functional\Module\Core;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class NotifierTest extends KernelTestCase
|
||||
{
|
||||
public function testNotifyPersistsANotificationForTheUser(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$notifier = self::getContainer()->get(NotifierInterface::class);
|
||||
|
||||
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
|
||||
self::assertNotNull($user);
|
||||
|
||||
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
|
||||
|
||||
$count = (int) $em->createQuery(
|
||||
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
|
||||
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
|
||||
|
||||
self::assertSame(1, $count);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lancer, vérifier l'échec** — `NotifierInterface` non instanciable / `Notification` introuvable au nouveau namespace.
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Déplacer `Notification` + repository + provider**
|
||||
|
||||
```bash
|
||||
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
|
||||
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
|
||||
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
|
||||
```
|
||||
- `Notification.php` : `namespace App\Module\Core\Domain\Entity;`, `use App\Shared\Domain\Contract\UserInterface;`, relation `user` → `targetEntity: UserInterface::class` + type `?UserInterface`, `repositoryClass: DoctrineNotificationRepository::class`, conserver `#[ORM\Table(name:'notification')]` + index VERBATIM, ApiResource (provider → nouveau FQCN). **Table/colonnes inchangées.**
|
||||
- `DoctrineNotificationRepository.php` : namespace Core, `use App\Module\Core\Domain\Entity\Notification;`, signatures `UserInterface`.
|
||||
- `NotificationProvider.php` : namespace Core, mêmes dépendances.
|
||||
|
||||
- [ ] **Step 4: Implémenter `Notifier`**
|
||||
|
||||
`src/Module/Core/Infrastructure/Notifier.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure;
|
||||
|
||||
use App\Module\Core\Domain\Entity\Notification;
|
||||
use App\Shared\Domain\Contract\NotifierInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class Notifier implements NotifierInterface
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function notify(UserInterface $user, string $type, string $title, string $message): void
|
||||
{
|
||||
$notification = new Notification();
|
||||
$notification->setUser($user);
|
||||
$notification->setType($type);
|
||||
$notification->setTitle($title);
|
||||
$notification->setMessage($message);
|
||||
$this->em->persist($notification);
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
```
|
||||
> ⚠️ Aligner sur les setters réels de `Notification` (la cartographie indique `user`, `type`, `title`, `message`, `isRead` default false, `createdAt`). Si `createdAt` n'est pas auto (prePersist), le poser ici. Si `setUser` attend le concret, accepter `UserInterface` (resolve_target_entities) — vérifier le type du setter.
|
||||
|
||||
- [ ] **Step 5: Recâbler `TaskNotificationListener` sur `NotifierInterface`**
|
||||
|
||||
Lire le listener ; remplacer la création directe de `Notification` (`new Notification()` + persist) par l'injection et l'appel de `NotifierInterface::notify(...)`. **Attention** : le listener tourne sur `onFlush`/`postFlush` — un `flush()` dans `notify()` pendant un `onFlush` est dangereux. Conserver le pattern existant (accumulation en `onFlush`, écriture en `postFlush`). Si `notify()` flush, l'appeler UNIQUEMENT en `postFlush` (jamais pendant `onFlush`). Préserver le comportement exact (mêmes types `task_assigned`/`task_collaborator_added`, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
|
||||
|
||||
> Si l'intrication onFlush/postFlush rend `NotifierInterface` inadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » : `NotifierInterface` doit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
|
||||
|
||||
- [ ] **Step 6: Tests + login + endpoints notifications**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS (118 + 1 = 119). Vérifier `doctrine:migrations:diff` → « No changes detected ». Bloc login. Puis curl notifications :
|
||||
```bash
|
||||
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
|
||||
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
|
||||
```
|
||||
Expected : 200 sur les deux.
|
||||
|
||||
- [ ] **Step 7: php-cs-fixer + commit**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
```bash
|
||||
git add -A -- src config tests
|
||||
git commit -m "feat(core) : move notification into core and expose notifier contract"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase E — Déclarer `CoreModule` actif
|
||||
|
||||
### Task 6: Enregistrer Core dans `config/modules.php`
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/modules.php`
|
||||
- Modify: `tests/Functional/Shared/ModulesEndpointTest.php` (ou équivalent — adapter l'assertion à la présence de `core`)
|
||||
|
||||
- [ ] **Step 1: Adapter/écrire le test de l'endpoint modules**
|
||||
|
||||
Vérifier le test existant de `/api/modules` (cartographie : `ModulesProvider`/`ModulesResource` créés en #56). Ajouter une assertion :
|
||||
```php
|
||||
public function testCoreModuleIsActive(): void
|
||||
{
|
||||
$client = self::createClient();
|
||||
// /api/modules est public (GET) d'après security.yaml
|
||||
$client->request('GET', '/api/modules');
|
||||
self::assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertContains('core', $data['modules']);
|
||||
}
|
||||
```
|
||||
> Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer `tests/Functional/Shared/ModulesEndpointTest.php`.
|
||||
|
||||
- [ ] **Step 2: Lancer, vérifier l'échec** (modules.php retourne `[]`).
|
||||
|
||||
- [ ] **Step 3: Activer Core**
|
||||
|
||||
`config/modules.php` :
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Module\Core\CoreModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Tests + curl**
|
||||
|
||||
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
|
||||
Expected: PASS. Curl :
|
||||
```bash
|
||||
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: commit**
|
||||
|
||||
```bash
|
||||
git add config/modules.php tests/
|
||||
git commit -m "feat(core) : activate core module in modules registry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase F — Layer front `modules/core/`
|
||||
|
||||
### Task 7: Déplacer login / profile / admin users dans `frontend/modules/core/`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`)
|
||||
- Move: `frontend/pages/login.vue` → `frontend/modules/core/pages/login.vue`
|
||||
- Move: `frontend/pages/profile.vue` → `frontend/modules/core/pages/profile.vue`
|
||||
- Move: `frontend/pages/admin/**` (gestion users) → `frontend/modules/core/pages/admin/**`
|
||||
- Move (si pertinent): composants/services liés à l'identité (ex. `frontend/components/user/**`, `frontend/components/admin/**`, `frontend/services/user.ts`) → `frontend/modules/core/{components,services}/**`
|
||||
|
||||
> ⚠️ AVANT de déplacer, LIRE `frontend/pages/` et `frontend/components/` pour identifier précisément les pages/compos d'identité. Le scan `readdirSync('modules/')` (LST-62) ajoute `./modules/core` à `extends` et `modules/core/composables`/`stores` à `imports.dirs`. Les `pages/` d'un layer Nuxt sont fusionnées automatiquement → **les URLs (`/login`, `/profile`, `/admin/...`) restent identiques**. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
|
||||
|
||||
- [ ] **Step 1: Créer le layer + déplacer les pages d'identité**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Lesstime/frontend
|
||||
mkdir -p modules/core/pages
|
||||
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
|
||||
git mv pages/login.vue modules/core/pages/login.vue
|
||||
git mv pages/profile.vue modules/core/pages/profile.vue
|
||||
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
|
||||
```
|
||||
> Lister `frontend/pages/admin/` d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
|
||||
|
||||
- [ ] **Step 2: Corriger les imports par chemin éventuels**
|
||||
|
||||
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules`
|
||||
Corriger toute référence cassée (les redirections `navigateTo('/login')` restent valides — c'est une URL, pas un chemin de fichier).
|
||||
|
||||
- [ ] **Step 3: Gate front (cf. LST-62) + smoke**
|
||||
|
||||
Run: `cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile"` → doit être VIDE.
|
||||
Run: `grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null` ou démarrer `make dev-nuxt` et confirmer que `/login`, `/profile` répondent (la fusion des pages du layer est effective).
|
||||
> Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
|
||||
|
||||
- [ ] **Step 4: commit**
|
||||
|
||||
```bash
|
||||
git add -A -- frontend
|
||||
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance check (après toutes les phases)
|
||||
|
||||
- [ ] **AC1** Login/JWT OK via le module : `login http=204`, `/api/me` 200, MCP apiToken 200, `/api/notifications` 200.
|
||||
- [ ] **AC2** `grep -rn "App\\Entity\\User" src/ config/` → **VIDE** (User vit dans `src/Module/Core/Domain/Entity/`, consommé via contrat ; fixtures importent le FQCN Core).
|
||||
- [ ] **AC3** `make test` vert (≈119 tests), `doctrine:schema:validate` OK, `doctrine:migrations:diff` = « No changes detected » (**aucune migration destructive ni même additive**).
|
||||
- [ ] `/api/modules` renvoie `core` ; `CoreModule::isRequired() === true`.
|
||||
- [ ] `resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User`.
|
||||
- [ ] Front : layer `modules/core/` détecté ; `/login`, `/profile` (+ admin users) accessibles aux mêmes URLs ; aucun `Cannot find module`.
|
||||
- [ ] `config/reference.php` jamais committé.
|
||||
|
||||
## Notes pour le ticket suivant (1.2 — RBAC fin)
|
||||
|
||||
`CoreModule::permissions()` est déjà posé (stub). 1.2 ajoutera `Role`/`Permission`, `app:sync-permissions`, `PermissionVoter`, et fera filtrer `SidebarProvider` **par permission** (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat `UserInterface` enrichi est prêt à recevoir `getPermissions()` si besoin.
|
||||
@@ -1,66 +0,0 @@
|
||||
# LST-58 (2.4) — Module Directory : Prospect + front répertoire (plan)
|
||||
|
||||
> Suite de la migration Directory. Client (back) déjà livré (`c5738d2`).
|
||||
> Reste : **entité Prospect** (nouvelle) + **front répertoire** (Clients + Prospects).
|
||||
> Spec produit non fournie → design défini ici de façon raisonnable, à valider au test.
|
||||
> Additif, sans régression. Branche `integration/modular-monolith-0.1-1.3`.
|
||||
|
||||
## Design Prospect (décidé, à valider)
|
||||
Aligné sur `Client` (même module Directory), enrichi des concepts de prospection commerciale.
|
||||
|
||||
**Entité `App\Module\Directory\Domain\Entity\Prospect`** (table `prospect`) :
|
||||
- `id` int PK
|
||||
- `name` string(255) NOT NULL — contact ou société
|
||||
- `company` string(255) nullable
|
||||
- `email` string(255) nullable
|
||||
- `phone` string(50) nullable
|
||||
- `street` string(255) nullable / `city` string(255) nullable / `postalCode` string(20) nullable (alignés Client)
|
||||
- `status` enum `ProspectStatus` NOT NULL (default `New`)
|
||||
- `source` string(255) nullable — origine (recommandation, salon, site web…)
|
||||
- `notes` text nullable
|
||||
- `convertedClient` ManyToOne `ClientInterface` nullable, JoinColumn ON DELETE SET NULL — rempli à la conversion
|
||||
- Timestampable/Blamable (trait) + `#[Auditable]`
|
||||
- Groupes : `prospect:read` / `prospect:write`
|
||||
|
||||
**Enum `App\Module\Directory\Domain\Enum\ProspectStatus`** : `New` (nouveau), `Contacted` (contacté), `Qualified` (qualifié), `Won` (gagné/converti), `Lost` (perdu). Méthode `label(): string` (FR), comme les autres enums.
|
||||
|
||||
**API Platform** (aligné Client) :
|
||||
- `GetCollection` paginationEnabled:false, `is_granted('ROLE_USER')`
|
||||
- `Get` ROLE_USER ; `Post`/`Patch`/`Delete` ROLE_ADMIN
|
||||
- Opération custom **`Post /prospects/{id}/convert`** (processor `ConvertProspectProcessor`) : crée un `Client` à partir du Prospect (name/company→name, email, phone, adresse), lie `convertedClient`, passe `status=Won`. Sécurité ROLE_ADMIN. Renvoie le Prospect mis à jour. Idempotent si déjà converti (renvoie l'existant).
|
||||
- `#[ApiFilter]` SearchFilter sur `status` (filtre répertoire).
|
||||
|
||||
**Repo** : `ProspectRepositoryInterface` (Domain) + `DoctrineProspectRepository` (Infra) + binding.
|
||||
|
||||
**MCP** (cohérent avec clients, sous `Infrastructure/Mcp/Tool/`) : `list-prospects`, `get-prospect`, `create-prospect`, `update-prospect`, `delete-prospect`, `convert-prospect`. Serializer : ajouter `prospect()` dans `src/Mcp/Tool/Serializer.php`.
|
||||
|
||||
**DirectoryModule.permissions()** : ajouter `directory.prospects.view`, `directory.prospects.manage` (additif).
|
||||
|
||||
**Migration additive** : CREATE TABLE prospect (colonnes + FK converted_client→client ON DELETE SET NULL + created_by/updated_by FK user + index + COMMENT). Down = DROP TABLE.
|
||||
|
||||
**Fixtures** : 2-3 prospects de démo (statuts variés), dont un converti.
|
||||
|
||||
## Front répertoire (`frontend/modules/directory/`)
|
||||
Aujourd'hui : pas de page client dédiée (AdminClientTab + picker ProjectDrawer). On crée un vrai répertoire.
|
||||
- `nuxt.config.ts` vide.
|
||||
- `services/` : `clients.ts` (move depuis racine), `prospects.ts` (nouveau) + `dto/{client,prospect}.ts`.
|
||||
- `pages/directory.vue` : page à 2 onglets (Clients / Prospects), tableaux paginés côté client (paginationEnabled:false back), recherche/filtre statut pour prospects.
|
||||
- `components/` : `ClientDrawer.vue` (move depuis `components/client/`), `ProspectDrawer.vue` (nouveau, create/edit + bouton « Convertir en client »).
|
||||
- Sidebar : ajouter item `sidebar.general.directory` → `/directory`, `'module' => 'directory'`, gate ROLE_ADMIN (gestion référentiel).
|
||||
- Réécrire imports consommateurs de `~/services/clients` / `~/services/dto/client` (AdminClientTab, ProjectDrawer, pages projects) → `~/modules/directory/services/...`. AdminClientTab : soit le retirer de /admin au profit de /directory, soit le laisser pointer le nouveau service. Décision : garder AdminClientTab fonctionnel (repoint service) ET ajouter la page /directory (les deux coexistent ; /directory = vue dédiée).
|
||||
- i18n global : ajouter clés `directory.*`, `prospects.*`, `sidebar.general.directory`.
|
||||
|
||||
## Vagues d'exécution
|
||||
1. **Back Prospect** : enum + entité + repo + API (CRUD + convert) + MCP (6 tools) + Serializer + permissions module + fixtures + migration. Vérif cache:clear/migrate/phpunit/cs-fixer → commit.
|
||||
2. **Front Directory** : layer (move client front + page répertoire + ProspectDrawer + prospects service/dto) + sidebar + imports + i18n. Vérif nuxt build → commit.
|
||||
|
||||
## Critères d'acceptation (ticket #58)
|
||||
- [x] Clients en module (fait, c5738d2)
|
||||
- [ ] Prospects en module + front répertoire fonctionnel
|
||||
- [x] resolve_target_entities → Directory\Client
|
||||
- [ ] make test vert, aucune migration destructive
|
||||
- [ ] toggle module directory (sidebar + route /directory)
|
||||
|
||||
## Suite phase 2 (après 2.4)
|
||||
- 2.5 (#67) Module Mail — WIP `docs/mail-integration.md`, à traiter avec précaution.
|
||||
- 2.6 (#68) Module Integration (Gitea/BookStack/Zimbra/Share).
|
||||
@@ -1,82 +0,0 @@
|
||||
# LST-65 (2.2) — Module ProjectManagement : plan de migration
|
||||
|
||||
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
|
||||
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
|
||||
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
|
||||
|
||||
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
|
||||
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
|
||||
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
|
||||
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
|
||||
|
||||
## Périmètre (10 entités + écosystème)
|
||||
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
|
||||
Enums : StatusCategory, RecurrenceType.
|
||||
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
|
||||
|
||||
## Décisions d'architecture (figées)
|
||||
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
|
||||
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
|
||||
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
|
||||
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
|
||||
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
|
||||
- PAS de WorkflowInterface (Workflow est intra-module PM).
|
||||
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
|
||||
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X` → `App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
|
||||
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
|
||||
5. **Services CalDavService + RecurrenceCalculator** → `Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
|
||||
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
|
||||
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
|
||||
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
|
||||
9. **resolve_target_entities** final :
|
||||
```
|
||||
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
|
||||
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
|
||||
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
|
||||
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
|
||||
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
|
||||
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
|
||||
|
||||
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
|
||||
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
|
||||
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
|
||||
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
|
||||
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
|
||||
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
|
||||
|
||||
## Tranche 2 — Move mécanique vers le module
|
||||
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
|
||||
|
||||
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
|
||||
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
|
||||
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
|
||||
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
|
||||
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
|
||||
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
|
||||
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
|
||||
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
|
||||
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
|
||||
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
|
||||
|
||||
## Tranche 3 — Timestampable additif (Task + Project)
|
||||
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
|
||||
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
|
||||
3. Champs hors groupes API existants (le trait porte ses propres groupes).
|
||||
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
|
||||
|
||||
## Tranche 4 — Front layer project-management
|
||||
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
|
||||
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
|
||||
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
|
||||
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
|
||||
|
||||
## Critères d'acceptation (ticket)
|
||||
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
|
||||
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
|
||||
- [ ] `make test` vert, aucune migration destructive.
|
||||
- [ ] Toggle module project-management (sidebar + routes) prouvé.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
||||
# Migration sidebar vers MalioSidebar — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer la sidebar maison de Lesstime par le composant `MalioSidebar` de `@malio/layer-ui`, en 3 groupes (Général / Outils / Administration), avec timer + version dans le footer et le logo Malio de Starseed.
|
||||
|
||||
**Architecture:** Modèle backend-driven conservé — `config/sidebar.php` filtré par `SidebarProvider` (permissions/rôles/modules côté serveur), exposé via `/api/sidebar`, consommé par `useSidebar()`. Le layout `default.vue` mappe ces sections vers le format `MalioSidebar` et fusionne les items contextuels rendus côté client (Kanban/Groupes/Archives, Documents, Mail+badge, Mes absences).
|
||||
|
||||
**Tech Stack:** Nuxt 4 (SPA), Vue 3 `<script setup>` TS, Pinia, `@malio/layer-ui` ^1.7.16, i18n (@nuxtjs/i18n), Symfony 8 / API Platform 4 (backend config PHP).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Ne jamais modifier `@malio/layer-ui`** (lib externe). Source de référence en lecture seule : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
||||
- `MalioSidebar` : props `sections` (requis), `modelValue` (v-model collapse bool), `sidebarClass`, `toggleClass`. Item = `{ label: string; to: string; exact?: boolean }` (pas d'icône ni de badge par item). Section = `{ label?: string; icon?: string; items: SidebarItem[] }`. Slots : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
||||
- **TypeScript strict** ; indentation **4 espaces** (frontend).
|
||||
- Backend : `declare(strict_types=1)` en tête des fichiers PHP.
|
||||
- Commits format projet : `type(scope) : message` (espaces autour du `:`), types autorisés minuscules (`feat`, `fix`, `refactor`, `chore`, …). **Ne committer que sur demande de l'utilisateur** (règle CLAUDE.md). Travailler sur une branche dédiée (pas directement sur `develop`).
|
||||
- **Pas de runner de test frontend** dans ce projet → vérification par `npm run build` (Nuxt, échoue sur erreur TS/template) + **QA manuelle navigateur** (`make dev-nuxt`, port 3002). Ne PAS introduire de framework de test (hors scope).
|
||||
- Décisions validées : 3 groupes ; badge mail = **suffixe `(N)`** sur le label.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `config/sidebar.php` — **Modify** : re-catégorisation en 3 sections.
|
||||
- `frontend/i18n/locales/fr.json` — **Modify** : clés de sections/items.
|
||||
- `frontend/i18n/locales/*.json` (autres langues présentes) — **Modify si existantes** : mêmes clés.
|
||||
- `frontend/public/LOGO_MALIO.png` — **Create** (copie Starseed).
|
||||
- `frontend/public/LOGO_MALIO_COLLAPSED.png` — **Create** (copie Starseed).
|
||||
- `frontend/app/layouts/default.vue` — **Modify** : réécriture du template sidebar + logique `mergedSections`.
|
||||
- `frontend/components/ui/SidebarLink.vue` — **Possible delete** (si plus aucun usage après migration).
|
||||
|
||||
---
|
||||
|
||||
## Task 0 : Branche de travail
|
||||
|
||||
**Files:** aucun (git).
|
||||
|
||||
- [ ] **Step 1 : Créer la branche depuis `develop`**
|
||||
|
||||
```bash
|
||||
cd /home/m-tristan/workspace/Lesstime
|
||||
git checkout develop && git pull --ff-only
|
||||
git checkout -b feat/malio-sidebar
|
||||
```
|
||||
|
||||
Expected : sur la branche `feat/malio-sidebar`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Backend — re-catégorisation `config/sidebar.php` + i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `config/sidebar.php`
|
||||
- Modify: `frontend/i18n/locales/fr.json`
|
||||
- Modify: autres `frontend/i18n/locales/*.json` si présentes (mêmes clés)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : `/api/sidebar` renvoie des sections dont les `label` sont les clés `sidebar.general.section`, `sidebar.tools.section`, `sidebar.admin.section`. Items inchangés en `to` ; gates (`module`/`roles`/`permission`) inchangés, juste réorganisés.
|
||||
|
||||
- [ ] **Step 1 : Réécrire `config/sidebar.php` en 3 sections**
|
||||
|
||||
Remplacer le `return [...]` (lignes 20-44) par :
|
||||
|
||||
```php
|
||||
return [
|
||||
[
|
||||
'label' => 'sidebar.general.section',
|
||||
'icon' => 'mdi:view-dashboard-outline',
|
||||
'items' => [
|
||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.tools.section',
|
||||
'icon' => 'mdi:tools',
|
||||
'items' => [
|
||||
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
||||
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'sidebar.admin.section',
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
> Mettre aussi à jour le commentaire d'en-tête si nécessaire (le bloc décrivant Mail/contextuels reste valable).
|
||||
|
||||
- [ ] **Step 2 : Mettre à jour les clés i18n FR**
|
||||
|
||||
Dans `frontend/i18n/locales/fr.json`, bloc `sidebar` :
|
||||
- `sidebar.general.section` : remplacer la valeur par `"Général"`.
|
||||
- Ajouter `sidebar.tools.section` : `"Outils"`.
|
||||
- Conserver `sidebar.general.dashboard|myTasks|projects|timeTracking|mail` et `sidebar.admin.*`.
|
||||
- Ajouter les clés pour items client (utilisées en Task 3) :
|
||||
- `sidebar.general.myAbsences` : `"Mes absences"`
|
||||
- `sidebar.project.kanban` : `"Kanban"`
|
||||
- `sidebar.project.groups` : `"Groupes"`
|
||||
- `sidebar.project.archives` : `"Archives"`
|
||||
|
||||
Résultat attendu du bloc (extrait) :
|
||||
|
||||
```json
|
||||
"sidebar": {
|
||||
"general": {
|
||||
"section": "Général",
|
||||
"dashboard": "Tableau de bord",
|
||||
"myTasks": "Mes tâches",
|
||||
"projects": "Projets",
|
||||
"timeTracking": "Suivi de temps",
|
||||
"mail": "Messagerie",
|
||||
"myAbsences": "Mes absences"
|
||||
},
|
||||
"tools": {
|
||||
"section": "Outils"
|
||||
},
|
||||
"project": {
|
||||
"kanban": "Kanban",
|
||||
"groups": "Groupes",
|
||||
"archives": "Archives"
|
||||
},
|
||||
"admin": {
|
||||
"section": "Administration",
|
||||
"teamAbsences": "Absences équipe",
|
||||
"directory": "Répertoire",
|
||||
"administration": "Administration",
|
||||
"reporting": "Rapports"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Répliquer les clés dans les autres locales si présentes**
|
||||
|
||||
```bash
|
||||
ls /home/m-tristan/workspace/Lesstime/frontend/i18n/locales/
|
||||
```
|
||||
|
||||
Pour chaque fichier autre que `fr.json`, ajouter `tools.section`, `general.myAbsences`, `project.kanban|groups|archives` et ajuster `general.section`. S'il n'existe que `fr.json`, ne rien faire de plus.
|
||||
|
||||
- [ ] **Step 4 : Vérifier `/api/sidebar` (admin)**
|
||||
|
||||
```bash
|
||||
docker exec -i php-lesstime-fpm php -r 'var_dump(require "/var/www/config/sidebar.php");' | head -5
|
||||
```
|
||||
|
||||
Expected : le fichier PHP se parse sans erreur (3 entrées de premier niveau). (Le chemin exact dans le container peut différer — sinon, vérifier via `make cache-clear` qui échouerait sur une erreur de syntaxe PHP.)
|
||||
|
||||
```bash
|
||||
make cache-clear
|
||||
```
|
||||
|
||||
Expected : succès, pas d'erreur de parse.
|
||||
|
||||
- [ ] **Step 5 : Commit (sur demande utilisateur)**
|
||||
|
||||
```bash
|
||||
git add config/sidebar.php frontend/i18n/locales/
|
||||
git commit -m "refactor(sidebar) : re-catégorisation en 3 groupes (Général / Outils / Administration)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Frontend — assets logo
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/public/LOGO_MALIO.png`
|
||||
- Create: `frontend/public/LOGO_MALIO_COLLAPSED.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces : assets statiques servis à `/LOGO_MALIO.png` et `/LOGO_MALIO_COLLAPSED.png`.
|
||||
|
||||
- [ ] **Step 1 : Copier les logos depuis Starseed**
|
||||
|
||||
```bash
|
||||
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO.png \
|
||||
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO.png
|
||||
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO_COLLAPSED.png \
|
||||
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO_COLLAPSED.png
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier**
|
||||
|
||||
```bash
|
||||
ls -la /home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO*.png
|
||||
```
|
||||
|
||||
Expected : deux fichiers présents (~5.8K et ~2.2K).
|
||||
|
||||
- [ ] **Step 3 : Commit (sur demande utilisateur)**
|
||||
|
||||
```bash
|
||||
git add frontend/public/LOGO_MALIO.png frontend/public/LOGO_MALIO_COLLAPSED.png
|
||||
git commit -m "chore(sidebar) : ajout des logos Malio (déplié / replié)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Frontend — migration du layout vers `MalioSidebar`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/layouts/default.vue`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes : `useSidebar().sections` (clés i18n des Task 1), `useUiStore().sidebarCollapsed`, `SidebarTimer` (`:collapsed`), `useAppVersion().version`, `useMailStore().globalUnreadCount`, `useShareStatus()`, `auth.user.isEmployee`, `auth.user.roles`, `useI18n().t`.
|
||||
- Produces : layout rendant `<MalioSidebar>`.
|
||||
|
||||
> Ce task est une réécriture cohérente d'un seul fichier : la sidebar doit rester fonctionnelle (toutes features préservées) à la fin du task. On ne committe pas d'état intermédiaire cassé.
|
||||
|
||||
- [ ] **Step 1 : Remplacer le bloc `<aside>…</aside>` (lignes 13-104) par `<MalioSidebar>`**
|
||||
|
||||
Nouveau template de la zone sidebar (remplace l'overlay mobile lignes 5-11 **et** l'`<aside>`) :
|
||||
|
||||
```vue
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="mergedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SidebarTimer :collapsed="false" />
|
||||
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer-collapsed>
|
||||
<SidebarTimer :collapsed="true" />
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
```
|
||||
|
||||
Le bloc `<div class="h-full flex-1 …">` (AppTopNav + `<main>` + `<slot/>`) et le `<TimeEntryDrawer>` restent **inchangés**.
|
||||
|
||||
- [ ] **Step 2 : Remplacer la logique `translatedSections` par `mergedSections` dans le `<script setup>`**
|
||||
|
||||
Supprimer le computed `translatedSections` (lignes 144-156) et le remplacer par :
|
||||
|
||||
```ts
|
||||
type MalioItem = { label: string; to: string; exact?: boolean }
|
||||
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
||||
|
||||
// Ordre d'affichage canonique des sections.
|
||||
const SECTION_ORDER = [
|
||||
'sidebar.general.section',
|
||||
'sidebar.tools.section',
|
||||
'sidebar.admin.section',
|
||||
] as const
|
||||
|
||||
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
||||
// ex. module mail off mais partage actif → section Outils à recréer).
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
||||
'sidebar.tools.section': 'mdi:tools',
|
||||
'sidebar.admin.section': 'mdi:cog-outline',
|
||||
}
|
||||
|
||||
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
||||
function clientItemsFor(key: string): MalioItem[] {
|
||||
if (key === 'sidebar.general.section') {
|
||||
const items: MalioItem[] = []
|
||||
if (currentProjectId.value) {
|
||||
const id = currentProjectId.value
|
||||
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true })
|
||||
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups` })
|
||||
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives` })
|
||||
}
|
||||
if (isEmployee.value) {
|
||||
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
||||
}
|
||||
return items
|
||||
}
|
||||
if (key === 'sidebar.tools.section') {
|
||||
const items: MalioItem[] = []
|
||||
if (isMailVisible.value) {
|
||||
const n = mailStore.globalUnreadCount
|
||||
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
||||
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
||||
}
|
||||
if (isDocumentsVisible.value) {
|
||||
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
||||
}
|
||||
return items
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const mergedSections = computed<MalioSection[]>(() => {
|
||||
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
||||
const backend = new Map<string, MalioSection>()
|
||||
for (const section of sections.value) {
|
||||
backend.set(section.label, {
|
||||
label: t(section.label),
|
||||
icon: section.icon,
|
||||
items: section.items
|
||||
.filter((item) => item.to !== '/mail')
|
||||
.map((item) => ({ label: t(item.label), to: item.to })),
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Fusion dans l'ordre canonique.
|
||||
const result: MalioSection[] = []
|
||||
for (const key of SECTION_ORDER) {
|
||||
const base = backend.get(key)
|
||||
const extra = clientItemsFor(key)
|
||||
if (base) {
|
||||
base.items.push(...extra)
|
||||
if (base.items.length > 0) {
|
||||
result.push(base)
|
||||
}
|
||||
} else if (extra.length > 0) {
|
||||
result.push({ label: t(key), icon: SECTION_ICON[key], items: extra })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
||||
for (const [key, section] of backend) {
|
||||
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
||||
result.push(section)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
> `isDocumentsVisible` existe déjà (ligne 166). `isMailVisible`, `isEmployee`, `currentProjectId`, `sections`, `mailStore`, `t`, `version`, `ui` sont déjà déclarés — ne pas les redéclarer.
|
||||
|
||||
- [ ] **Step 3 : Nettoyer le `<script>` et les imports devenus inutiles**
|
||||
|
||||
- Supprimer `sidebarIsCollapsed` (computed lignes 169-172) **si** plus utilisé après suppression de l'`<aside>` (l'était pour le rendu manuel). Vérifier qu'aucune autre référence ne subsiste :
|
||||
|
||||
```bash
|
||||
grep -n "sidebarIsCollapsed" frontend/app/layouts/default.vue
|
||||
```
|
||||
|
||||
S'il ne reste aucune occurrence hors déclaration, supprimer le computed.
|
||||
|
||||
- Conserver `watch(() => route.path, () => { ui.closeMobileSidebar() })` (fermeture mobile sur navigation).
|
||||
- Vérifier que `SidebarLink` n'est plus référencé dans ce fichier (le composant Malio le remplace) :
|
||||
|
||||
```bash
|
||||
grep -n "SidebarLink" frontend/app/layouts/default.vue
|
||||
```
|
||||
|
||||
Expected : aucune occurrence.
|
||||
|
||||
- [ ] **Step 4 : Build de vérification**
|
||||
|
||||
```bash
|
||||
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
||||
```
|
||||
|
||||
Expected : build Nuxt réussi, **aucune erreur TypeScript** ni de template. (Si `mergedSections`/types invalides, le build échoue ici.)
|
||||
|
||||
- [ ] **Step 5 : QA manuelle (dev server)**
|
||||
|
||||
```bash
|
||||
make dev-nuxt # port 3002
|
||||
```
|
||||
|
||||
Vérifier en **admin** (`admin`/`admin`) :
|
||||
- 3 groupes : Général, Outils, Administration.
|
||||
- Général : Tableau de bord, Mes tâches, Projets, Suivi de temps.
|
||||
- En ouvrant un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent dans Général ; Kanban actif uniquement sur la page kanban (exact).
|
||||
- Outils : Messagerie (+ `(N)` si non-lus), Documents (si partage activé).
|
||||
- Administration : Absences équipe, Répertoire, Rapports, Administration.
|
||||
- Footer : timer cliquable (start/stop) + `v <version>` ; en replié, le timer reste (icône) et la version disparaît.
|
||||
- Logo Malio déplié + replié (collapsed via toggle du composant).
|
||||
- Route active surlignée ; pas de doublon `/mail`.
|
||||
|
||||
Vérifier en **utilisateur non-admin** (`alice`/`alice`) :
|
||||
- **Pas** de groupe Administration.
|
||||
- Items gated par permission absents si l'utilisateur n'a pas la permission.
|
||||
- Mes absences visible uniquement si `isEmployee`.
|
||||
|
||||
- [ ] **Step 6 : Vérifier le comportement mobile (largeur < lg)**
|
||||
|
||||
Réduire la fenêtre / activer le responsive devtools.
|
||||
- Vérifier l'ouverture/fermeture de la sidebar sur mobile.
|
||||
- Vérifier le bouton hamburger éventuel de `AppTopNav` :
|
||||
|
||||
```bash
|
||||
grep -rn "openMobileSidebar\|sidebarOpen\|closeMobileSidebar" frontend/app/components/ frontend/components/ frontend/app/layouts/default.vue
|
||||
```
|
||||
|
||||
- Si `MalioSidebar` gère le responsive et que l'overlay supprimé n'est plus nécessaire : OK.
|
||||
- Si l'ouverture mobile ne fonctionne plus (ex. AppTopNav appelait `openMobileSidebar` pour l'ancien overlay) : adapter **sans modifier la lib** — a minima conserver le repli/déploiement via `ui.sidebarCollapsed`, ou conserver un déclencheur. Documenter le choix retenu dans le commit.
|
||||
|
||||
- [ ] **Step 7 : Commit (sur demande utilisateur)**
|
||||
|
||||
```bash
|
||||
git add frontend/app/layouts/default.vue
|
||||
git commit -m "feat(sidebar) : migration du layout vers MalioSidebar (footer timer + version, logo Malio)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Nettoyage des éléments obsolètes
|
||||
|
||||
**Files:**
|
||||
- Possible delete: `frontend/components/ui/SidebarLink.vue`
|
||||
- Possible delete: anciens logos `frontend/public/malio.png`, `frontend/public/LOGO_CARRE.png`
|
||||
|
||||
**Interfaces:** aucun (suppression sûre uniquement si zéro référence).
|
||||
|
||||
- [ ] **Step 1 : Vérifier les usages restants de `SidebarLink`**
|
||||
|
||||
```bash
|
||||
grep -rn "SidebarLink" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" | grep -v node_modules
|
||||
```
|
||||
|
||||
- Si **aucune** occurrence : supprimer le fichier.
|
||||
|
||||
```bash
|
||||
git rm frontend/components/ui/SidebarLink.vue
|
||||
```
|
||||
|
||||
- Si encore référencé ailleurs : **ne pas supprimer**, laisser tel quel.
|
||||
|
||||
- [ ] **Step 2 : Vérifier les usages des anciens logos**
|
||||
|
||||
```bash
|
||||
grep -rn "malio.png\|LOGO_CARRE.png" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" --include="*.css" | grep -v node_modules
|
||||
```
|
||||
|
||||
- Si **aucune** occurrence : supprimer les deux PNG.
|
||||
|
||||
```bash
|
||||
git rm frontend/public/malio.png frontend/public/LOGO_CARRE.png
|
||||
```
|
||||
|
||||
- Sinon : conserver.
|
||||
|
||||
- [ ] **Step 3 : Build final**
|
||||
|
||||
```bash
|
||||
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
||||
```
|
||||
|
||||
Expected : build réussi.
|
||||
|
||||
- [ ] **Step 4 : Commit (sur demande utilisateur)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(sidebar) : suppression des composants/assets obsolètes de l'ancienne sidebar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (auteur du plan)
|
||||
|
||||
**Spec coverage :**
|
||||
- Remplacement par MalioSidebar → Task 3 ✓
|
||||
- Permissions serveur préservées → Task 1 (gates inchangés) + Task 3 (mail filtré/ré-injecté, garde-fou sections) ✓
|
||||
- 3 groupes Général/Outils/Administration → Task 1 + Task 3 (ordre canonique) ✓
|
||||
- Footer timer + version → Task 3 Step 1 ✓
|
||||
- Logo Malio Starseed → Task 2 + Task 3 ✓
|
||||
- Items contextuels (Kanban/Groupes/Archives, Documents, Mes absences) → Task 3 `clientItemsFor` ✓
|
||||
- Badge mail = suffixe `(N)` → Task 3 `clientItemsFor` ✓
|
||||
- Mobile → Task 3 Step 6 ✓
|
||||
- Nettoyage → Task 4 ✓
|
||||
|
||||
**Placeholder scan :** pas de TBD ; les branches conditionnelles de suppression (Task 4) et d'adaptation mobile (Task 3 Step 6) sont des décisions binaires basées sur un `grep`, pas des placeholders.
|
||||
|
||||
**Type consistency :** `MalioItem`/`MalioSection` définis une fois (Task 3) et utilisés de façon cohérente ; `clientItemsFor`/`mergedSections`/`SECTION_ORDER`/`SECTION_ICON` cohérents. Items produits conformes au type attendu par `MalioSidebar` (`{label, to, exact?}`).
|
||||
|
||||
**Réserve connue :** absence de runner de test FE → vérification par build + QA manuelle (assumé, conforme à l'état du repo).
|
||||
@@ -1,203 +0,0 @@
|
||||
# Répertoire — Contacts, Adresses & Rapports commerciaux
|
||||
|
||||
**Date :** 2026-06-22
|
||||
**Module :** `Directory` (Lesstime)
|
||||
**Statut :** Conception validée — prêt pour plan d'implémentation
|
||||
|
||||
## Contexte & objectif
|
||||
|
||||
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
|
||||
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
|
||||
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
|
||||
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
|
||||
|
||||
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
|
||||
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
|
||||
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
|
||||
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
|
||||
type d'échange + auteur) auquel on peut **joindre des documents**.
|
||||
|
||||
Décisions cadrées avec l'utilisateur :
|
||||
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
|
||||
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
|
||||
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
|
||||
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
|
||||
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
|
||||
|
||||
## Approche retenue
|
||||
|
||||
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
|
||||
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
|
||||
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
|
||||
|
||||
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
|
||||
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
|
||||
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
|
||||
FK** (pas de copie), ce qui préserve l'historique.
|
||||
|
||||
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
|
||||
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
|
||||
|
||||
## Modèle de données (backend — `src/Module/Directory`)
|
||||
|
||||
Toutes les nouvelles entités vivent dans le module `Directory`
|
||||
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
|
||||
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
|
||||
sont `#[Auditable]` comme `Client`/`Prospect`.
|
||||
|
||||
### `Contact` (répétable)
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | int PK | |
|
||||
| firstName | string? | |
|
||||
| lastName | string? | |
|
||||
| jobTitle | string? | fonction |
|
||||
| email | string? | lowercase |
|
||||
| phonePrimary | string? | |
|
||||
| phoneSecondary | string? | |
|
||||
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
|
||||
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
|
||||
|
||||
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
|
||||
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
|
||||
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
|
||||
`isContactNamed` de Starseed).
|
||||
|
||||
### `Address` (répétable)
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | int PK | |
|
||||
| label | string? | libellé libre (« Siège », « Facturation »…) |
|
||||
| street | string? | |
|
||||
| streetComplement | string? | |
|
||||
| postalCode | string? | |
|
||||
| city | string? | |
|
||||
| country | string | défaut `FR` |
|
||||
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
|
||||
|
||||
### `CommercialReport` (compte-rendu, répétable)
|
||||
| Champ | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| id | int PK | |
|
||||
| subject | string | objet du compte-rendu |
|
||||
| body | text | le compte-rendu lui-même |
|
||||
| occurredAt | date | date de l'échange |
|
||||
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
|
||||
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
|
||||
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
|
||||
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
|
||||
|
||||
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
|
||||
|
||||
### Migration de l'adresse *inline*
|
||||
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
|
||||
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
|
||||
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
|
||||
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
|
||||
restent sur `Client`/`Prospect` (identité principale).
|
||||
|
||||
### Documents des comptes-rendus
|
||||
|
||||
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
|
||||
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
|
||||
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
|
||||
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
|
||||
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
|
||||
> recouple `ProjectManagement` ↔ `Directory`.
|
||||
|
||||
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
|
||||
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
|
||||
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
|
||||
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
|
||||
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
|
||||
croisée `ProjectManagement` → `Directory`) au prix d'une duplication maîtrisée du processor
|
||||
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
|
||||
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
|
||||
dépendent que du DTO document + de l'URL de download).
|
||||
|
||||
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
|
||||
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
|
||||
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
|
||||
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
|
||||
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
|
||||
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
|
||||
`GetCollection` filtrable par `commercialReport`.
|
||||
|
||||
## API Platform
|
||||
|
||||
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
|
||||
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
|
||||
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
|
||||
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
|
||||
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
|
||||
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
|
||||
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
|
||||
`author` (id + username) et `documents`.
|
||||
|
||||
Permissions RBAC ajoutées au `Module::permissions()` :
|
||||
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
|
||||
`directory.clients.*` / `directory.prospects.*` existants.)
|
||||
|
||||
## Conversion prospect → client
|
||||
|
||||
`ConvertProspectProcessor`
|
||||
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
|
||||
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
|
||||
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
|
||||
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
|
||||
automatiquement (rattachés au `CommercialReport`, pas au prospect).
|
||||
|
||||
## Frontend (Nuxt — `frontend/modules/directory`)
|
||||
|
||||
### Liste & navigation
|
||||
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
|
||||
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
|
||||
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
|
||||
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
|
||||
|
||||
### Fiches détail
|
||||
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
|
||||
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
|
||||
- Bloc principal (identité : name/email/phone…), éditable en place.
|
||||
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
|
||||
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
|
||||
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
|
||||
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
|
||||
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
|
||||
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
|
||||
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
|
||||
|
||||
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
|
||||
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
|
||||
|
||||
### Services & DTO
|
||||
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
|
||||
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
|
||||
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
|
||||
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
|
||||
|
||||
## i18n
|
||||
|
||||
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
|
||||
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
|
||||
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
|
||||
|
||||
## Tests (PHPUnit)
|
||||
|
||||
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
|
||||
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
|
||||
client (`prospect = null`), idempotence.
|
||||
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
|
||||
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
|
||||
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
|
||||
ayant une adresse non vide).
|
||||
|
||||
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
|
||||
> (relations, statuts, présence), pas des **counts absolus**.
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
|
||||
- Pas de dashboard/statistiques commerciales chiffrées.
|
||||
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
|
||||
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.
|
||||
@@ -1,200 +0,0 @@
|
||||
# Migration de la sidebar vers `MalioSidebar` (@malio/layer-ui)
|
||||
|
||||
**Date** : 2026-06-25
|
||||
**Statut** : Design validé
|
||||
**Scope** : Frontend (layout) + backend (config sidebar) + assets
|
||||
|
||||
## Contexte
|
||||
|
||||
La sidebar actuelle de Lesstime est un `<aside>` fait main dans
|
||||
`frontend/app/layouts/default.vue`, qui itère sur les sections renvoyées par
|
||||
`/api/sidebar` et rend chaque item via le composant maison `SidebarLink`. Le
|
||||
timer et la version sont empilés en bas du `<aside>`, le toggle collapse et
|
||||
l'overlay mobile sont gérés manuellement.
|
||||
|
||||
La librairie `@malio/layer-ui` (mise à jour) fournit désormais un composant
|
||||
`MalioSidebar`. Le projet **Starseed** a déjà effectué cette migration sur une
|
||||
architecture identique (`config/sidebar.php` → `SidebarProvider` → composable
|
||||
`useSidebar` → layout). Cette spec applique la même migration à Lesstime, avec
|
||||
trois spécificités Lesstime : footer (timer + version), re-catégorisation des
|
||||
onglets, et plusieurs items contextuels rendus côté client.
|
||||
|
||||
On **ne modifie pas** la lib `@malio/layer-ui` (règle CLAUDE.md).
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. Remplacer le `<aside>` maison par `<MalioSidebar>`.
|
||||
2. Préserver le filtrage des permissions/rôles/modules **côté serveur**.
|
||||
3. Re-catégoriser la navigation en 3 groupes : **Général / Outils / Administration**.
|
||||
4. Mettre le timer et la version dans le **footer** du composant.
|
||||
5. Reprendre le **logo Malio** de Starseed.
|
||||
|
||||
## Décisions validées
|
||||
|
||||
- **Catégorisation** : 3 groupes (option B).
|
||||
- **Badge mail** : le compteur de non-lus devient un **suffixe sur le label**
|
||||
(`Messagerie (3)`), faute de slot badge/icône par item dans `MalioSidebar`.
|
||||
|
||||
## Contraintes du composant `MalioSidebar`
|
||||
|
||||
Source : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
||||
|
||||
- **Props** : `sections` (requis), `modelValue` (v-model collapse, bool),
|
||||
`id`, `sidebarClass`, `toggleClass`.
|
||||
- **Types** :
|
||||
- `SidebarItem = { label: string; to: string; exact?: boolean }`
|
||||
- `SidebarSection = { label?: string; icon?: string; items: SidebarItem[] }`
|
||||
- **Slots** : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
||||
- **Events** : `update:modelValue(boolean)`.
|
||||
- **Item** : pas d'icône par item ni de badge — uniquement l'icône de section.
|
||||
Route active = match exact ou par préfixe (`exact: true` pour exact strict).
|
||||
- Largeurs fixes : 232px (déplié) / 72px (replié). Toggle interne.
|
||||
|
||||
### Conséquences (compromis assumés)
|
||||
|
||||
- Perte de l'**icône par item** (design malioUI = texte + icône de section).
|
||||
Starseed fonctionne ainsi.
|
||||
- Le **badge mail** ne peut pas être une pastille → suffixe `(N)` dans le label.
|
||||
|
||||
## Architecture cible
|
||||
|
||||
Modèle **backend-driven** conservé (sécurité serveur intacte). Le frontend
|
||||
mappe les sections renvoyées par `/api/sidebar` vers le format `MalioSidebar`
|
||||
et **fusionne** les items contextuels (qui dépendent d'un état runtime non
|
||||
connu du backend).
|
||||
|
||||
### 1. Backend — `config/sidebar.php`
|
||||
|
||||
Re-catégorisation en 3 sections (gates inchangés, juste réorganisés) :
|
||||
|
||||
```
|
||||
GÉNÉRAL (sidebar.general.section, icon mdi:view-dashboard-outline)
|
||||
Tableau de bord / —
|
||||
Mes tâches /my-tasks module project-management, perm tasks.view
|
||||
Projets /projects module project-management, perm projects.view
|
||||
Suivi de temps /time-tracking module time-tracking, perm entries.view
|
||||
|
||||
OUTILS (sidebar.tools.section, icon mdi:tools)
|
||||
Messagerie /mail module mail
|
||||
(filtré du rendu backend côté front, ré-injecté avec badge)
|
||||
|
||||
ADMINISTRATION (sidebar.admin.section, icon mdi:cog-outline, roles [ROLE_ADMIN])
|
||||
Absences équipe /team-absences module absence
|
||||
Répertoire /directory module directory
|
||||
Rapports /reporting module reporting, perm reporting.view
|
||||
Administration /admin perm core.users.view
|
||||
```
|
||||
|
||||
> `/mail` reste déclaré pour le gating module (`disabledRoutes`), mais est
|
||||
> filtré des sections rendues et ré-injecté côté client avec son badge, comme
|
||||
> aujourd'hui.
|
||||
|
||||
### 2. i18n — `frontend/i18n/locales/fr.json`
|
||||
|
||||
- Renommer `sidebar.general.section` : « Gestion de projet » → « Général ».
|
||||
- Ajouter `sidebar.tools.section` : « Outils ».
|
||||
- Conserver les clés d'items existantes. Items client : réutiliser les clés
|
||||
existantes quand elles existent (`sharedFiles.sidebar.title` pour Documents,
|
||||
`mail.sidebar.title`/`sidebar.general.mail` pour Messagerie) ; ajouter une
|
||||
clé pour « Mes absences » (aujourd'hui en dur) et pour les contextuels
|
||||
(Kanban/Groupes/Archives, aujourd'hui en dur) si on souhaite les traduire,
|
||||
sinon conserver les libellés en dur actuels.
|
||||
|
||||
### 3. Frontend — `frontend/app/layouts/default.vue`
|
||||
|
||||
Réécriture du template autour de `<MalioSidebar>` :
|
||||
|
||||
```vue
|
||||
<MalioSidebar v-model="ui.sidebarCollapsed" :sections="mergedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'">
|
||||
<template #logo> <img src="/LOGO_MALIO.png" alt="Malio"/></template>
|
||||
<template #logo-collapsed> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/></template>
|
||||
<template #footer>
|
||||
<SidebarTimer :collapsed="false" />
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</template>
|
||||
<template #footer-collapsed>
|
||||
<SidebarTimer :collapsed="true" />
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
```
|
||||
|
||||
**Computed `mergedSections`** : construit les sections finales dans l'ordre
|
||||
canonique `[général, outils, administration]`.
|
||||
|
||||
Logique de fusion :
|
||||
1. Partir des sections backend (déjà filtrées), mappées en
|
||||
`{ label: t(label), icon, items: items.filter(to !== '/mail').map({label: t, to}) }`.
|
||||
2. Définir une table `clientItems` indexée par clé de section :
|
||||
- `sidebar.general.section` → (si `currentProjectId`) Kanban (`exact`),
|
||||
Groupes, Archives ; puis (si `isEmployee`) Mes absences.
|
||||
- `sidebar.tools.section` → (si `isMailVisible`) Messagerie avec label
|
||||
`Messagerie` + suffixe `(N)` quand `mailStore.globalUnreadCount > 0`
|
||||
(`99+` au-delà) ; puis (si `shareEnabled`) Documents.
|
||||
3. Pour chaque section backend, **append** ses items client.
|
||||
4. Si une clé de `clientItems` produit des items mais que la section
|
||||
correspondante n'est **pas** présente dans la réponse backend (ex. module
|
||||
mail off mais partage on → pas de section « Outils » côté backend), **créer**
|
||||
la section côté front (label + icône depuis une table locale).
|
||||
5. **Supprimer** les sections finales sans items.
|
||||
6. Trier selon l'ordre canonique des clés.
|
||||
|
||||
Le reste du `<script>` (timer title watchers, `refData`/`TimeEntryDrawer`,
|
||||
polling mail, `ensureShareStatus`, `currentProjectId`, `isEmployee`,
|
||||
`isMailVisible`, `shareEnabled`) est **conservé tel quel**.
|
||||
|
||||
### 4. Mobile
|
||||
|
||||
Starseed a **supprimé l'overlay mobile custom** et ne garde que
|
||||
`watch(route) → ui.closeMobileSidebar()`. On s'aligne : suppression du markup
|
||||
overlay (`ui.sidebarOpen`, `.sidebar-overlay`) si `MalioSidebar` gère le
|
||||
responsive. **À vérifier à l'implémentation** : comportement mobile réel du
|
||||
composant ; si l'ouverture mobile n'est pas couverte, adapter a minima sans
|
||||
modifier la lib.
|
||||
|
||||
### 5. Assets — logo
|
||||
|
||||
Copier depuis Starseed vers `frontend/public/` :
|
||||
- `LOGO_MALIO.png` (128×44)
|
||||
- `LOGO_MALIO_COLLAPSED.png` (34×40)
|
||||
|
||||
Les anciens `/malio.png` et `/LOGO_CARRE.png` ne sont plus référencés par le
|
||||
layout (les laisser ou les retirer si plus aucun usage — à vérifier).
|
||||
|
||||
## Composants / éléments réutilisés
|
||||
|
||||
- `SidebarTimer` (`components/ui/SidebarTimer.vue`) : inchangé, déjà piloté par
|
||||
`:collapsed`.
|
||||
- `useAppVersion()` : inchangé.
|
||||
- `useSidebar()` : inchangé.
|
||||
- `usePermissions()` : inchangé (le filtrage permission reste backend ; les
|
||||
flags client `isEmployee`/`isMailVisible`/`shareEnabled` restent locaux).
|
||||
|
||||
## Éléments supprimés
|
||||
|
||||
- Le `<aside>` manuel et son markup (logo, nav, toggle, overlay) dans
|
||||
`default.vue`.
|
||||
- L'usage de `SidebarLink` dans le layout (le composant peut rester s'il est
|
||||
utilisé ailleurs — à vérifier ; sinon suppression possible).
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
1. La sidebar est rendue par `<MalioSidebar>`.
|
||||
2. 3 groupes : Général, Outils, Administration (Administration visible
|
||||
uniquement pour `ROLE_ADMIN` / permissions, comme avant).
|
||||
3. Toutes les permissions/rôles/modules sont respectés à l'identique (aucune
|
||||
régression de visibilité pour user/admin).
|
||||
4. Items contextuels présents : Kanban/Groupes/Archives (dans un projet),
|
||||
Documents (partage activé), Mes absences (employé).
|
||||
5. Messagerie affiche `(N)` quand il y a des non-lus.
|
||||
6. Footer : timer fonctionnel + version (version masquée en replié).
|
||||
7. Logo Malio de Starseed affiché (déplié + replié).
|
||||
8. Collapse/expand et route active fonctionnent.
|
||||
9. Pas de doublon `/mail`. Pas de section vide affichée.
|
||||
10. Build Nuxt OK, pas d'erreur TS.
|
||||
|
||||
## Hors scope
|
||||
|
||||
- Refonte du `SiteSelector` (n'existe pas dans Lesstime).
|
||||
- Modification de la lib `@malio/layer-ui`.
|
||||
- Changement du modèle de permissions backend.
|
||||
@@ -1,267 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<MalioSidebar
|
||||
v-model="ui.sidebarCollapsed"
|
||||
:sections="mergedSections"
|
||||
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||
>
|
||||
<template #logo>
|
||||
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||
</template>
|
||||
<template #logo-collapsed>
|
||||
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex flex-col gap-2">
|
||||
<SidebarTimer :collapsed="false" />
|
||||
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer-collapsed>
|
||||
<SidebarTimer :collapsed="true" />
|
||||
</template>
|
||||
</MalioSidebar>
|
||||
|
||||
<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-6 lg:px-12 xl:px-11">
|
||||
<slot/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TimeEntryDrawer
|
||||
v-model="completeDrawerOpen"
|
||||
:entry="timerStore.pendingCompleteEntry"
|
||||
:users="refData.users"
|
||||
:projects="refData.projects"
|
||||
:tags="refData.tags"
|
||||
@saved="onCompleteSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { sections } = useSidebar()
|
||||
|
||||
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
|
||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
||||
|
||||
const currentProjectId = computed(() => {
|
||||
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||
return match ? match[1] : null
|
||||
})
|
||||
|
||||
type MalioItem = { label: string; to: string; exact?: boolean }
|
||||
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
||||
|
||||
// Ordre d'affichage canonique des sections.
|
||||
const SECTION_ORDER = [
|
||||
'sidebar.general.section',
|
||||
'sidebar.tools.section',
|
||||
'sidebar.admin.section',
|
||||
] as const
|
||||
|
||||
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
||||
// ex. module mail off mais partage actif → section Outils à recréer).
|
||||
const SECTION_ICON: Record<string, string> = {
|
||||
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
||||
'sidebar.tools.section': 'mdi:tools',
|
||||
'sidebar.admin.section': 'mdi:cog-outline',
|
||||
}
|
||||
|
||||
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
|
||||
// (sinon ajouté en fin de section).
|
||||
type ClientItem = MalioItem & { after?: string }
|
||||
|
||||
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
||||
function clientItemsFor(key: string): ClientItem[] {
|
||||
if (key === 'sidebar.general.section') {
|
||||
const items: ClientItem[] = []
|
||||
if (currentProjectId.value) {
|
||||
const id = currentProjectId.value
|
||||
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
|
||||
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
|
||||
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
|
||||
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
|
||||
}
|
||||
if (isEmployee.value) {
|
||||
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
||||
}
|
||||
return items
|
||||
}
|
||||
if (key === 'sidebar.tools.section') {
|
||||
const items: ClientItem[] = []
|
||||
if (isMailVisible.value) {
|
||||
const n = mailStore.globalUnreadCount
|
||||
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
||||
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
||||
}
|
||||
if (isDocumentsVisible.value) {
|
||||
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
||||
}
|
||||
return items
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
|
||||
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
|
||||
const result = [...base]
|
||||
for (const { after, ...item } of extra) {
|
||||
const idx = after ? result.findIndex((i) => i.to === after) : -1
|
||||
if (idx !== -1) {
|
||||
result.splice(idx + 1, 0, item)
|
||||
} else {
|
||||
result.push(item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const mergedSections = computed<MalioSection[]>(() => {
|
||||
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
||||
const backend = new Map<string, MalioSection>()
|
||||
for (const section of sections.value) {
|
||||
backend.set(section.label, {
|
||||
label: t(section.label),
|
||||
icon: section.icon,
|
||||
items: section.items
|
||||
.filter((item) => item.to !== '/mail')
|
||||
.map((item) => ({ label: t(item.label), to: item.to })),
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Fusion dans l'ordre canonique.
|
||||
const result: MalioSection[] = []
|
||||
for (const key of SECTION_ORDER) {
|
||||
const base = backend.get(key)
|
||||
const extra = clientItemsFor(key)
|
||||
if (base) {
|
||||
base.items = mergeClientItems(base.items, extra)
|
||||
if (base.items.length > 0) {
|
||||
result.push(base)
|
||||
}
|
||||
} else if (extra.length > 0) {
|
||||
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], extra) })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
||||
for (const [key, section] of backend) {
|
||||
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
||||
result.push(section)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
const baseTitle = ref('Lesstime')
|
||||
|
||||
useHead({
|
||||
titleTemplate: (title) => {
|
||||
baseTitle.value = title || 'Lesstime'
|
||||
return title || 'Lesstime'
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => timerStore.elapsedFormatted, () => timerStore.isRunning, () => timerStore.activeEntry?.title],
|
||||
([elapsed, running, label]) => {
|
||||
if (import.meta.server) return
|
||||
const base = baseTitle.value
|
||||
if (running) {
|
||||
document.title = label ? `${base} | ${elapsed} · ${label}` : `${base} | ${elapsed}`
|
||||
} else {
|
||||
document.title = base
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
timerStore.fetchActive()
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
})
|
||||
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else {
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
ensureShareStatus()
|
||||
}
|
||||
})
|
||||
|
||||
const completeDrawerOpen = ref(false)
|
||||
const refData = reactive({
|
||||
users: [] as UserData[],
|
||||
projects: [] as Project[],
|
||||
tags: [] as TaskTag[],
|
||||
loaded: false,
|
||||
})
|
||||
|
||||
async function loadRefData() {
|
||||
if (refData.loaded) return
|
||||
const api = useApi()
|
||||
const [usersData, projectsData, typesData] = await Promise.all([
|
||||
api.get<HydraCollection<UserData>>('/users'),
|
||||
api.get<HydraCollection<Project>>('/projects'),
|
||||
api.get<HydraCollection<TaskTag>>('/task_tags'),
|
||||
])
|
||||
refData.users = extractHydraMembers(usersData)
|
||||
refData.projects = extractHydraMembers(projectsData)
|
||||
refData.tags = extractHydraMembers(typesData)
|
||||
refData.loaded = true
|
||||
}
|
||||
|
||||
watch(() => timerStore.pendingCompleteEntry, async (entry) => {
|
||||
if (entry) {
|
||||
await loadRefData()
|
||||
completeDrawerOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
watch(completeDrawerOpen, (open) => {
|
||||
if (!open) {
|
||||
nextTick(() => {
|
||||
timerStore.clearPendingEntry()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function onCompleteSaved() {
|
||||
completeDrawerOpen.value = false
|
||||
nextTick(() => {
|
||||
timerStore.clearPendingEntry()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,30 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const isLogin = to.path === '/login'
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
if (!isLogin && !auth.isAuthenticated) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
|
||||
const { loaded: sidebarLoaded, loadSidebar, resetSidebar } = useSidebar()
|
||||
const { loaded: modulesLoaded, loadModules, resetModules } = useModules()
|
||||
|
||||
if (auth.isAuthenticated) {
|
||||
await Promise.all([
|
||||
sidebarLoaded.value ? Promise.resolve() : loadSidebar(),
|
||||
modulesLoaded.value ? Promise.resolve() : loadModules(),
|
||||
])
|
||||
} else {
|
||||
// Logout / session expirée : purge l'état partagé pour le prochain login.
|
||||
resetSidebar()
|
||||
resetModules()
|
||||
}
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const { loaded, loadSidebar, isRouteDisabled } = useSidebar()
|
||||
if (!loaded.value) {
|
||||
await loadSidebar()
|
||||
}
|
||||
|
||||
if (isRouteDisabled(to.path)) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
+2
-2
@@ -19,8 +19,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-1
@@ -73,7 +73,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
balances: AbsenceBalance[]
|
||||
+3
-2
@@ -52,8 +52,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
+3
-2
@@ -135,8 +135,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-2
@@ -26,8 +26,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-2
@@ -105,8 +105,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -51,8 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4">
|
||||
<MalioSelect
|
||||
v-model="entityTypeFilter"
|
||||
:options="entityTypeOptions"
|
||||
:label="$t('admin.audit.filterEntityType')"
|
||||
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="actionFilter"
|
||||
:options="actionOptions"
|
||||
:label="$t('admin.audit.filterAction')"
|
||||
:empty-option-label="$t('admin.audit.filterActionAll')"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.audit.empty')"
|
||||
>
|
||||
<template #cell-performedAt="{ item }">
|
||||
{{ formatDate(item.performedAt) }}
|
||||
</template>
|
||||
<template #cell-entityType="{ item }">
|
||||
{{ entityTypeLabel(item.entityType) }}
|
||||
</template>
|
||||
<template #cell-action="{ item }">
|
||||
{{ actionLabel(item.action) }}
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.previous')"
|
||||
:disabled="page <= 1 || isLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.audit.next')"
|
||||
:disabled="!hasNextPage || isLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
|
||||
import { useAuditLogService } from '~/modules/core/services/audit-logs'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
|
||||
{ key: 'performedBy', label: t('admin.audit.performedBy') },
|
||||
{ key: 'entityType', label: t('admin.audit.entityType') },
|
||||
{ key: 'action', label: t('admin.audit.action') },
|
||||
{ key: 'entityId', label: t('admin.audit.entityId') },
|
||||
])
|
||||
|
||||
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
|
||||
{ value: 'create', label: t('audit.action.create') },
|
||||
{ value: 'update', label: t('audit.action.update') },
|
||||
{ value: 'delete', label: t('audit.action.delete') },
|
||||
])
|
||||
|
||||
const auditLogService = useAuditLogService()
|
||||
|
||||
const rows = ref<AuditLogItem[]>([])
|
||||
const entityTypes = ref<string[]>([])
|
||||
const totalItems = ref(0)
|
||||
const page = ref(1)
|
||||
const isLoading = ref(true)
|
||||
const entityTypeFilter = ref<string | null>(null)
|
||||
const actionFilter = ref<AuditLogAction | null>(null)
|
||||
|
||||
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
|
||||
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
|
||||
)
|
||||
|
||||
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
|
||||
// "next" button accurate even on the last (partial) page.
|
||||
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
|
||||
|
||||
function entityTypeLabel(value: string): string {
|
||||
const key = `audit.entity.${value}`
|
||||
return te(key) ? t(key) : value
|
||||
}
|
||||
|
||||
function actionLabel(action: AuditLogAction): string {
|
||||
return t(`audit.action.${action}`)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await auditLogService.list({
|
||||
page: page.value,
|
||||
entityType: entityTypeFilter.value ?? undefined,
|
||||
action: actionFilter.value ?? undefined,
|
||||
})
|
||||
rows.value = result.items
|
||||
totalItems.value = result.totalItems
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEntityTypes() {
|
||||
entityTypes.value = await auditLogService.entityTypes()
|
||||
}
|
||||
|
||||
function goToPage(target: number) {
|
||||
if (target < 1) {
|
||||
return
|
||||
}
|
||||
page.value = target
|
||||
loadItems()
|
||||
}
|
||||
|
||||
watch([entityTypeFilter, actionFilter], () => {
|
||||
page.value = 1
|
||||
loadItems()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadEntityTypes()
|
||||
})
|
||||
</script>
|
||||
@@ -51,7 +51,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('admin.roles.addRole')"
|
||||
@click="openCreate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:items="items"
|
||||
:loading="isLoading"
|
||||
:empty-message="$t('admin.roles.empty')"
|
||||
@row-click="openEdit"
|
||||
>
|
||||
<template #cell-isSystem="{ item }">
|
||||
<span
|
||||
v-if="item.isSystem"
|
||||
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ $t('admin.roles.system') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-permissions="{ item }">
|
||||
<span class="text-neutral-600">{{ item.permissions.length }}</span>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<MalioButtonIcon
|
||||
v-if="!item.isSystem"
|
||||
icon="mdi:delete-outline"
|
||||
:aria-label="$t('common.delete')"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
button-class="text-neutral-400 hover:text-red-500"
|
||||
@click.stop="handleDelete(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<RoleDrawer
|
||||
v-model="drawerOpen"
|
||||
:item="selectedItem"
|
||||
:permissions="permissions"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
import { usePermissionService } from '~/modules/core/services/permissions'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'label', label: t('admin.roles.label'), primary: true },
|
||||
{ key: 'code', label: t('admin.roles.code') },
|
||||
{ key: 'permissions', label: t('admin.roles.permissions') },
|
||||
{ key: 'isSystem', label: '' },
|
||||
])
|
||||
|
||||
const roleService = useRoleService()
|
||||
const permissionService = usePermissionService()
|
||||
|
||||
const items = ref<Role[]>([])
|
||||
const permissions = ref<Permission[]>([])
|
||||
const isLoading = ref(true)
|
||||
const drawerOpen = ref(false)
|
||||
const selectedItem = ref<Role | null>(null)
|
||||
|
||||
async function loadItems() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
items.value = await roleService.list()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
permissions.value = await permissionService.list()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedItem.value = null
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: Role) {
|
||||
selectedItem.value = item
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await roleService.remove(id)
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
async function onSaved() {
|
||||
await loadItems()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadItems()
|
||||
loadPermissions()
|
||||
})
|
||||
</script>
|
||||
@@ -70,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
|
||||
import { useShareSettingsService } from '~/services/share-settings'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useZimbraService } from '~/modules/integration/services/zimbra'
|
||||
import { useZimbraService } from '~/services/zimbra'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">
|
||||
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
|
||||
</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
:label="$t('admin.roles.code')"
|
||||
input-class="w-full"
|
||||
:disabled="isEditing"
|
||||
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
|
||||
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
|
||||
@blur="touched.code = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="$t('admin.roles.label')"
|
||||
input-class="w-full"
|
||||
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
|
||||
@blur="touched.label = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('admin.roles.description')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">
|
||||
{{ $t('admin.roles.permissions') }}
|
||||
</label>
|
||||
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
|
||||
{{ $t('admin.roles.noPermissions') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="group in groupedPermissions"
|
||||
:key="group.module"
|
||||
class="mt-3 rounded-lg border border-neutral-200 p-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||||
{{ group.module }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
v-for="perm in group.permissions"
|
||||
:key="perm.id"
|
||||
class="flex items-start gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.permissions"
|
||||
type="checkbox"
|
||||
:value="perm['@id']"
|
||||
class="mt-0.5 rounded border-neutral-300"
|
||||
/>
|
||||
<span>
|
||||
{{ perm.label }}
|
||||
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Role, RoleWrite } from '~/modules/core/services/roles'
|
||||
import { useRoleService } from '~/modules/core/services/roles'
|
||||
import type { Permission } from '~/modules/core/services/permissions'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
item: Role | null
|
||||
permissions: Permission[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
label: '',
|
||||
description: '',
|
||||
permissions: [] as string[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
code: false,
|
||||
label: false,
|
||||
})
|
||||
|
||||
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const byModule = new Map<string, Permission[]>()
|
||||
for (const perm of props.permissions) {
|
||||
const list = byModule.get(perm.module) ?? []
|
||||
list.push(perm)
|
||||
byModule.set(perm.module, list)
|
||||
}
|
||||
return [...byModule.entries()]
|
||||
.map(([module, permissions]) => ({ module, permissions }))
|
||||
.sort((a, b) => a.module.localeCompare(b.module))
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.code = props.item.code
|
||||
form.label = props.item.label
|
||||
form.description = props.item.description ?? ''
|
||||
form.permissions = props.item.permissions
|
||||
.map((p) => p['@id'])
|
||||
.filter((iri): iri is string => !!iri)
|
||||
} else {
|
||||
form.code = ''
|
||||
form.label = ''
|
||||
form.description = ''
|
||||
form.permissions = []
|
||||
}
|
||||
touched.code = false
|
||||
touched.label = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useRoleService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.code = true
|
||||
touched.label = true
|
||||
if (!form.label.trim()) {
|
||||
return
|
||||
}
|
||||
if (!isEditing.value && !codeValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (isEditing.value && props.item) {
|
||||
const payload: Partial<RoleWrite> = {
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await update(props.item.id, payload)
|
||||
} else {
|
||||
const payload: RoleWrite = {
|
||||
code: form.code.trim(),
|
||||
label: form.label.trim(),
|
||||
description: form.description.trim() || null,
|
||||
permissions: form.permissions,
|
||||
}
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -96,11 +96,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
+55
-4
@@ -6,11 +6,36 @@
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom société"
|
||||
label="Nom"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.email"
|
||||
label="Email"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.phone"
|
||||
label="Téléphone"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.street"
|
||||
label="Rue"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.city"
|
||||
label="Ville"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.postalCode"
|
||||
label="Code Postal"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
@@ -25,8 +50,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -48,16 +73,37 @@ const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
street: '',
|
||||
city: '',
|
||||
postalCode: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
email: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
form.name = props.client?.name ?? ''
|
||||
if (props.client) {
|
||||
form.name = props.client.name ?? ''
|
||||
form.email = props.client.email ?? ''
|
||||
form.phone = props.client.phone ?? ''
|
||||
form.street = props.client.street ?? ''
|
||||
form.city = props.client.city ?? ''
|
||||
form.postalCode = props.client.postalCode ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.street = ''
|
||||
form.city = ''
|
||||
form.postalCode = ''
|
||||
}
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,6 +117,11 @@ async function handleSubmit() {
|
||||
try {
|
||||
const payload: ClientWrite = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
street: form.street.trim() || null,
|
||||
city: form.city.trim() || null,
|
||||
postalCode: form.postalCode.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.client) {
|
||||
+8
-8
@@ -1,14 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/modules/mail/stores/mail'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
+7
-7
@@ -123,13 +123,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import type { Project, ProjectWrite } from '~/services/dto/project'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { GiteaRepository } from '~/services/dto/gitea'
|
||||
import type { BookStackShelf } from '~/services/dto/bookstack'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+4
-4
@@ -67,10 +67,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+6
-6
@@ -82,12 +82,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -167,8 +167,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import type { FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
+2
-2
@@ -75,8 +75,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
|
||||
import { useBookStackService } from '~/services/bookstack'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
+6
-6
@@ -104,13 +104,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
selectedCount: number
|
||||
+1
-1
@@ -102,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
+2
-2
@@ -60,8 +60,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
defineProps<{
|
||||
+2
-2
@@ -121,8 +121,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
+3
-3
@@ -56,9 +56,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+1
-1
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
+2
-2
@@ -25,8 +25,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+3
-3
@@ -226,9 +226,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { copyToClipboard } from '~/utils/clipboard'
|
||||
|
||||
const { t } = useI18n()
|
||||
+4
-4
@@ -56,10 +56,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
+16
-16
@@ -536,23 +536,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
|
||||
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useTaskService } from '~/modules/project-management/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -569,7 +569,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved', task?: Task): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
|
||||
await removeRecurrence(props.task.recurrence.id)
|
||||
}
|
||||
|
||||
emit('saved', savedTask)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
+2
-2
@@ -28,8 +28,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-2
@@ -28,8 +28,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: TimeEntry
|
||||
+1
-1
@@ -35,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
+4
-4
@@ -124,11 +124,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTimeEntryService } from '~/services/time-entries'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -67,7 +67,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+2
-2
@@ -150,8 +150,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
+3
-3
@@ -108,9 +108,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
|
||||
const props = defineProps<{
|
||||
users: UserData[]
|
||||
@@ -3,11 +3,11 @@
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:menu"
|
||||
aria-label="Replier ou déplier le menu"
|
||||
aria-label="Menu"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||
@click="ui.toggleSidebar()"
|
||||
@click="ui.openMobileSidebar()"
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ $t('directory.reports.confirmDeleteMessage') }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="busy"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="busy"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
// Suppression en cours : on désactive les actions pour éviter un double envoi.
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
if (props.busy) return
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<!-- Entête de page standard : source unique du style des titres.
|
||||
Toujours sticky en haut du <main> scrollable : reste visible au scroll.
|
||||
Fond blanc + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu
|
||||
défilant soit masqué sous l'entête (espaces haut ET bas compris) et que
|
||||
l'entête soit collée sous l'AppTopNav sans trou.
|
||||
Slots :
|
||||
- défaut : texte du titre
|
||||
- #actions : boutons à droite du titre
|
||||
- #subheader : barre de filtres / onglets rendue SOUS le titre, dans le
|
||||
même bloc sticky (reste donc collée avec le titre). La
|
||||
marge titre -> sous-entête est portée par le contenu passé
|
||||
(ex. mt-4) pour laisser chaque page régler son cas. -->
|
||||
<div class="sticky top-0 z-20 bg-white pt-[38px] pb-[30px]">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h1 class="text-[30px] font-semibold text-primary-500">
|
||||
<slot/>
|
||||
</h1>
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions"/>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="subheader"/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
@@ -106,43 +106,30 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
const { create, update, getById } = useUserService()
|
||||
|
||||
function applyUser(user: UserData) {
|
||||
form.username = user.username ?? ''
|
||||
form.firstName = user.firstName ?? ''
|
||||
form.lastName = user.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...user.roles]
|
||||
form.isEmployee = user.isEmployee ?? false
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
if (props.item) {
|
||||
applyUser(props.item)
|
||||
try {
|
||||
const full = await getById(props.item.id)
|
||||
applyUser(full)
|
||||
} catch {
|
||||
// Keep the list data if the detailed fetch fails.
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.firstName = props.item.firstName ?? ''
|
||||
form.lastName = props.item.lastName ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
}
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useUserService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.username = true
|
||||
touched.password = true
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
|
||||
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FetchOptions } from 'ofetch'
|
||||
import { $fetch, FetchError } from 'ofetch'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export type AnyObject = Record<string, unknown>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
|
||||
export function useShareStatus() {
|
||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||
+774
-1048
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user