Compare commits

...

3 Commits

Author SHA1 Message Date
Matthieu 8e00c5f5a8 fix(deploy) : seed RBAC system roles during deployment
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m8s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m37s
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.
2026-06-23 16:11:58 +02:00
Matthieu f2d945b0c3 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.
2026-06-23 16:04:02 +02:00
Matthieu 610e99eeb9 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.
2026-06-23 16:03:32 +02:00
11 changed files with 402 additions and 75 deletions
+6
View File
@@ -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)
+31 -1
View File
@@ -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
```
---
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col gap-6 pt-6">
<!-- Formulaire d'ajout / édition -->
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
class="col-span-2"
:label="$t('directory.reports.fields.subject')"
@@ -50,8 +50,8 @@
</p>
</div>
<div v-if="isAdmin" class="flex gap-2">
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
<MalioButtonIcon icon="mdi:pencil-outline" variant="ghost" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:delete-outline" variant="ghost" :aria-label="$t('common.delete')" @click="remove(report.id)" />
</div>
</div>
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
@@ -1,13 +1,13 @@
<template>
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -1,13 +1,13 @@
<template>
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:trash-can-outline"
class="absolute right-2 top-2"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
@@ -6,10 +6,12 @@ import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string }
/**
* Logique partagée des fiches détail Client/Prospect : gestion des blocs
* répétables Contact et Adresse (chargement, ajout, édition par bloc avec
* persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
* (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService()
@@ -17,6 +19,8 @@ export function useDirectoryDetail(owner: Owner) {
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact {
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
@@ -25,54 +29,75 @@ export function useDirectoryDetail(owner: Owner) {
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
}
async function onContactInput(index: number, value: Contact): Promise<void> {
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
contacts.value[index] = value
await persistContact(index)
}
async function persistContact(index: number): Promise<void> {
const c = contacts.value[index]
if (!c) return
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
const created = await contactService.create(payload)
contacts.value[index] = created
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
}
function addContact(): void {
contacts.value.push(emptyContact())
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
// simplement retirée de la liste.
async function removeContact(index: number): Promise<void> {
const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id)
contacts.value.splice(index, 1)
}
async function onAddressInput(index: number, value: Address): Promise<void> {
addresses.value[index] = value
await persistAddress(index)
}
async function persistAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (!a) return
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
const created = await addressService.create(payload)
addresses.value[index] = created
}
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
async function removeAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id)
addresses.value.splice(index, 1)
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
if (savingContacts.value) return
savingContacts.value = true
try {
for (let i = 0; i < contacts.value.length; i++) {
const c = contacts.value[i]
if (!c) continue
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
contacts.value[i] = await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
contacts.value[i] = await contactService.create(payload)
}
}
} finally {
savingContacts.value = false
}
}
async function saveAddresses(): Promise<void> {
if (savingAddresses.value) return
savingAddresses.value = true
try {
for (let i = 0; i < addresses.value.length; i++) {
const a = addresses.value[i]
if (!a) continue
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
addresses.value[i] = await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
addresses.value[i] = await addressService.create(payload)
}
}
} finally {
savingAddresses.value = false
}
}
async function load(): Promise<void> {
contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner)
@@ -81,12 +106,16 @@ export function useDirectoryDetail(owner: Owner) {
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
}
}
@@ -19,13 +19,22 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
@@ -40,13 +49,22 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
@@ -76,12 +94,16 @@ const clientService = useClientService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
@@ -19,13 +19,22 @@
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
@@ -40,13 +49,22 @@
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
@@ -76,12 +94,16 @@ const prospectService = useProspectService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
+3
View File
@@ -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
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Serializer;
use ApiPlatform\Metadata\IriConverterInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Throwable;
use function array_key_exists;
use function is_string;
/**
* Modular monolith: cross-module relations are typed with a Shared\Domain\Contract
* interface (e.g. UserInterface, TaskTagInterface) instead of the concrete entity,
* to keep modules decoupled. Doctrine maps those back to the concrete entity through
* resolve_target_entities.
*
* API Platform denormalizes *single* interface relations fine (the concrete class is
* derived from the IRI), but blows up on *collections*: the collection value type stays
* the interface, which is not a registered API resource, so no normalizer supports it
* and the request fails with NotNormalizableValueException.
*
* This denormalizer bridges that gap for every contract interface, reusing Doctrine's
* resolve_target_entities mapping (no per-entity config):
* - a string value is an IRI -> 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<string, ?class-string> */
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);
}
}
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Shared;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Regression: cross-module to-many relations are typed with a Shared contract
* interface (TaskTagInterface[], UserInterface[]). API Platform cannot
* denormalize a collection whose value type is an interface (no resource
* normalizer supports it), so every POST/PATCH carrying such a collection
* blew up with NotNormalizableValueException.
*
* @internal
*/
final class InterfaceCollectionDenormalizationTest extends WebTestCase
{
protected function tearDown(): void
{
$conn = self::getContainer()->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();
}
}