Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s

## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Command;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -22,7 +22,7 @@ use function sprintf;
class GenerateApiTokenCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
+113 -31
View File
@@ -4,28 +4,32 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\AbsenceBalance;
use App\Entity\AbsencePolicy;
use App\Entity\AbsenceRequest;
use App\Entity\Client;
use App\Entity\MailConfiguration;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskEffort;
use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskRecurrence;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\User;
use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\ContractType;
use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Enum\ContractType;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ProspectStatus;
use App\Module\Integration\Domain\Entity\ZimbraConfiguration;
use App\Module\Mail\Domain\Entity\MailConfiguration;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
use App\Module\ProjectManagement\Domain\Entity\TaskRecurrence;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use App\Module\ProjectManagement\Domain\Entity\Workflow;
use App\Module\ProjectManagement\Domain\Enum\RecurrenceType;
use App\Module\ProjectManagement\Domain\Enum\StatusCategory;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture;
@@ -36,6 +40,7 @@ class AppFixtures extends Fixture
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RbacSeeder $rbacSeeder,
) {}
public function load(ObjectManager $manager): void
@@ -79,29 +84,102 @@ class AppFixtures extends Fixture
$clientLiot->setName('LIOT');
$clientLiot->setEmail('contact@liot.fr');
$clientLiot->setPhone('05 50 50 50 50');
$clientLiot->setStreet('14 allée d\'argenson');
$clientLiot->setCity('Poitiers');
$clientLiot->setPostalCode('86100');
$manager->persist($clientLiot);
$addressLiot = new Address();
$addressLiot->setLabel('Principale');
$addressLiot->setStreet('14 allée d\'argenson');
$addressLiot->setCity('Poitiers');
$addressLiot->setPostalCode('86100');
$addressLiot->setCountry('FR');
$addressLiot->setClient($clientLiot);
$manager->persist($addressLiot);
$clientAcme = new Client();
$clientAcme->setName('ACME Corp');
$clientAcme->setEmail('contact@acme.com');
$clientAcme->setPhone('01 23 45 67 89');
$clientAcme->setStreet('10 rue de la Paix');
$clientAcme->setCity('Paris');
$clientAcme->setPostalCode('75002');
$manager->persist($clientAcme);
$addressAcme = new Address();
$addressAcme->setLabel('Principale');
$addressAcme->setStreet('10 rue de la Paix');
$addressAcme->setCity('Paris');
$addressAcme->setPostalCode('75002');
$addressAcme->setCountry('FR');
$addressAcme->setClient($clientAcme);
$manager->persist($addressAcme);
$clientNova = new Client();
$clientNova->setName('Nova Tech');
$clientNova->setEmail('info@novatech.io');
$clientNova->setPhone('04 56 78 90 12');
$clientNova->setStreet('5 avenue Jean Jaurès');
$clientNova->setCity('Lyon');
$clientNova->setPostalCode('69007');
$manager->persist($clientNova);
$addressNova = new Address();
$addressNova->setLabel('Principale');
$addressNova->setStreet('5 avenue Jean Jaurès');
$addressNova->setCity('Lyon');
$addressNova->setPostalCode('69007');
$addressNova->setCountry('FR');
$addressNova->setClient($clientNova);
$manager->persist($addressNova);
// Prospects
$prospectLead = new Prospect();
$prospectLead->setName('Marie Dupont');
$prospectLead->setCompany('Atelier Dupont');
$prospectLead->setEmail('marie@atelier-dupont.fr');
$prospectLead->setPhone('06 11 22 33 44');
$prospectLead->setStatus(ProspectStatus::New);
$prospectLead->setSource('Site web');
$prospectLead->setNotes('Demande de devis via le formulaire de contact.');
$manager->persist($prospectLead);
$addressLead = new Address();
$addressLead->setLabel('Principale');
$addressLead->setCity('Nantes');
$addressLead->setPostalCode('44000');
$addressLead->setCountry('FR');
$addressLead->setProspect($prospectLead);
$manager->persist($addressLead);
$prospectQualified = new Prospect();
$prospectQualified->setName('Jean Martin');
$prospectQualified->setCompany('Martin & Fils');
$prospectQualified->setEmail('contact@martin-fils.fr');
$prospectQualified->setPhone('07 55 66 77 88');
$prospectQualified->setStatus(ProspectStatus::Qualified);
$prospectQualified->setSource('Salon professionnel');
$manager->persist($prospectQualified);
$addressQualified = new Address();
$addressQualified->setLabel('Principale');
$addressQualified->setStreet('22 rue du Commerce');
$addressQualified->setCity('Bordeaux');
$addressQualified->setPostalCode('33000');
$addressQualified->setCountry('FR');
$addressQualified->setProspect($prospectQualified);
$manager->persist($addressQualified);
$prospectWon = new Prospect();
$prospectWon->setName('Sophie Bernard');
$prospectWon->setCompany('ACME Corp');
$prospectWon->setEmail('contact@acme.com');
$prospectWon->setPhone('01 23 45 67 89');
$prospectWon->setStatus(ProspectStatus::Won);
$prospectWon->setSource('Recommandation');
$prospectWon->setConvertedClient($clientAcme);
$manager->persist($prospectWon);
$addressWon = new Address();
$addressWon->setLabel('Principale');
$addressWon->setCity('Paris');
$addressWon->setPostalCode('75002');
$addressWon->setCountry('FR');
$addressWon->setProspect($prospectWon);
$manager->persist($addressWon);
// Workflow par défaut
$standardWorkflow = new Workflow();
$standardWorkflow->setName('Standard');
@@ -751,5 +829,9 @@ class AppFixtures extends Fixture
$manager->persist($pendingMarriage);
$manager->flush();
// Seed des rôles système RBAC (admin, user). Idempotent ; aucune matrice
// métier attachée (cf. Décision 4 : les modules métier arrivent en 2.x).
$this->rbacSeeder->ensureSystemRoles();
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence;
use App\Shared\Domain\Module\ModuleInterface;
final class AbsenceModule implements ModuleInterface
{
public static function id(): string
{
return 'absence';
}
public static function label(): string
{
return 'Absences';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Absence.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'absence.requests.view', 'label' => 'Voir les demandes d\'absence'],
['code' => 'absence.requests.manage', 'label' => 'Gérer les demandes d\'absence'],
['code' => 'absence.policies.manage', 'label' => 'Gérer les règles d\'absence'],
['code' => 'absence.balances.manage', 'label' => 'Gérer les soldes d\'absence'],
];
}
}
@@ -2,13 +2,14 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\Absence\Application\Service;
use App\Entity\AbsenceBalance;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
@@ -21,21 +22,24 @@ final readonly class AbsenceBalanceService
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceBalanceRepository $balanceRepository,
private AbsenceBalanceRepositoryInterface $balanceRepository,
) {}
/**
* Reference period string for a request: paid leave follows the employee's
* reference period (e.g. "2025-2026"), other types are tracked yearly.
*/
public function periodFor(User $user, AbsenceType $type, DateTimeInterface $date): string
public function periodFor(UserInterface $user, AbsenceType $type, DateTimeInterface $date): string
{
if (AbsenceType::PaidLeave !== $type) {
return $date->format('Y');
}
$year = (int) $date->format('Y');
$startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01"
$year = (int) $date->format('Y');
// The reference-period start (e.g. "06-01") is an HR profile field,
// accessed through the LeaveProfileInterface contract to keep the
// Absence module decoupled from the concrete Core User entity.
$startMonthDay = $user instanceof LeaveProfileInterface ? $user->getReferencePeriodStart() : '01-01';
$currentMonthDay = $date->format('m-d');
$startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1;
@@ -43,7 +47,7 @@ final readonly class AbsenceBalanceService
return sprintf('%d-%d', $startYear, $startYear + 1);
}
public function getOrCreateBalance(User $user, AbsenceType $type, string $period): AbsenceBalance
public function getOrCreateBalance(UserInterface $user, AbsenceType $type, string $period): AbsenceBalance
{
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
@@ -85,7 +89,7 @@ final readonly class AbsenceBalanceService
return null;
}
/** @var User $user */
/** @var UserInterface $user */
$user = $request->getUser();
$period = $this->periodFor($user, $request->getType(), $request->getStartDate());
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
@@ -128,7 +132,7 @@ final readonly class AbsenceBalanceService
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
{
/** @var User $user */
/** @var UserInterface $user */
$user = $request->getUser();
$type = $request->getType();
$period = $this->periodFor($user, $type, $request->getStartDate());
@@ -2,15 +2,19 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\State\AbsenceBalanceProvider;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceBalanceProvider;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -34,21 +38,23 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['absence_balance:read']],
denormalizationContext: ['groups' => ['absence_balance:write']],
)]
#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsenceBalanceRepository::class)]
#[ORM\Table(name: 'absence_balance')]
#[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])]
class AbsenceBalance
class AbsenceBalance implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['absence_balance:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_balance:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_balance:read'])]
@@ -110,12 +116,12 @@ class AbsenceBalance
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
@@ -2,14 +2,17 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -31,11 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['absence_policy:write']],
order: ['type' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsencePolicyRepository::class)]
#[ORM\Table(name: 'absence_policy')]
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
class AbsencePolicy
class AbsencePolicy implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Absence\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
@@ -10,14 +10,15 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceRequestRepository;
use App\State\AbsenceCancelProcessor;
use App\State\AbsenceRequestProcessor;
use App\State\AbsenceRequestProvider;
use App\State\AbsenceReviewProcessor;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceCancelProcessor;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceRequestProcessor;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceRequestProvider;
use App\Module\Absence\Infrastructure\ApiPlatform\State\AbsenceReviewProcessor;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -63,7 +64,7 @@ use Symfony\Component\Validator\Constraints as Assert;
denormalizationContext: ['groups' => ['absence_request:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineAbsenceRequestRepository::class)]
#[ORM\Table(name: 'absence_request')]
class AbsenceRequest
{
@@ -73,10 +74,10 @@ class AbsenceRequest
#[Groups(['absence_request:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['absence_request:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
#[Groups(['absence_request:read', 'absence_request:write'])]
@@ -130,10 +131,10 @@ class AbsenceRequest
#[Groups(['absence_request:read'])]
private ?DateTimeImmutable $reviewedAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['absence_request:read'])]
private ?User $reviewedBy = null;
private ?UserInterface $reviewedBy = null;
#[Groups(['absence_request:read'])]
public function getLabel(): ?string
@@ -156,12 +157,12 @@ class AbsenceRequest
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
@@ -312,12 +313,12 @@ class AbsenceRequest
return $this;
}
public function getReviewedBy(): ?User
public function getReviewedBy(): ?UserInterface
{
return $this->reviewedBy;
}
public function setReviewedBy(?User $reviewedBy): static
public function setReviewedBy(?UserInterface $reviewedBy): static
{
$this->reviewedBy = $reviewedBy;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
enum AbsenceStatus: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
enum AbsenceType: string
{
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Absence\Domain\Enum;
enum HalfDay: string
{
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
interface AbsenceBalanceRepositoryInterface
{
public function findById(int $id): ?AbsenceBalance;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return AbsenceBalance[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
interface AbsencePolicyRepositoryInterface
{
public function findById(int $id): ?AbsencePolicy;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return AbsencePolicy[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
public function findOneByType(AbsenceType $type): ?AbsencePolicy;
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Domain\Repository;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
interface AbsenceRequestRepositoryInterface
{
public function findById(int $id): ?AbsenceRequest;
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range.
*/
public function hasOverlap(
UserInterface $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
): bool;
/**
* Absences (approved or pending) overlapping a date range, all employees.
*
* @return AbsenceRequest[]
*/
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array;
/**
* @return AbsenceRequest[]
*/
public function findFiltered(
?UserInterface $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
?DateTimeInterface $to = null,
): array;
}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\Absence\Domain\Service;
use App\Enum\HalfDay;
use App\Module\Absence\Domain\Enum\HalfDay;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\Absence\Domain\Service;
use DateTimeImmutable;
use DateTimeInterface;
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceBalance;
use App\Entity\User;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,19 +19,19 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceBalanceRepositoryInterface $balanceRepository,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceBalance|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceBalance::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
if (isset($uriVariables['id'])) {
$balance = $repo->find($uriVariables['id']);
$balance = $this->balanceRepository->findById((int) $uriVariables['id']);
if (null === $balance) {
return null;
}
@@ -41,7 +42,8 @@ final readonly class AbsenceBalanceProvider implements ProviderInterface
return $balance;
}
$qb = $repo->createQueryBuilder('b')
$qb = $this->entityManager->getRepository(AbsenceBalance::class)
->createQueryBuilder('b')
->orderBy('b.type', 'ASC')
;
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -50,6 +50,12 @@ final readonly class AbsenceCancelProcessor implements ProcessorInterface
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$status = $data->getStatus();
// Authorize before mutating the balance: an employee may only cancel
// their own request (admins can cancel any).
if (!$isAdmin && $data->getUser() !== $user) {
throw new AccessDeniedHttpException('You can only cancel your own requests.');
}
if (AbsenceStatus::Pending === $status) {
$this->balanceService->release($data, false);
} elseif (AbsenceStatus::Approved === $status) {
@@ -61,11 +67,6 @@ final readonly class AbsenceCancelProcessor implements ProcessorInterface
throw new ConflictHttpException('This request can no longer be cancelled.');
}
// An employee may only cancel their own request (admins can cancel any).
if (!$isAdmin && $data->getUser() !== $user) {
throw new AccessDeniedHttpException('You can only cancel your own requests.');
}
$data->setStatus(AbsenceStatus::Cancelled);
$this->entityManager->flush();
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -32,8 +32,8 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
private EntityManagerInterface $entityManager,
private Security $security,
private AbsenceDayCalculator $calculator,
private AbsencePolicyRepository $policyRepository,
private AbsenceRequestRepository $requestRepository,
private AbsencePolicyRepositoryInterface $policyRepository,
private AbsenceRequestRepositoryInterface $requestRepository,
private AbsenceBalanceService $balanceService,
) {}
@@ -42,7 +42,7 @@ final readonly class AbsenceRequestProcessor implements ProcessorInterface
assert($data instanceof AbsenceRequest);
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$type = $data->getType();
$startDate = $data->getStartDate();
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -18,20 +19,20 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceRequestRepositoryInterface $requestRepository,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest|array|null
{
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$repo = $this->entityManager->getRepository(AbsenceRequest::class);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
// Single item: owner or admin only
if (isset($uriVariables['id'])) {
$request = $repo->find($uriVariables['id']);
$request = $this->requestRepository->findById((int) $uriVariables['id']);
if (null === $request) {
return null;
}
@@ -42,7 +43,8 @@ final readonly class AbsenceRequestProvider implements ProviderInterface
return $request;
}
$qb = $repo->createQueryBuilder('a')
$qb = $this->entityManager->getRepository(AbsenceRequest::class)
->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
;
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -55,7 +55,7 @@ final readonly class AbsenceReviewProcessor implements ProcessorInterface
}
$admin = $this->security->getUser();
assert($admin instanceof User);
assert($admin instanceof UserInterface);
if ($isApprove) {
// Never let an approval push the balance below zero (CP only): the
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\Command;
namespace App\Module\Absence\Infrastructure\Command;
use App\Enum\AbsenceType;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -37,8 +38,8 @@ use function sprintf;
class AccrueLeaveCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly EntityManagerInterface $entityManager,
) {
@@ -88,7 +89,14 @@ class AccrueLeaveCommand extends Command
$skipped = 0;
foreach ($employees as $user) {
$rate = ($user->getAnnualLeaveDays() / 12) * $user->getWorkTimeRatio();
// RH leave profile fields are read through the contract to keep the
// Absence module decoupled from the concrete Core User entity.
$profile = $user instanceof LeaveProfileInterface ? $user : null;
if (null === $profile) {
continue;
}
$rate = ($profile->getAnnualLeaveDays() / 12) * $profile->getWorkTimeRatio();
$period = $this->balanceService->periodFor($user, AbsenceType::PaidLeave, $firstDay);
$balance = $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $period);
@@ -104,7 +112,7 @@ class AccrueLeaveCommand extends Command
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null;
$balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $user->getInitialLeaveBalance(),
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
);
}
@@ -119,7 +127,7 @@ class AccrueLeaveCommand extends Command
$balance->setLastAccruedMonth($monthKey);
++$accrued;
$seeded = $isNew && (null !== self::previousPeriod($period) || $user->getInitialLeaveBalance() > 0);
$seeded = $isNew && (null !== self::previousPeriod($period) || $profile->getInitialLeaveBalance() > 0);
$rows[] = [
$user->getUsername(),
$period,
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceCalendarController extends AbstractController
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
) {}
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
@@ -2,10 +2,9 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\AbsenceRequest;
use Doctrine\ORM\EntityManagerInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -21,7 +20,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class AbsenceJustificationDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -30,7 +29,7 @@ class AbsenceJustificationDownloadController extends AbstractController
#[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
$absence = $this->requestRepository->findById($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
@@ -34,6 +34,7 @@ class AbsenceJustificationUploadController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
@@ -42,7 +43,7 @@ class AbsenceJustificationUploadController extends AbstractController
#[IsGranted('ROLE_USER')]
public function __invoke(int $id, Request $request): JsonResponse
{
$absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id);
$absence = $this->requestRepository->findById($id);
if (null === $absence) {
throw new NotFoundHttpException('Absence request not found.');
}
@@ -2,15 +2,15 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Entity\User;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\AbsencePolicyRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
@@ -30,8 +30,8 @@ class AbsencePreviewController extends AbstractController
public function __construct(
private readonly Security $security,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly AbsenceBalanceService $balanceService,
) {}
@@ -71,7 +71,7 @@ class AbsencePreviewController extends AbstractController
);
$user = $this->security->getUser();
assert($user instanceof User);
assert($user instanceof UserInterface);
$available = null;
$projectedAvailable = null;
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Controller\Absence;
namespace App\Module\Absence\Infrastructure\Controller;
use App\Service\PublicHolidayProvider;
use App\Module\Absence\Domain\Service\PublicHolidayProvider;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceBalance>
*/
class DoctrineAbsenceBalanceRepository extends ServiceEntityRepository implements AbsenceBalanceRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceBalance::class);
}
public function findById(int $id): ?AbsenceBalance
{
return $this->find($id);
}
public function findOneForPeriod(UserInterface $user, AbsenceType $type, string $period): ?AbsenceBalance
{
return $this->findOneBy([
'user' => $user,
'type' => $type,
'period' => $period,
]);
}
}
@@ -2,23 +2,29 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsencePolicy;
use App\Enum\AbsenceType;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsencePolicy>
*/
class AbsencePolicyRepository extends ServiceEntityRepository
class DoctrineAbsencePolicyRepository extends ServiceEntityRepository implements AbsencePolicyRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsencePolicy::class);
}
public function findById(int $id): ?AbsencePolicy
{
return $this->find($id);
}
public function findOneByType(AbsenceType $type): ?AbsencePolicy
{
return $this->findOneBy(['type' => $type]);
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Absence\Infrastructure\Doctrine;
use App\Entity\AbsenceRequest;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -15,20 +16,25 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<AbsenceRequest>
*/
class AbsenceRequestRepository extends ServiceEntityRepository
class DoctrineAbsenceRequestRepository extends ServiceEntityRepository implements AbsenceRequestRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AbsenceRequest::class);
}
public function findById(int $id): ?AbsenceRequest
{
return $this->find($id);
}
/**
* Whether the user already has a PENDING or APPROVED absence that overlaps
* the given date range. Two ranges overlap when start_a <= end_b and
* end_a >= start_b.
*/
public function hasOverlap(
User $user,
UserInterface $user,
DateTimeInterface $startDate,
DateTimeInterface $endDate,
?int $excludeId = null,
@@ -77,7 +83,7 @@ class AbsenceRequestRepository extends ServiceEntityRepository
* @return AbsenceRequest[]
*/
public function findFiltered(
?User $user = null,
?UserInterface $user = null,
?AbsenceStatus $status = null,
?AbsenceType $type = null,
?DateTimeInterface $from = null,
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -21,7 +21,7 @@ class CancelAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
@@ -32,7 +32,7 @@ class CancelAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,18 +2,18 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Entity\AbsenceRequest;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\HalfDay;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Service\AbsenceBalanceService;
use App\Service\AbsenceDayCalculator;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Enum\HalfDay;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Absence\Domain\Service\AbsenceDayCalculator;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -28,9 +28,9 @@ class CreateAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserRepository $userRepository,
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceDayCalculator $calculator,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
@@ -49,7 +49,7 @@ class CreateAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$user = $this->userRepository->find($userId)
$user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
$typeEnum = AbsenceType::tryFrom($type)
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -17,7 +17,7 @@ use function sprintf;
class DeleteAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -28,7 +28,7 @@ class DeleteAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -17,7 +17,7 @@ use function sprintf;
class GetAbsenceRequestTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly Security $security,
) {}
@@ -27,7 +27,7 @@ class GetAbsenceRequestTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Repository\UserRepository;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -19,8 +19,8 @@ use function sprintf;
class ListAbsenceBalancesTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly UserRepository $userRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
@@ -32,7 +32,7 @@ class ListAbsenceBalancesTool
$criteria = [];
if (null !== $userId) {
$user = $this->userRepository->find($userId);
$user = $this->userRepository->findById($userId);
if (null === $user) {
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListAbsencePoliciesTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly Security $security,
) {}
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Repository\UserRepository;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -21,8 +21,8 @@ use function sprintf;
class ListAbsenceRequestsTool
{
public function __construct(
private readonly AbsenceRequestRepository $requestRepository,
private readonly UserRepository $userRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly UserRepositoryInterface $userRepository,
private readonly Security $security,
) {}
@@ -39,7 +39,7 @@ class ListAbsenceRequestsTool
$user = null;
if (null !== $userId) {
$user = $this->userRepository->find($userId)
$user = $this->userRepository->findById($userId)
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
}
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Entity\User;
use App\Enum\AbsenceStatus;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceRequestRepository;
use App\Service\AbsenceBalanceService;
use App\Module\Absence\Application\Service\AbsenceBalanceService;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -24,7 +24,7 @@ class ReviewAbsenceRequestTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AbsenceRequestRepository $requestRepository,
private readonly AbsenceRequestRepositoryInterface $requestRepository,
private readonly AbsenceBalanceService $balanceService,
private readonly Security $security,
) {}
@@ -39,7 +39,7 @@ class ReviewAbsenceRequestTool
throw new InvalidArgumentException('decision must be "approve" or "reject".');
}
$request = $this->requestRepository->find($id);
$request = $this->requestRepository->findById($id);
if (null === $request) {
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
}
@@ -49,7 +49,7 @@ class ReviewAbsenceRequestTool
}
$admin = $this->security->getUser();
assert($admin instanceof User);
assert($admin instanceof UserInterface);
if ('approve' === $decision) {
// Never let an approval push the balance below zero (CP only).
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsenceBalanceRepository;
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsenceBalanceTool
{
public function __construct(
private readonly AbsenceBalanceRepository $balanceRepository,
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -33,7 +33,7 @@ class UpdateAbsenceBalanceTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$balance = $this->balanceRepository->find($id);
$balance = $this->balanceRepository->findById($id);
if (null === $balance) {
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Absence;
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\AbsencePolicyRepository;
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -18,7 +18,7 @@ use function sprintf;
class UpdateAbsencePolicyTool
{
public function __construct(
private readonly AbsencePolicyRepository $policyRepository,
private readonly AbsencePolicyRepositoryInterface $policyRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -36,7 +36,7 @@ class UpdateAbsencePolicyTool
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
}
$policy = $this->policyRepository->find($id);
$policy = $this->policyRepository->findById($id);
if (null === $policy) {
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\DTO;
use DateTimeImmutable;
/**
* DTO de sortie pour une ligne d'audit.
*
* Readonly : aucune mutation possible apres hydration. La resource API
* Platform expose directement ce DTO (pas d'entite sous-jacente car la
* table audit_log n'est pas geree par l'ORM).
*/
final readonly class AuditLogOutput
{
public function __construct(
public string $id,
public string $entityType,
public string $entityId,
public string $action,
/** @var array<string, mixed> */
public array $changes,
public string $performedBy,
public DateTimeImmutable $performedAt,
public ?string $ipAddress,
public ?string $requestId,
) {}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Application\Rbac;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use Doctrine\ORM\EntityManagerInterface;
final readonly class RbacSeeder
{
public function __construct(
private EntityManagerInterface $em,
private RoleRepositoryInterface $roles,
) {}
/**
* Crée les rôles système s'ils sont absents. Idempotent.
*/
public function ensureSystemRoles(): void
{
$this->ensureRole(SystemRoles::ADMIN_CODE, 'Administrateur', 'Accès complet (bypass RBAC).');
$this->ensureRole(SystemRoles::USER_CODE, 'Utilisateur', 'Rôle de base sans permission spécifique.');
$this->em->flush();
}
private function ensureRole(string $code, string $label, string $description): void
{
if (null !== $this->roles->findByCode($code)) {
return;
}
$this->roles->save(new Role($code, $label, $description, true));
}
}
+42
View File
@@ -0,0 +1,42 @@
<?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 RBAC fin du Module Core (1.2).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.users.view', 'label' => 'Voir les utilisateurs'],
['code' => 'core.users.manage', 'label' => 'Gérer les utilisateurs (créer, éditer, supprimer)'],
['code' => 'core.roles.view', 'label' => 'Voir les rôles RBAC'],
['code' => 'core.roles.manage', 'label' => 'Gérer les rôles et permissions'],
['code' => 'core.permissions.view', 'label' => 'Consulter le catalogue des permissions RBAC'],
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
];
}
}
@@ -2,13 +2,14 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Repository\NotificationRepository;
use App\State\NotificationProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\NotificationProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -28,7 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['notification:write']],
order: ['createdAt' => 'DESC'],
)]
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineNotificationRepository::class)]
#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')]
#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')]
class Notification
@@ -39,10 +40,10 @@ class Notification
#[Groups(['notification:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['notification:read'])]
private ?User $user = null;
private ?UserInterface $user = null;
#[ORM\Column(length: 50)]
#[Groups(['notification:read'])]
@@ -69,12 +70,12 @@ class Notification
return $this->id;
}
public function getUser(): ?User
public function getUser(): ?UserInterface
{
return $this->user;
}
public function setUser(?User $user): static
public function setUser(?UserInterface $user): static
{
$this->user = $user;
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
use App\Shared\Domain\Attribute\Auditable;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
#[ORM\Table(name: 'permission')]
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
],
normalizationContext: ['groups' => ['permission:read']],
security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')",
)]
class Permission
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['permission:read', 'role:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, unique: true, options: ['comment' => 'Permission code (module.resource[.sub].action)'])]
#[Groups(['permission:read', 'role:read'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable permission label'])]
#[Groups(['permission:read', 'role:read'])]
private string $label;
#[ORM\Column(length: 100, options: ['comment' => 'Owning module id (e.g. core)'])]
#[Groups(['permission:read', 'role:read'])]
private string $module;
#[ORM\Column(options: ['comment' => 'True when the permission is no longer declared by any active module'])]
#[Groups(['permission:read'])]
private bool $orphan = false;
public function __construct(string $code, string $label, string $module)
{
$code = trim($code);
$label = trim($label);
$module = trim($module);
if ('' === $code || !str_contains($code, '.')) {
throw new InvalidArgumentException(sprintf('Code de permission invalide : "%s" (attendu module.resource.action).', $code));
}
if ('' === $label) {
throw new InvalidArgumentException('Le libellé de permission ne peut pas être vide.');
}
if ('' === $module) {
throw new InvalidArgumentException('Le module de permission ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->module = $module;
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function getModule(): string
{
return $this->module;
}
public function isOrphan(): bool
{
return $this->orphan;
}
public function markOrphan(): void
{
$this->orphan = true;
}
public function revive(string $label, string $module): void
{
$this->orphan = false;
$this->updateMetadata($label, $module);
}
public function updateMetadata(string $label, string $module): void
{
$this->label = $label;
$this->module = $module;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
use App\Shared\Domain\Attribute\Auditable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
#[ORM\Table(name: '`role`')]
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('core.roles.view')", paginationEnabled: false),
new Get(security: "is_granted('core.roles.view')"),
new Post(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Patch(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
new Delete(security: "is_granted('core.roles.manage')", processor: RoleProcessor::class),
],
normalizationContext: ['groups' => ['role:read']],
denormalizationContext: ['groups' => ['role:write']],
)]
class Role
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['role:read'])]
private ?int $id = null;
#[ORM\Column(length: 100, unique: true, options: ['comment' => 'Immutable role code (snake_case)'])]
#[Groups(['role:read', 'role:write'])]
private string $code;
#[ORM\Column(length: 255, options: ['comment' => 'Human-readable role label'])]
#[Groups(['role:read', 'role:write'])]
private string $label;
#[ORM\Column(type: 'text', nullable: true, options: ['comment' => 'Optional role description'])]
#[Groups(['role:read', 'role:write'])]
private ?string $description;
#[ORM\Column(name: 'is_system', options: ['comment' => 'True for built-in roles that cannot be deleted'])]
private bool $isSystem;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'role_permission')]
#[Groups(['role:read', 'role:write'])]
private Collection $permissions;
public function __construct(string $code, string $label, ?string $description = null, bool $isSystem = false)
{
if (1 !== preg_match('/^[a-z][a-z0-9_]*$/', $code)) {
throw new InvalidArgumentException(sprintf('Code de rôle invalide : "%s" (attendu snake_case).', $code));
}
if ('' === trim($label)) {
throw new InvalidArgumentException('Le libellé de rôle ne peut pas être vide.');
}
$this->code = $code;
$this->label = $label;
$this->description = $description;
$this->isSystem = $isSystem;
$this->permissions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getCode(): string
{
return $this->code;
}
public function getLabel(): string
{
return $this->label;
}
public function setLabel(string $label): void
{
$this->label = $label;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
// PropertyInfo strips the `is` prefix and would expose this field as `system`.
// An explicit SerializedName guarantees the `isSystem` key expected by API clients.
#[Groups(['role:read'])]
#[SerializedName('isSystem')]
public function isSystem(): bool
{
return $this->isSystem;
}
/**
* @return Collection<int, Permission>
*/
public function getPermissions(): Collection
{
return $this->permissions;
}
public function addPermission(Permission $permission): void
{
if (!$this->permissions->contains($permission)) {
$this->permissions->add($permission);
}
}
public function removePermission(Permission $permission): void
{
$this->permissions->removeElement($permission);
}
public function ensureDeletable(): void
{
if ($this->isSystem) {
throw new SystemRoleDeletionException($this->code);
}
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
@@ -11,11 +11,18 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
use App\Module\Core\Domain\Enum\ContractType;
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
@@ -30,30 +37,45 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['me:read']],
),
new Get(
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['user:list']],
),
new GetCollection(
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['user:list']],
),
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Get(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
),
new Patch(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
normalizationContext: ['groups' => ['user:rbac:read']],
denormalizationContext: ['groups' => ['user:rbac:write']],
processor: UserRbacProcessor::class,
),
],
denormalizationContext: ['groups' => ['user:write']],
)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface, LeaveProfileInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read', 'commercial_report:read'])]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read', 'commercial_report:read'])]
private ?string $username = null;
#[ORM\Column(length: 100, nullable: true)]
@@ -71,9 +93,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private array $roles = [];
#[ORM\Column]
#[AuditIgnore]
private ?string $password = null;
#[Groups(['user:write'])]
#[AuditIgnore]
private ?string $plainPassword = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
@@ -81,6 +105,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 64, unique: true, nullable: true)]
#[Groups(['me:read'])]
#[AuditIgnore]
private ?string $apiToken = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -134,9 +159,27 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
/**
* @var Collection<int, Role>
*/
#[ORM\ManyToMany(targetEntity: Role::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_role')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $rbacRoles;
/**
* @var Collection<int, Permission>
*/
#[ORM\ManyToMany(targetEntity: Permission::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_permission')]
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
}
public function getId(): ?int
@@ -188,7 +231,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles = $this->roles;
// Every authenticated user gets ROLE_USER.
$roles[] = 'ROLE_USER';
return array_values(array_unique($roles));
@@ -372,4 +417,67 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
/**
* @return Collection<int, Role>
*/
public function getRbacRoles(): Collection
{
return $this->rbacRoles;
}
public function addRbacRole(Role $role): void
{
if (!$this->rbacRoles->contains($role)) {
$this->rbacRoles->add($role);
}
}
public function removeRbacRole(Role $role): void
{
$this->rbacRoles->removeElement($role);
}
/**
* @return Collection<int, Permission>
*/
public function getDirectPermissions(): Collection
{
return $this->directPermissions;
}
public function addDirectPermission(Permission $permission): void
{
if (!$this->directPermissions->contains($permission)) {
$this->directPermissions->add($permission);
}
}
public function removeDirectPermission(Permission $permission): void
{
$this->directPermissions->removeElement($permission);
}
/**
* Permissions effectives = union (rôles RBAC permissions) (permissions directes), triée, dédupliquée.
*
* @return list<string>
*/
#[Groups(['me:read', 'user:rbac:read'])]
public function getEffectivePermissions(): array
{
$codes = [];
foreach ($this->rbacRoles as $role) {
foreach ($role->getPermissions() as $permission) {
$codes[$permission->getCode()] = true;
}
}
foreach ($this->directPermissions as $permission) {
$codes[$permission->getCode()] = true;
}
$keys = array_keys($codes);
sort($keys);
return $keys;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Enum;
namespace App\Module\Core\Domain\Enum;
enum ContractType: string
{
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Exception;
use DomainException;
final class SystemRoleDeletionException extends DomainException
{
public function __construct(string $code)
{
parent::__construct(sprintf('Le rôle système "%s" ne peut pas être supprimé.', $code));
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Permission;
interface PermissionRepositoryInterface
{
public function findById(int $id): ?Permission;
public function findByCode(string $code): ?Permission;
/** @return list<Permission> */
public function findAll(): array;
/** @return list<string> */
public function findAllCodes(): array;
public function save(Permission $permission): void;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Module\Core\Domain\Entity\Role;
interface RoleRepositoryInterface
{
public function findById(int $id): ?Role;
public function findByCode(string $code): ?Role;
/** @return list<Role> */
public function findAll(): array;
public function save(Role $role): void;
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
interface UserRepositoryInterface
{
public function findById(int $id): ?UserInterface;
/**
* @param int[] $ids
*
* @return list<UserInterface>
*/
public function findByIds(array $ids): array;
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Security;
final class SystemRoles
{
public const string ADMIN_CODE = 'admin';
public const string USER_CODE = 'user';
}
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Pagination;
use ApiPlatform\State\Pagination\PaginatorInterface;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
/**
* Paginator pour resources alimentees par DBAL (pas par Doctrine ORM).
*
* Implemente PaginatorInterface : API Platform l'introspecte pour generer
* automatiquement la section `hydra:view` (first / next / previous / last)
* dans la reponse JSON-LD. Aucun calcul manuel de liens.
*
* @template T of object
*
* @implements PaginatorInterface<T>
*/
final readonly class DbalPaginator implements PaginatorInterface, IteratorAggregate
{
/**
* @param list<T> $items Items deja decoupes sur la page courante
* @param int $currentPage Page courante (1-indexee)
* @param int $itemsPerPage Limite appliquee a la requete SQL
* @param int $totalItems Resultat du COUNT(*) sans limite
*/
public function __construct(
private array $items,
private int $currentPage,
private int $itemsPerPage,
private int $totalItems,
) {}
public function getCurrentPage(): float
{
return (float) $this->currentPage;
}
public function getLastPage(): float
{
if ($this->itemsPerPage <= 0) {
return 1.0;
}
return (float) max(1, (int) ceil($this->totalItems / $this->itemsPerPage));
}
public function getItemsPerPage(): float
{
return (float) $this->itemsPerPage;
}
public function getTotalItems(): float
{
return (float) $this->totalItems;
}
public function count(): int
{
return count($this->items);
}
/**
* @return Traversable<int, T>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
/**
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
* d'audit). La liste evolue automatiquement avec les nouvelles entites
* `#[Auditable]` au fil des ecritures.
*/
#[ApiResource(
shortName: 'AuditLogEntityTypes',
operations: [
new Get(
uriTemplate: '/audit-log-entity-types',
security: "is_granted('core.audit_log.view')",
provider: AuditLogEntityTypesProvider::class,
),
],
)]
final class AuditLogEntityTypesResource
{
/** @param list<string> $entityTypes */
public function __construct(
public readonly string $id = 'entity-types',
public readonly array $entityTypes = [],
) {}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
/**
* Resource API Platform en lecture seule sur le journal d'audit.
*
* Aucune operation d'ecriture exposee (POST/PUT/PATCH/DELETE -> 405)
* conformement au caractere append-only de la table `audit_log`.
*
* La resource est un simple porteur de metadonnees #[ApiResource] ; le
* provider lit via DBAL et retourne directement des instances du DTO
* `AuditLogOutput` (declare via `output:`). La table n'est pas geree par
* l'ORM : aucune entite Doctrine n'est necessaire ici.
*
* Filtres query-param supportes par le provider :
* ?entity_type=core.User
* ?entity_id=42
* ?action=update
* ?performed_by=admin
* ?performed_at[after]=2026-04-01T00:00:00Z
* ?performed_at[before]=2026-04-30T23:59:59Z
*
* La pagination herite du standard global (10 items / page, max 50, cf.
* `config/packages/api_platform.yaml`). Elle est materialisee par le
* DbalPaginator du provider qui implemente PaginatorInterface — API Platform
* genere automatiquement hydra:view sans construction manuelle.
*/
#[ApiResource(
shortName: 'AuditLog',
operations: [
new GetCollection(
uriTemplate: '/audit-logs',
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
new Get(
uriTemplate: '/audit-logs/{id}',
requirements: ['id' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'],
security: "is_granted('core.audit_log.view')",
provider: AuditLogProvider::class,
),
],
output: AuditLogOutput::class,
)]
final class AuditLogResource {}
@@ -2,12 +2,13 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
/**
* @implements ProviderInterface<User>
@@ -20,7 +21,11 @@ final readonly class MeProvider implements ProviderInterface
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
{
// @var User $user
return $this->security->getUser();
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new UnauthorizedHttpException('Bearer', 'Not authenticated.');
}
return $user;
}
}
@@ -2,14 +2,14 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Notification;
use App\Repository\NotificationRepository;
use App\Module\Core\Domain\Entity\Notification;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use ArrayIterator;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
@@ -23,7 +23,7 @@ final readonly class NotificationProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private NotificationRepository $notificationRepository,
private DoctrineNotificationRepository $notificationRepository,
private Pagination $pagination,
) {}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\Role;
use Doctrine\ORM\EntityManagerInterface;
use DomainException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<Role, null|Role>
*/
final readonly class RoleProcessor implements ProcessorInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?Role
{
assert($data instanceof Role);
if ($operation instanceof DeleteOperationInterface) {
try {
$data->ensureDeletable();
} catch (DomainException $e) {
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->em->remove($data);
$this->em->flush();
return null;
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use function assert;
/**
* @implements ProcessorInterface<User, User>
*/
final readonly class UserRbacProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): User
{
assert($data instanceof User);
// Defense-in-depth: a user may never edit their OWN RBAC assignment
// through this endpoint, even with core.users.manage — this prevents
// self-escalation if the permission is ever delegated to a non-admin.
$current = $this->security->getUser();
if ($current instanceof User && $current->getId() === $data->getId()) {
throw new AccessDeniedHttpException('You cannot edit your own RBAC assignment.');
}
$this->em->persist($data);
$this->em->flush();
return $data;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
use Doctrine\DBAL\Connection;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
*
* @implements ProviderInterface<AuditLogEntityTypesResource>
*/
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
{
/** @var list<string> $types */
$types = $this->connection
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
->fetchFirstColumn()
;
return new AuditLogEntityTypesResource(entityTypes: $types);
}
}
@@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface;
use App\Module\Core\Application\DTO\AuditLogOutput;
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Provider API Platform pour la resource AuditLog.
*
* Lit la table `audit_log` via DBAL (pas d'entite ORM). Retourne soit :
* - une instance unique d'AuditLogOutput (operation Get) ;
* - un DbalPaginator de AuditLogOutput (operation GetCollection).
*
* Le paginator implementant PaginatorInterface laisse API Platform generer
* automatiquement la section `hydra:view` : aucune manipulation manuelle.
*
* Connexion DBAL : `default` (lecture — aucun besoin de la connexion `audit`
* reservee a l'ecriture hors transaction ORM).
*/
final readonly class AuditLogProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'doctrine.dbal.default_connection')]
private Connection $connection,
private Pagination $pagination,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogOutput|DbalPaginator|null
{
if (!$operation instanceof CollectionOperationInterface) {
return $this->provideItem((string) $uriVariables['id']);
}
return $this->provideCollection($operation, $context);
}
private function provideItem(string $id): ?AuditLogOutput
{
/** @var array<string, mixed>|false $row */
$row = $this->connection->fetchAssociative(
'SELECT id, entity_type, entity_id, action, changes, performed_by, performed_at, ip_address, request_id
FROM audit_log WHERE id = :id',
['id' => $id],
);
if (false === $row) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $context
*/
private function provideCollection(Operation $operation, array $context): DbalPaginator
{
// Contrairement aux ressources ORM (cf. CategoryProvider), ce provider
// ne gere PAS l'echappatoire `?pagination=false` : la pagination y est
// toujours forcee. `audit_log` est une table append-only a croissance
// infinie — la dumper entierement saturerait memoire/reseau et n'a aucun
// usage front (pas de <select> alimente par l'audit). Le flag global
// `pagination_client_enabled: true` reste donc volontairement inerte ici.
//
// `page` brut peut etre <= 0 (parametre client) → OFFSET negatif → 500 PG
// (`SQLSTATE[22023] OFFSET must not be negative`). API Platform clampe
// `itemsPerPage` au max de la resource mais pas `page` ; on impose un
// minimum a 1 cote provider.
$page = max(1, $this->pagination->getPage($context));
$itemsPerPage = $this->pagination->getLimit($operation, $context);
$offset = ($page - 1) * $itemsPerPage;
$filters = $this->extractFilters($context['filters'] ?? []);
$dataQuery = $this->buildBaseQuery()
->select('id', 'entity_type', 'entity_id', 'action', 'changes', 'performed_by', 'performed_at', 'ip_address', 'request_id')
->orderBy('performed_at', 'DESC')
// Tie-breaker sur `id` (UUID v7 monotone) : garantit un tri
// totalement deterministe quand plusieurs lignes partagent la
// meme timestamp (ex: batch fixture, bulk flush < 1µs).
->addOrderBy('id', 'DESC')
->setFirstResult($offset)
->setMaxResults($itemsPerPage)
;
$countQuery = $this->buildBaseQuery()->select('COUNT(*)');
$this->applyFilters($dataQuery, $filters);
$this->applyFilters($countQuery, $filters);
/** @var list<array<string, mixed>> $rows */
$rows = $dataQuery->executeQuery()->fetchAllAssociative();
$totalItems = (int) $countQuery->executeQuery()->fetchOne();
$items = array_map(fn (array $row) => $this->hydrate($row), $rows);
return new DbalPaginator($items, $page, $itemsPerPage, $totalItems);
}
private function buildBaseQuery(): QueryBuilder
{
return $this->connection->createQueryBuilder()->from('audit_log');
}
/**
* @param array<string, mixed> $raw
*
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
*/
private function extractFilters(array $raw): array
{
$filters = [];
// `entity_type` accepte soit une chaine, soit une liste (query syntax
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
// multi-selection cote front. On normalise en list<string> non-vide.
if (isset($raw['entity_type'])) {
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
$filters['entity_type'] = $raw['entity_type'];
} elseif (is_array($raw['entity_type'])) {
$cleaned = array_values(array_filter(
$raw['entity_type'],
static fn ($v): bool => is_string($v) && '' !== $v,
));
if ([] !== $cleaned) {
$filters['entity_type'] = $cleaned;
}
}
}
foreach (['entity_id', 'performed_by'] as $key) {
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
$filters[$key] = $raw[$key];
}
}
// `action` : whitelist stricte. Un input hors-liste provoquait avant
// un simple match vide (resultat 0 ligne) mais permettait d'incrementer
// le log applicatif a chaque variation ; on rejette en 400 explicite.
if (isset($raw['action']) && is_string($raw['action']) && '' !== $raw['action']) {
if (!in_array($raw['action'], ['create', 'update', 'delete'], true)) {
throw new BadRequestHttpException(
'Filtre "action" invalide : valeurs autorisees create|update|delete.',
);
}
$filters['action'] = $raw['action'];
}
// Filtres de plage `performed_at[after]` / `performed_at[before]`.
// Sans validation, un input malforme remonte jusqu'a Postgres qui
// leve `SQLSTATE[22007]: invalid input syntax for type timestamp` →
// 500 Internal Server Error, log Monolog pollue, mauvaise UX API.
// On valide en amont et on rejette en 400 explicite.
if (isset($raw['performed_at']) && is_array($raw['performed_at'])) {
$range = $raw['performed_at'];
foreach (['after', 'before'] as $bound) {
if (!isset($range[$bound]) || !is_string($range[$bound]) || '' === $range[$bound]) {
continue;
}
if (false === strtotime($range[$bound])) {
throw new BadRequestHttpException(sprintf(
'Filtre "performed_at[%s]" invalide : date ISO 8601 attendue (ex: 2026-04-22T00:00:00Z).',
$bound,
));
}
$filters['performed_at_'.$bound] = $range[$bound];
}
}
return $filters;
}
/**
* @param array<string, list<string>|string> $filters
*/
private function applyFilters(QueryBuilder $qb, array $filters): void
{
if (isset($filters['entity_type'])) {
if (is_array($filters['entity_type'])) {
$qb->andWhere('entity_type IN (:entity_types)')
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
;
} else {
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
}
}
if (isset($filters['entity_id'])) {
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
}
if (isset($filters['action'])) {
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
}
if (isset($filters['performed_by'])) {
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
// matche n'importe quel caractere). Pas de clause ESCAPE : `\` est
// deja le caractere d'echappement LIKE par defaut en PostgreSQL.
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
$qb->andWhere('performed_by ILIKE :performed_by')
->setParameter('performed_by', '%'.$escaped.'%')
;
}
if (isset($filters['performed_at_after'])) {
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
}
if (isset($filters['performed_at_before'])) {
$qb->andWhere('performed_at <= :performed_at_before')->setParameter('performed_at_before', $filters['performed_at_before']);
}
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): AuditLogOutput
{
/** @var string $rawChanges */
$rawChanges = $row['changes'] ?? '{}';
/** @var array<string, mixed> $changes */
$changes = is_array($rawChanges) ? $rawChanges : json_decode((string) $rawChanges, true, 512, JSON_THROW_ON_ERROR);
return new AuditLogOutput(
id: (string) $row['id'],
entityType: (string) $row['entity_type'],
entityId: (string) $row['entity_id'],
action: (string) $row['action'],
changes: $changes,
performedBy: (string) $row['performed_by'],
performedAt: new DateTimeImmutable((string) $row['performed_at']),
ipAddress: null !== $row['ip_address'] ? (string) $row['ip_address'] : null,
requestId: null !== $row['request_id'] ? (string) $row['request_id'] : null,
);
}
}
@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -0,0 +1,94 @@
<?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;
}
}
@@ -0,0 +1,33 @@
<?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;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Application\Rbac\RbacSeeder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:seed-rbac', description: 'Seed les rôles système RBAC (admin, user).')]
final class SeedRbacCommand extends Command
{
public function __construct(private readonly RbacSeeder $seeder)
{
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$this->seeder->ensureSystemRoles();
$io->success('Rôles système RBAC seedés (admin, user).');
return Command::SUCCESS;
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Console;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use function count;
#[AsCommand(name: 'app:sync-permissions', description: 'Synchronise le catalogue des permissions depuis les modules actifs.')]
final class SyncPermissionsCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PermissionRepositoryInterface $permissions,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
// Phase 1 : permissions désirées (code => {code,label,module}).
$desired = [];
foreach (ModuleRegistry::permissions($moduleClasses) as $perm) {
$desired[$perm['code']] = $perm;
}
// Phase 2 : upsert.
$existing = [];
foreach ($this->permissions->findAll() as $permission) {
$existing[$permission->getCode()] = $permission;
}
$added = $updated = $revived = 0;
foreach ($desired as $code => $perm) {
$entity = $existing[$code] ?? null;
if (null === $entity) {
$this->permissions->save(new Permission($perm['code'], $perm['label'], $perm['module']));
++$added;
continue;
}
if ($entity->isOrphan()) {
$entity->revive($perm['label'], $perm['module']);
++$revived;
} elseif ($entity->getLabel() !== $perm['label'] || $entity->getModule() !== $perm['module']) {
$entity->updateMetadata($perm['label'], $perm['module']);
++$updated;
}
}
// Phase 3 : orphelines (existantes absentes des désirées).
$orphaned = 0;
foreach ($existing as $code => $entity) {
if (!isset($desired[$code]) && !$entity->isOrphan()) {
$entity->markOrphan();
++$orphaned;
}
}
$this->em->flush();
$io->success(sprintf('Permissions synchronisées : %d ajoutées, %d mises à jour, %d réactivées, %d orphelines. Total désirées : %d.', $added, $updated, $revived, $orphaned, count($desired)));
return Command::SUCCESS;
}
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\Core\Infrastructure\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class MarkAllReadController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly DoctrineNotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): Response
{
/** @var User $user */
/** @var UserInterface $user */
$user = $this->getUser();
$this->notificationRepository->markAllReadByUser($user);
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\Core\Infrastructure\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineNotificationRepository;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
@@ -14,14 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationUnreadCountController extends AbstractController
{
public function __construct(
private readonly NotificationRepository $notificationRepository,
private readonly DoctrineNotificationRepository $notificationRepository,
) {}
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(): JsonResponse
{
/** @var User $user */
/** @var UserInterface $user */
$user = $this->getUser();
$count = $this->notificationRepository->countUnreadByUser($user);
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\Core\Infrastructure\Controller;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\Core\Infrastructure\Controller;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Infrastructure\Audit\AuditLogWriter;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use ReflectionProperty;
use Throwable;
/**
* Listener Doctrine qui produit les lignes d'audit pour les entites portant
* l'attribut #[Auditable].
*
* Pipeline en deux temps :
* 1. onFlush : on traverse UnitOfWork (insertions / updates / deletions) et
* on capture les changements en memoire. Aucune ecriture SQL cote audit
* a ce stade pour ne pas interferer avec la transaction ORM en cours.
* 2. postFlush : on ecrit via AuditLogWriter (connexion DBAL dediee).
*
* Pattern swap-and-clear dans postFlush :
* - on copie localement la liste des evenements ;
* - on vide la propriete pendingLogs immediatement ;
* - on itere la copie.
* Pourquoi : si une ecriture audit declenchait un flush re-entrant (cas rare,
* ex: callback listener externe), l'etat de pendingLogs serait deja nettoye —
* pas de double insertion, pas de boucle infinie.
*
* Erreurs silencieuses : un INSERT audit qui echoue est logue en error mais
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
* de garantie forte (dead-letter queue, retry).
*
* Collections (OneToMany / ManyToMany) :
* - Les modifications de collections sont tracees via
* `getScheduledCollectionUpdates()` et reportees comme un changement
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
* l'entite proprietaire.
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
* - Si l'entite proprietaire est scheduled pour deletion, les collections
* associees sont ignorees (deja couvertes par le snapshot delete).
*
* Limitations connues :
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
* operation de purge/nettoyage qui doit etre auditee doit passer par
* `EntityManager::remove()` + `flush()`. Si un futur batch (ex: commande
* "purger users inactifs") utilise du DQL bulk, les suppressions ne
* seront pas dans `audit_log` — choix d'architecture explicite a faire.
*/
#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
final class AuditListener
{
/**
* Cache par FQCN : true si la classe porte #[Auditable], false sinon.
* Evite une ReflectionClass par entite a chaque flush.
*
* @var array<class-string, bool>
*/
private array $auditableCache = [];
/**
* Cache par FQCN : liste des noms de proprietes ignorees (#[AuditIgnore]).
*
* @var array<class-string, list<string>>
*/
private array $ignoredPropertiesCache = [];
/**
* Logs en attente d'ecriture (remplis en onFlush, consommes en postFlush).
*
* Pour les inserts, l'ID est assignee DURANT le flush : on capture la
* reference de l'entite et on resout l'ID au moment du postFlush.
*
* @var list<array{entity: object, metadata: ClassMetadata, entityType: string, action: string, changes: array<string, mixed>, capturedId: ?string}>
*/
private array $pendingLogs = [];
public function __construct(
private readonly AuditLogWriter $writer,
private readonly LoggerInterface $logger,
) {}
public function onFlush(OnFlushEventArgs $args): void
{
/** @var EntityManagerInterface $em */
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
// Reset defensif en debut de cycle : si un flush precedent a leve une
// exception, Doctrine n'appelle PAS postFlush et pendingLogs reste
// rempli avec des changements jamais committes. Sans ce reset, un
// flush ulterieur reussi ecrirait les fausses entrees dans audit_log.
// Le swap-and-clear dans postFlush couvre deja les flushes re-entrants,
// ce reset ne le fragilise donc pas.
$this->pendingLogs = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'create');
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'update');
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$this->capturePendingLog($entity, $em, $uow, 'delete');
}
// Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
// ne les expose pas, il faut interroger `UnitOfWork` separement. On
// merge la diff dans le log de l'entite proprietaire si elle est deja
// scheduled, sinon on cree une entree "update" dediee.
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: false);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->captureCollectionChange($collection, $em, cleared: true);
}
}
public function postFlush(PostFlushEventArgs $args): void
{
// Swap-and-clear : protege d'un flush re-entrant (aucune double
// insertion meme si un callback utilisateur re-declenche un flush).
$logs = $this->pendingLogs;
$this->pendingLogs = [];
foreach ($logs as $log) {
// Pour les inserts, l'ID n'etait pas encore disponible en onFlush :
// on la resout maintenant (Doctrine l'a hydratee pendant le flush).
$entityId = $log['capturedId'] ?? $this->resolveEntityId($log['entity'], $log['metadata']);
if (null === $entityId) {
$this->logger->warning(
'AuditListener : impossible de resoudre l\'ID de l\'entite apres flush, entree ignoree',
['entityType' => $log['entityType'], 'action' => $log['action']]
);
continue;
}
try {
$this->writer->log(
$log['entityType'],
$entityId,
$log['action'],
$log['changes'],
);
} catch (Throwable $e) {
// Erreur audit : logue mais ne crashe jamais le flux metier.
$this->logger->error(
'Echec d\'ecriture audit_log',
[
'exception' => $e,
'entityType' => $log['entityType'],
'entityId' => $entityId,
'action' => $log['action'],
]
);
}
}
}
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
{
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
// proxy Doctrine pour une entite chargee en lazy (ex:
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
// sur la classe parente.
$metadata = $em->getClassMetadata($entity::class);
$class = $metadata->getName();
if (!$this->isAuditable($class)) {
return;
}
// Sur `delete`, on inclut aussi les collections to-many dans le
// snapshot : c'est la derniere occasion de capturer l'etat complet
// (ex: quelles permissions etaient rattachees au role supprime).
// Sur `create`, les collections initiales sont rapportees via
// captureCollectionChange quand l'entite est scheduled avec un
// collection update dans le meme flush.
$changes = match ($action) {
'update' => $this->buildUpdateChanges($entity, $uow, $class),
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
default => [],
};
if ('update' === $action && [] === $changes) {
// Flush sans changement reel sur une entite auditable : on n'emet pas.
return;
}
// Pour delete/update, l'ID est deja set en onFlush — on la capture
// maintenant (apres postFlush, l'entite detachee peut perdre sa ref
// dans l'identity map). Pour create (IDENTITY), l'ID est generee par
// le flush — on differe a postFlush.
$capturedId = 'create' === $action ? null : $this->resolveEntityId($entity, $metadata);
$this->pendingLogs[] = [
'entity' => $entity,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => $action,
'changes' => $changes,
'capturedId' => $capturedId,
];
}
/**
* Capture la modification d'une collection to-many.
*
* Strategie de merge :
* - Si l'entite proprietaire est deja scheduled pour `delete` → ignore
* (redondant avec le snapshot delete deja produit).
* - Si l'entite est deja scheduled pour `create` → on ajoute le champ
* collection au snapshot initial, sous forme de liste d'IDs ajoutes.
* - Si l'entite est deja scheduled pour `update` → on merge la diff
* {added, removed} dans le changeset existant.
* - Sinon → on cree une nouvelle entree `update` dediee pour l'entite
* proprietaire (cas d'une collection modifiee sans autre changement
* sur l'entite elle-meme, ex : ajout d'une permission a un role).
*
* @param bool $cleared true si la collection entiere est supprimee
* (getScheduledCollectionDeletions) — tous les
* items du snapshot sont consideres comme retires
*/
private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
{
$owner = $collection->getOwner();
if (null === $owner) {
return;
}
// Voir capturePendingLog : meme contournement proxy Doctrine.
$class = $em->getClassMetadata($owner::class)->getName();
if (!$this->isAuditable($class)) {
return;
}
$fieldName = $collection->getMapping()->fieldName;
if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
return;
}
if ($cleared) {
$added = [];
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getSnapshot(),
);
} else {
$added = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getInsertDiff(),
);
$removed = array_map(
fn ($item): mixed => $this->normalizeValue($item),
$collection->getDeleteDiff(),
);
}
if ([] === $added && [] === $removed) {
return;
}
// Chercher un log deja en attente pour cette entite, pour merger la
// diff au lieu de creer une entree d'audit redondante.
foreach ($this->pendingLogs as $idx => $log) {
if ($log['entity'] !== $owner) {
continue;
}
if ('delete' === $log['action']) {
// Deletion de l'entite : la collection suit mecaniquement,
// pas d'entree dediee (le snapshot delete contient deja
// l'etat a supprimer).
return;
}
if ('create' === $log['action']) {
// Insertion : le snapshot create ne contient pas les
// collections (buildSnapshot ignore les to-many). On ajoute
// donc la liste des items initiaux comme IDs, pour avoir
// une trace complete de l'etat a la creation. array_values
// garantit un array JSON (pas un objet) si les cles du diff
// ne sont pas sequentielles.
$this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);
return;
}
// Update : on merge dans le changeset existant.
$this->pendingLogs[$idx]['changes'][$fieldName] = [
'added' => array_values($added),
'removed' => array_values($removed),
];
return;
}
// Aucun log existant : l'entite n'a eu QUE des changements de
// collection. On cree une entree update minimale.
$metadata = $em->getClassMetadata($class);
$this->pendingLogs[] = [
'entity' => $owner,
'metadata' => $metadata,
'entityType' => $this->formatEntityType($class),
'action' => 'update',
'changes' => [$fieldName => [
'added' => array_values($added),
'removed' => array_values($removed),
]],
'capturedId' => $this->resolveEntityId($owner, $metadata),
];
}
/**
* Build du changeset "update" : {champ: {old, new}} a partir de
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
* null-safe via `?->getId()`.
*
* @return array<string, array{old: mixed, new: mixed}>
*/
private function buildUpdateChanges(object $entity, UnitOfWork $uow, string $class): array
{
$changeSet = $uow->getEntityChangeSet($entity);
$ignored = $this->getIgnoredProperties($class);
$filteredChanges = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if (in_array($field, $ignored, true)) {
continue;
}
$filteredChanges[$field] = [
'old' => $this->normalizeValue($oldValue),
'new' => $this->normalizeValue($newValue),
];
}
return $filteredChanges;
}
/**
* Build d'un snapshot complet (create / delete) : lit toutes les
* proprietes non-ignorees via Reflection.
*
* @param bool $includeCollections si true, les associations to-many sont
* aussi snapshotees (liste d'IDs). Utilise
* uniquement sur `delete` pour preserver
* l'etat des relations au moment de la
* suppression. En create, on laisse
* captureCollectionChange enrichir le
* snapshot si une collection est modifiee
* dans le meme flush.
*
* @return array<string, mixed>
*/
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
{
$ignored = $this->getIgnoredProperties($class);
$snapshot = [];
foreach ($metadata->getFieldNames() as $field) {
if (in_array($field, $ignored, true)) {
continue;
}
$snapshot[$field] = $this->normalizeValue($metadata->getFieldValue($entity, $field));
}
foreach ($metadata->getAssociationNames() as $assoc) {
if (in_array($assoc, $ignored, true)) {
continue;
}
if ($metadata->isSingleValuedAssociation($assoc)) {
$related = $metadata->getFieldValue($entity, $assoc);
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
? $related->getId()
: null;
continue;
}
if (!$includeCollections) {
continue;
}
// Collection to-many : snapshot = liste d'IDs. On itere la
// Collection (PersistentCollection ou ArrayCollection) pour
// obtenir les elements. Pour un delete, la collection est deja
// chargee (Doctrine en a besoin pour les cascades).
$collection = $metadata->getFieldValue($entity, $assoc);
if (!is_iterable($collection)) {
continue;
}
$ids = [];
foreach ($collection as $item) {
$ids[] = $this->normalizeValue($item);
}
$snapshot[$assoc] = $ids;
}
return $snapshot;
}
private function isAuditable(string $class): bool
{
if (array_key_exists($class, $this->auditableCache)) {
return $this->auditableCache[$class];
}
$reflection = new ReflectionClass($class);
$isAuditable = [] !== $reflection->getAttributes(Auditable::class);
$this->auditableCache[$class] = $isAuditable;
return $isAuditable;
}
/**
* @return list<string>
*/
private function getIgnoredProperties(string $class): array
{
if (array_key_exists($class, $this->ignoredPropertiesCache)) {
return $this->ignoredPropertiesCache[$class];
}
$ignored = [];
$reflection = new ReflectionClass($class);
foreach ($reflection->getProperties(ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE | ReflectionProperty::IS_PUBLIC) as $property) {
if ([] !== $property->getAttributes(AuditIgnore::class)) {
$ignored[] = $property->getName();
}
}
$this->ignoredPropertiesCache[$class] = $ignored;
return $ignored;
}
/**
* Transforme un FQCN `App\Module\Core\Domain\Entity\User` en `core.User`.
*
* Format `module.Entity` pour eviter les collisions inter-modules.
*/
private function formatEntityType(string $class): string
{
if (1 === preg_match('#^App\\\Module\\\(?<module>[^\\\]+)\\\.+\\\(?<entity>[^\\\]+)$#', $class, $matches)) {
return strtolower($matches['module']).'.'.$matches['entity'];
}
// Fallback : on retourne le FQCN complet si la regex ne matche pas
// (entite hors structure modulaire — ne devrait pas arriver).
return $class;
}
private function resolveEntityId(object $entity, ClassMetadata $metadata): ?string
{
$identifier = $metadata->getIdentifierValues($entity);
if ([] === $identifier) {
return null;
}
// Cle composee : on concatene les valeurs. Cas rare sur le projet.
return implode('-', array_map(static fn ($v) => (string) $v, $identifier));
}
/**
* Normalise une valeur pour encodage JSON stable.
*/
private function normalizeValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if (is_object($value)) {
// Relation to-one non parsee par buildSnapshot (cas update sur
// un champ qui devient un objet) : on tente getId() si possible.
if (method_exists($value, 'getId')) {
return $value->getId();
}
return (string) $value;
}
return $value;
}
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Entity\Notification;
use App\Entity\User;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
@@ -14,7 +14,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
class DoctrineNotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
@@ -30,7 +30,7 @@ class NotificationRepository extends ServiceEntityRepository
;
}
public function countUnreadByUser(User $user): int
public function countUnreadByUser(SharedUserInterface $user): int
{
return (int) $this->createQueryBuilder('n')
->select('COUNT(n.id)')
@@ -42,7 +42,7 @@ class NotificationRepository extends ServiceEntityRepository
;
}
public function markAllReadByUser(User $user): int
public function markAllReadByUser(SharedUserInterface $user): int
{
return $this->createQueryBuilder('n')
->update()
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Repository\PermissionRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Permission>
*/
final class DoctrinePermissionRepository extends ServiceEntityRepository implements PermissionRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Permission::class);
}
public function findById(int $id): ?Permission
{
return $this->find($id);
}
public function findByCode(string $code): ?Permission
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Permission> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
/** @return list<string> */
public function findAllCodes(): array
{
/** @var list<array{code: string}> $rows */
$rows = $this->createQueryBuilder('p')->select('p.code')->getQuery()->getArrayResult();
return array_map(static fn (array $r): string => $r['code'], $rows);
}
public function save(Permission $permission): void
{
$this->getEntityManager()->persist($permission);
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Role>
*/
final class DoctrineRoleRepository extends ServiceEntityRepository implements RoleRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Role::class);
}
public function findById(int $id): ?Role
{
return $this->find($id);
}
public function findByCode(string $code): ?Role
{
return $this->findOneBy(['code' => $code]);
}
/** @return list<Role> */
public function findAll(): array
{
return array_values($this->findBy([]));
}
public function save(Role $role): void
{
$this->getEntityManager()->persist($role);
}
}
@@ -2,9 +2,11 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\Core\Infrastructure\Doctrine;
use App\Entity\User;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\UserRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -12,15 +14,39 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function findById(int $id): ?UserInterface
{
return $this->find($id);
}
/**
* @return User[]
* @param int[] $ids
*
* @return list<UserInterface>
*/
public function findByIds(array $ids): array
{
if ([] === $ids) {
return [];
}
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array
{
@@ -43,7 +69,7 @@ class UserRepository extends ServiceEntityRepository
/**
* Employees active on the given date (hired on/before it, not yet left).
*
* @return User[]
* @return list<UserInterface>
*/
public function findActiveEmployees(DateTimeInterface $date): array
{
@@ -59,4 +85,9 @@ class UserRepository extends ServiceEntityRepository
->getResult()
;
}
public function findOneByUsername(string $username): ?UserInterface
{
return $this->findOneBy(['username' => $username]);
}
}
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
namespace App\Module\Core\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Infrastructure\Mcp\Serializer;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -17,7 +17,7 @@ use function sprintf;
class GetUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
namespace App\Module\Core\Infrastructure\Mcp\Tool;
use App\Repository\UserRepository;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
@@ -13,7 +13,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListUsersTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly Security $security,
) {}
@@ -2,11 +2,11 @@
declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
namespace App\Module\Core\Infrastructure\Mcp\Tool;
use App\Enum\ContractType;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use App\Module\Core\Domain\Enum\ContractType;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Infrastructure\Mcp\Serializer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -20,7 +20,7 @@ use function sprintf;
class UpdateUserTool
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly DoctrineUserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -0,0 +1,33 @@
<?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 DateTimeImmutable;
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);
$notification->setCreatedAt(new DateTimeImmutable());
$this->em->persist($notification);
$this->em->flush();
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Security;
use App\Module\Core\Domain\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* @extends Voter<string, mixed>
*/
final class PermissionVoter extends Voter
{
private const string PATTERN = '/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/';
protected function supports(string $attribute, mixed $subject): bool
{
return 1 === preg_match(self::PATTERN, $attribute);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
// ROLE_ADMIN = bypass total (cf. Décision 1).
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
return in_array($attribute, $user->getEffectivePermissions(), true);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory;
use App\Shared\Domain\Module\ModuleInterface;
final class DirectoryModule implements ModuleInterface
{
public static function id(): string
{
return 'directory';
}
public static function label(): string
{
return 'Répertoire';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module Directory.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER/ROLE_ADMIN (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'directory.clients.view', 'label' => 'Voir les clients'],
['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'],
['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'],
['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'],
];
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']],
order: ['id' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)]
#[ORM\Table(name: 'directory_address')]
class Address implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['address:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private ?string $label = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private ?string $streetComplement = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private ?string $postalCode = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private ?string $city = null;
#[ORM\Column(length: 2)]
#[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])]
private string $country = 'FR';
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['address:read', 'address:write'])]
private ?Client $client = null;
#[ORM\ManyToOne(targetEntity: Prospect::class)]
#[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['address:read', 'address:write'])]
private ?Prospect $prospect = null;
public function getId(): ?int
{
return $this->id;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getStreetComplement(): ?string
{
return $this->streetComplement;
}
public function setStreetComplement(?string $streetComplement): static
{
$this->streetComplement = $streetComplement;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getCountry(): string
{
return $this->country;
}
public function setCountry(string $country): static
{
$this->country = $country;
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function getProspect(): ?Prospect
{
return $this->prospect;
}
public function setProspect(?Prospect $prospect): static
{
$this->prospect = $prospect;
return $this;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
@@ -10,12 +10,19 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\ClientRepository;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
@@ -28,9 +35,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['client:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: ClientRepository::class)]
class Client
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
class Client implements ClientInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -49,20 +58,8 @@ class Client
#[Groups(['client:read', 'client:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $street = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $city = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $postalCode = null;
/** @var Collection<int, Project> */
#[ORM\OneToMany(targetEntity: Project::class, mappedBy: 'client')]
/** @var Collection<int, ProjectInterface> */
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
private Collection $projects;
public function __construct()
@@ -111,43 +108,7 @@ class Client
return $this;
}
public function getStreet(): ?string
{
return $this->street;
}
public function setStreet(?string $street): static
{
$this->street = $street;
return $this;
}
public function getCity(): ?string
{
return $this->city;
}
public function setCity(?string $city): static
{
$this->city = $city;
return $this;
}
public function getPostalCode(): ?string
{
return $this->postalCode;
}
public function setPostalCode(?string $postalCode): static
{
$this->postalCode = $postalCode;
return $this;
}
/** @return Collection<int, Project> */
/** @return Collection<int, ProjectInterface> */
public function getProjects(): Collection
{
return $this->projects;
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['commercial_report:read']],
denormalizationContext: ['groups' => ['commercial_report:write']],
order: ['occurredAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)]
#[ORM\Table(name: 'commercial_report')]
class CommercialReport implements TimestampableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['commercial_report:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?string $subject = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?string $body = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?DateTimeImmutable $occurredAt = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: ReportType::class)]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ReportType $type = ReportType::Note;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['commercial_report:read'])]
private ?UserInterface $author = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Client $client = null;
#[ORM\ManyToOne(targetEntity: Prospect::class)]
#[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Prospect $prospect = null;
/** @var Collection<int, ReportDocument> */
#[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])]
#[Groups(['commercial_report:read'])]
private Collection $documents;
public function __construct()
{
$this->documents = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getSubject(): ?string
{
return $this->subject;
}
public function setSubject(string $subject): static
{
$this->subject = $subject;
return $this;
}
public function getBody(): ?string
{
return $this->body;
}
public function setBody(?string $body): static
{
$this->body = $body;
return $this;
}
public function getOccurredAt(): ?DateTimeImmutable
{
return $this->occurredAt;
}
public function setOccurredAt(DateTimeImmutable $occurredAt): static
{
$this->occurredAt = $occurredAt;
return $this;
}
public function getType(): ReportType
{
return $this->type;
}
public function setType(ReportType $type): static
{
$this->type = $type;
return $this;
}
public function getAuthor(): ?UserInterface
{
return $this->author;
}
public function setAuthor(?UserInterface $author): static
{
$this->author = $author;
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function getProspect(): ?Prospect
{
return $this->prospect;
}
public function setProspect(?Prospect $prospect): static
{
$this->prospect = $prospect;
return $this;
}
/** @return Collection<int, ReportDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']],
order: ['lastName' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineContactRepository::class)]
#[ORM\Table(name: 'directory_contact')]
class Contact implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['contact:read'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $firstName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $lastName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $jobTitle = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $phonePrimary = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])]
private ?string $phoneSecondary = null;
#[ORM\ManyToOne(targetEntity: Client::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['contact:read', 'contact:write'])]
private ?Client $client = null;
#[ORM\ManyToOne(targetEntity: Prospect::class)]
#[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['contact:read', 'contact:write'])]
private ?Prospect $prospect = null;
public function getId(): ?int
{
return $this->id;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getJobTitle(): ?string
{
return $this->jobTitle;
}
public function setJobTitle(?string $jobTitle): static
{
$this->jobTitle = $jobTitle;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhonePrimary(): ?string
{
return $this->phonePrimary;
}
public function setPhonePrimary(?string $phonePrimary): static
{
$this->phonePrimary = $phonePrimary;
return $this;
}
public function getPhoneSecondary(): ?string
{
return $this->phoneSecondary;
}
public function setPhoneSecondary(?string $phoneSecondary): static
{
$this->phoneSecondary = $phoneSecondary;
return $this;
}
public function getClient(): ?Client
{
return $this->client;
}
public function setClient(?Client $client): static
{
$this->client = $client;
return $this;
}
public function getProspect(): ?Prospect
{
return $this->prospect;
}
public function setProspect(?Prospect $prospect): static
{
$this->prospect = $prospect;
return $this;
}
}
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Domain\Enum\ProspectStatus;
use App\Module\Directory\Infrastructure\ApiPlatform\State\ConvertProspectProcessor;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Post(
uriTemplate: '/prospects/{id}/convert',
security: "is_granted('ROLE_ADMIN')",
processor: ConvertProspectProcessor::class,
),
],
normalizationContext: ['groups' => ['prospect:read']],
denormalizationContext: ['groups' => ['prospect:write']],
order: ['name' => 'ASC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)]
#[ORM\Table(name: 'prospect')]
class Prospect implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['prospect:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $company = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $phone = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)]
#[Groups(['prospect:read', 'prospect:write'])]
private ProspectStatus $status = ProspectStatus::New;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $source = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $notes = null;
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'converted_client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['prospect:read'])]
private ?ClientInterface $convertedClient = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCompany(): ?string
{
return $this->company;
}
public function setCompany(?string $company): static
{
$this->company = $company;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getStatus(): ProspectStatus
{
return $this->status;
}
public function setStatus(ProspectStatus $status): static
{
$this->status = $status;
return $this;
}
public function getSource(): ?string
{
return $this->source;
}
public function setSource(?string $source): static
{
$this->source = $source;
return $this;
}
public function getNotes(): ?string
{
return $this->notes;
}
public function setNotes(?string $notes): static
{
$this->notes = $notes;
return $this;
}
public function getConvertedClient(): ?ClientInterface
{
return $this->convertedClient;
}
public function setConvertedClient(?ClientInterface $convertedClient): static
{
$this->convertedClient = $convertedClient;
return $this;
}
}
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor;
use App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
new Get(security: "is_granted('ROLE_USER')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: ReportDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['report_document:read']],
denormalizationContext: ['groups' => ['report_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['commercialReport' => 'exact'])]
#[ORM\Entity]
#[ORM\Table(name: 'report_document')]
#[ORM\EntityListeners([ReportDocumentListener::class])]
class ReportDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: CommercialReport::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'commercial_report_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['report_document:read', 'report_document:write'])]
private ?CommercialReport $commercialReport = null;
#[ORM\Column(length: 255)]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?string $fileName = null;
#[ORM\Column(length: 100)]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'uploaded_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['report_document:read', 'commercial_report:read'])]
private ?UserInterface $uploadedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getCommercialReport(): ?CommercialReport
{
return $this->commercialReport;
}
public function setCommercialReport(?CommercialReport $commercialReport): static
{
$this->commercialReport = $commercialReport;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
}
public function setOriginalName(string $originalName): static
{
$this->originalName = $originalName;
return $this;
}
public function getFileName(): ?string
{
return $this->fileName;
}
public function setFileName(?string $fileName): static
{
$this->fileName = $fileName;
return $this;
}
public function getMimeType(): ?string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): ?int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUploadedBy(): ?UserInterface
{
return $this->uploadedBy;
}
public function setUploadedBy(?UserInterface $uploadedBy): static
{
$this->uploadedBy = $uploadedBy;
return $this;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Enum;
enum ProspectStatus: string
{
case New = 'new';
case Contacted = 'contacted';
case Qualified = 'qualified';
case Won = 'won';
case Lost = 'lost';
public function label(): string
{
return match ($this) {
self::New => 'Nouveau',
self::Contacted => 'Contacté',
self::Qualified => 'Qualifié',
self::Won => 'Gagné',
self::Lost => 'Perdu',
};
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Enum;
enum ReportType: string
{
case Call = 'call';
case Meeting = 'meeting';
case Email = 'email';
case Note = 'note';
public function label(): string
{
return match ($this) {
self::Call => 'Appel',
self::Meeting => 'Rendez-vous',
self::Email => 'Email',
self::Note => 'Note',
};
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Address;
interface AddressRepositoryInterface
{
public function findById(int $id): ?Address;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Client;
interface ClientRepositoryInterface
{
public function findById(int $id): ?Client;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Client[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\CommercialReport;
interface CommercialReportRepositoryInterface
{
public function findById(int $id): ?CommercialReport;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Contact;
interface ContactRepositoryInterface
{
public function findById(int $id): ?Contact;
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Prospect;
interface ProspectRepositoryInterface
{
public function findById(int $id): ?Prospect;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Prospect[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\ReportDocument;
interface ReportDocumentRepositoryInterface
{
public function findById(int $id): ?ReportDocument;
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ProspectStatus;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Converts a Prospect into a Client and reassigns its contacts, addresses and
* commercial reports to the new client (preserving the commercial history).
*
* Idempotent: if already converted, returns it unchanged.
*
* @implements ProcessorInterface<Prospect, Prospect>
*/
final readonly class ConvertProspectProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private ProspectRepositoryInterface $prospectRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Prospect
{
$id = $uriVariables['id'] ?? null;
$prospect = is_numeric($id) ? $this->prospectRepository->findById((int) $id) : null;
if (!$prospect instanceof Prospect) {
throw new NotFoundHttpException('Prospect not found.');
}
// Idempotent: already converted, return as-is.
if (null !== $prospect->getConvertedClient()) {
return $prospect;
}
$client = new Client();
$client->setName($prospect->getCompany() ?: (string) $prospect->getName());
$client->setEmail($prospect->getEmail());
$client->setPhone($prospect->getPhone());
$this->entityManager->persist($client);
$this->reassignContacts($prospect, $client);
$this->reassignAddresses($prospect, $client);
$this->reassignReports($prospect, $client);
$prospect->setConvertedClient($client);
$prospect->setStatus(ProspectStatus::Won);
$this->entityManager->flush();
return $prospect;
}
private function reassignContacts(Prospect $prospect, Client $client): void
{
foreach ($this->entityManager->getRepository(Contact::class)->findBy(['prospect' => $prospect]) as $contact) {
$contact->setClient($client);
$contact->setProspect(null);
}
}
private function reassignAddresses(Prospect $prospect, Client $client): void
{
foreach ($this->entityManager->getRepository(Address::class)->findBy(['prospect' => $prospect]) as $address) {
$address->setClient($client);
$address->setProspect(null);
}
}
private function reassignReports(Prospect $prospect, Client $client): void
{
foreach ($this->entityManager->getRepository(CommercialReport::class)->findBy(['prospect' => $prospect]) as $report) {
$report->setClient($client);
$report->setProspect(null);
}
}
}
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\ReportDocument;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
use Throwable;
use function in_array;
/**
* @implements ProcessorInterface<ReportDocument, ReportDocument>
*/
final readonly class ReportDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv',
'application/zip', 'application/x-rar-compressed', 'application/gzip',
'application/json', 'application/xml', 'text/xml',
];
private const MIME_TO_EXTENSION = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'text/plain' => 'txt',
'text/csv' => 'csv',
'application/zip' => 'zip',
'application/x-rar-compressed' => 'rar',
'application/gzip' => 'gz',
'application/json' => 'json',
'application/xml' => 'xml',
'text/xml' => 'xml',
];
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private string $uploadDir,
) {}
/**
* @param ReportDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument
{
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating report documents requires admin privileges.');
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
$document = $this->createUpload($request);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
try {
$this->entityManager->persist($document);
$this->entityManager->flush();
} catch (Throwable $e) {
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (file_exists($filePath)) {
@unlink($filePath);
}
throw $e;
}
return $document;
}
private function createUpload(Request $request): ReportDocument
{
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$report = $this->resolveReport((string) $request->request->get('commercialReport', ''));
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$fileSize = $file->getSize();
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType));
}
$extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin';
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new ReportDocument();
$document->setCommercialReport($report);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
return $document;
}
private function resolveReport(string $iri): CommercialReport
{
$idString = basename($iri);
if ('' === $iri || !ctype_digit($idString)) {
throw new BadRequestHttpException('A valid commercialReport IRI is required.');
}
$report = $this->entityManager->getRepository(CommercialReport::class)->find((int) $idString);
if (null === $report) {
throw new BadRequestHttpException('Commercial report not found.');
}
return $report;
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Controller;
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class ReportDocumentDownloadController
{
private const INLINE_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf',
];
public function __construct(
private readonly ReportDocumentRepositoryInterface $repository,
private readonly string $uploadDir,
) {}
#[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function __invoke(int $id): Response
{
$document = $this->repository->findById($id);
if (null === $document || null === $document->getFileName()) {
throw new NotFoundHttpException('Document not found.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!is_file($filePath)) {
throw new NotFoundHttpException('File missing on disk.');
}
$response = new BinaryFileResponse($filePath);
$mimeType = (string) $document->getMimeType();
$disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true)
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
$response->setContentDisposition($disposition, (string) $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);
$response->headers->set('X-Content-Type-Options', 'nosniff');
return $response;
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Doctrine;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Address>
*/
final class DoctrineAddressRepository extends ServiceEntityRepository implements AddressRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Address::class);
}
public function findById(int $id): ?Address
{
return $this->find($id);
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Doctrine;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Client>
*/
final class DoctrineClientRepository extends ServiceEntityRepository implements ClientRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Client::class);
}
public function findById(int $id): ?Client
{
return $this->find($id);
}
}

Some files were not shown because too many files have changed in this diff Show More