From 610e99eeb9d68a3113aeae82961f55791ab104e8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 23 Jun 2026 16:03:32 +0200 Subject: [PATCH 1/3] fix(api) : denormalize interface-typed relation collections Add ContractRelationDenormalizer to resolve IRIs for collections typed against an interface (Contract), fixing POST/PATCH 400 errors. Cover it with InterfaceCollectionDenormalizationTest. --- .../ContractRelationDenormalizer.php | 96 ++++++++++++++ ...InterfaceCollectionDenormalizationTest.php | 119 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php create mode 100644 tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php diff --git a/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php b/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php new file mode 100644 index 0000000..d173c67 --- /dev/null +++ b/src/Shared/Infrastructure/ApiPlatform/Serializer/ContractRelationDenormalizer.php @@ -0,0 +1,96 @@ + resolved through the IriConverter + * - an array value is an embedded object -> denormalized into the concrete entity + */ +final class ContractRelationDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + private const CONTRACT_NAMESPACE = 'App\Shared\Domain\Contract\\'; + + /** @var array */ + private array $resolved = []; + + public function __construct( + private readonly IriConverterInterface $iriConverter, + private readonly EntityManagerInterface $entityManager, + ) {} + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return null !== $this->concreteClassFor($type); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?object + { + $concrete = $this->concreteClassFor($type); + if (null === $concrete) { + return null; + } + + if (is_string($data)) { + return $this->iriConverter->getResourceFromIri($data, $context); + } + + // Embedded object payload: denormalize into the resolved concrete entity. + return $this->denormalizer->denormalize($data, $concrete, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + // Support depends on the runtime-resolved Doctrine mapping, so it cannot be + // statically cached by the serializer. + return ['object' => false]; + } + + /** + * @return ?class-string the concrete entity a contract interface resolves to, or null + */ + private function concreteClassFor(string $type): ?string + { + if (array_key_exists($type, $this->resolved)) { + return $this->resolved[$type]; + } + + if (!str_starts_with($type, self::CONTRACT_NAMESPACE) || !interface_exists($type)) { + return $this->resolved[$type] = null; + } + + try { + $name = $this->entityManager->getClassMetadata($type)->getName(); + } catch (Throwable) { + // Not a Doctrine-mapped (resolve_target_entities) interface. + return $this->resolved[$type] = null; + } + + return $this->resolved[$type] = ($name !== $type ? $name : null); + } +} diff --git a/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php b/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php new file mode 100644 index 0000000..6437c98 --- /dev/null +++ b/tests/Functional/Module/Shared/InterfaceCollectionDenormalizationTest.php @@ -0,0 +1,119 @@ +get(EntityManagerInterface::class)->getConnection(); + $conn->executeStatement("DELETE FROM time_entry_task_type WHERE time_entry_id IN (SELECT id FROM time_entry WHERE title = 'iface-denorm-te')"); + $conn->executeStatement("DELETE FROM time_entry WHERE title = 'iface-denorm-te'"); + $conn->executeStatement("DELETE FROM task_collaborator WHERE task_id IN (SELECT id FROM task WHERE title = 'iface-denorm-task')"); + $conn->executeStatement("DELETE FROM task WHERE title = 'iface-denorm-task'"); + parent::tearDown(); + } + + public function testPostTimeEntryWithInterfaceTypedTags(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->loginAdmin($client); + + $userId = $this->adminId($em); + $tagId = $this->aTaskTagId($em); + + $client->request('POST', '/api/time_entries', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode([ + 'title' => 'iface-denorm-te', + 'startedAt' => '2026-06-22T10:00:00+02:00', + 'stoppedAt' => '2026-06-22T11:00:00+02:00', + 'user' => '/api/users/'.$userId, + 'tags' => ['/api/task_tags/'.$tagId], + ])); + + self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: ''); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $data['tags'] ?? []); + } + + public function testPostTaskWithInterfaceTypedCollaborators(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->loginAdmin($client); + + $userId = $this->adminId($em); + $projectId = $this->aProjectId($em); + + $client->request('POST', '/api/tasks', server: [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', + ], content: json_encode([ + 'title' => 'iface-denorm-task', + 'project' => '/api/projects/'.$projectId, + 'collaborators' => ['/api/users/'.$userId], + ])); + + self::assertResponseStatusCodeSame(201, $client->getResponse()->getContent() ?: ''); + $data = json_decode($client->getResponse()->getContent(), true); + self::assertCount(1, $data['collaborators'] ?? []); + } + + private function loginAdmin(KernelBrowser $client): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } + + private function adminId(EntityManagerInterface $em): int + { + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + + return $user->getId(); + } + + private function aTaskTagId(EntityManagerInterface $em): int + { + $tag = $em->getRepository(TaskTag::class)->findOneBy([]); + if (null === $tag) { + $tag = new TaskTag(); + $tag->setLabel('iface-denorm-tag'); + $em->persist($tag); + $em->flush(); + } + + return $tag->getId(); + } + + private function aProjectId(EntityManagerInterface $em): int + { + $project = $em->getRepository(Project::class)->findOneBy([]); + self::assertInstanceOf(Project::class, $project); + + return $project->getId(); + } +} From f2d945b0c31ad8db26a545c21290c9e79fc2d647 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 23 Jun 2026 16:04:02 +0200 Subject: [PATCH 2/3] fix(directory) : persist contacts/addresses on explicit save instead of on blur Hold contact/address block edits in memory and persist them via explicit saveContacts/saveAddresses on click (with saving guards), matching the task forms. Keep immediate deletion. Minor restyle of blocks and action buttons. --- .../components/CommercialReportTab.vue | 6 +- .../components/DirectoryAddressBlock.vue | 8 +- .../components/DirectoryContactBlock.vue | 8 +- .../composables/useDirectoryDetail.ts | 99 ++++++++++++------- .../pages/directory/clients/[id].vue | 50 +++++++--- .../pages/directory/prospects/[id].vue | 50 +++++++--- 6 files changed, 147 insertions(+), 74 deletions(-) diff --git a/frontend/modules/directory/components/CommercialReportTab.vue b/frontend/modules/directory/components/CommercialReportTab.vue index e29c78c..9374caf 100644 --- a/frontend/modules/directory/components/CommercialReportTab.vue +++ b/frontend/modules/directory/components/CommercialReportTab.vue @@ -1,7 +1,7 @@ @@ -40,13 +49,22 @@ @update:model-value="(v) => onAddressInput(i, v)" @remove="removeAddress(i)" /> - +
+ + +
@@ -76,12 +94,16 @@ const prospectService = useProspectService() const { contacts, addresses, + savingContacts, + savingAddresses, onContactInput, addContact, removeContact, + saveContacts, onAddressInput, addAddress, removeAddress, + saveAddresses, load, } = useDirectoryDetail(owner) From 8e00c5f5a8ab5b745af176fdb0adc7545449ea31 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 23 Jun 2026 16:11:58 +0200 Subject: [PATCH 3/3] fix(deploy) : seed RBAC system roles during deployment deploy.sh only synced the permission catalog; the system roles (admin, user) were never seeded, leaving the admin Roles page empty after a fresh deploy. Add app:seed-rbac (idempotent) to the deploy script, refresh the embedded script in deployment-docker.md, document the RBAC post-deploy step (with the manual fix for an already-deployed prod), and note it in CLAUDE.md. --- CLAUDE.md | 6 ++++++ doc/deployment-docker.md | 32 +++++++++++++++++++++++++++++++- infra/prod/deploy.sh | 3 +++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e7f8d2..f1a7143 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,12 @@ 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) diff --git a/doc/deployment-docker.md b/doc/deployment-docker.md index 2dbb342..ff97b98 100644 --- a/doc/deployment-docker.md +++ b/doc/deployment-docker.md @@ -128,6 +128,12 @@ 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 @@ -294,7 +300,31 @@ 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 et vide le cache. +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 +``` --- diff --git a/infra/prod/deploy.sh b/infra/prod/deploy.sh index 1027034..a4ab798 100755 --- a/infra/prod/deploy.sh +++ b/infra/prod/deploy.sh @@ -27,6 +27,9 @@ 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