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/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) 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 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(); + } +}