Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
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:
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Application\Service;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Maintains per-employee leave balances as absence requests move through their
|
||||
* lifecycle: a PENDING request reserves days in `pending`, an APPROVED one
|
||||
* moves them to `taken`, and a cancellation gives them back.
|
||||
*/
|
||||
final readonly class AbsenceBalanceService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
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(UserInterface $user, AbsenceType $type, DateTimeInterface $date): string
|
||||
{
|
||||
if (AbsenceType::PaidLeave !== $type) {
|
||||
return $date->format('Y');
|
||||
}
|
||||
|
||||
$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;
|
||||
|
||||
return sprintf('%d-%d', $startYear, $startYear + 1);
|
||||
}
|
||||
|
||||
public function getOrCreateBalance(UserInterface $user, AbsenceType $type, string $period): AbsenceBalance
|
||||
{
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
|
||||
|
||||
if (null === $balance) {
|
||||
$balance = new AbsenceBalance()
|
||||
->setUser($user)
|
||||
->setType($type)
|
||||
->setPeriod($period)
|
||||
;
|
||||
$this->entityManager->persist($balance);
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
/** Reserve the requested days in the PENDING bucket. */
|
||||
public function reservePending(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending($balance->getPending() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* Days still available to take in the request's balance period
|
||||
* (acquired N-1 + acquiring N − already taken), or null when the type is
|
||||
* not balance-tracked (per-event leaves such as bereavement or marriage).
|
||||
*
|
||||
* Days currently reserved in PENDING are intentionally not subtracted: the
|
||||
* request being reviewed already sits in that pending bucket, and approval
|
||||
* only moves it to TAKEN.
|
||||
*/
|
||||
public function availableForRequest(AbsenceRequest $request): ?float
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var UserInterface $user */
|
||||
$user = $request->getUser();
|
||||
$period = $this->periodFor($user, $request->getType(), $request->getStartDate());
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $request->getType(), $period);
|
||||
|
||||
return $balance?->getAvailable() ?? 0.0;
|
||||
}
|
||||
|
||||
/** Move reserved days from PENDING to TAKEN on approval. */
|
||||
public function applyApproval(AbsenceRequest $request): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
$balance->setTaken($balance->getTaken() + $request->getCountedDays());
|
||||
}
|
||||
|
||||
/**
|
||||
* Give days back when a request is cancelled or rejected.
|
||||
*
|
||||
* @param bool $wasApproved true if the request had already been approved
|
||||
* (days were in TAKEN), false if still PENDING
|
||||
*/
|
||||
public function release(AbsenceRequest $request, bool $wasApproved): void
|
||||
{
|
||||
if (!$this->shouldTrack($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$balance = $this->balanceForRequest($request);
|
||||
|
||||
if ($wasApproved) {
|
||||
$balance->setTaken(max(0.0, $balance->getTaken() - $request->getCountedDays()));
|
||||
} else {
|
||||
$balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays()));
|
||||
}
|
||||
}
|
||||
|
||||
private function balanceForRequest(AbsenceRequest $request): AbsenceBalance
|
||||
{
|
||||
/** @var UserInterface $user */
|
||||
$user = $request->getUser();
|
||||
$type = $request->getType();
|
||||
$period = $this->periodFor($user, $type, $request->getStartDate());
|
||||
|
||||
return $this->getOrCreateBalance($user, $type, $period);
|
||||
}
|
||||
|
||||
private function shouldTrack(AbsenceRequest $request): bool
|
||||
{
|
||||
$type = $request->getType();
|
||||
|
||||
return null !== $type && $type->decrementsBalance() && null !== $request->getUser();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Per-employee, per-type leave balance for a given reference period.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceBalanceProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceBalanceProvider::class,
|
||||
),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_balance:read']],
|
||||
denormalizationContext: ['groups' => ['absence_balance:write']],
|
||||
)]
|
||||
#[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 implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private AbsenceType $type;
|
||||
|
||||
/** Reference period, e.g. "2025-2026" for paid leave or "2025" for yearly. */
|
||||
#[ORM\Column(length: 16)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private ?string $period = null;
|
||||
|
||||
/** Days acquired during the *previous* reference period (Congés N-1): fully available to take. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $acquired = 0.0;
|
||||
|
||||
/** Days being accrued during the *current* reference period (Congés N): "en cours d'acquisition". */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $acquiring = 0.0;
|
||||
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read', 'absence_balance:write'])]
|
||||
private float $taken = 0.0;
|
||||
|
||||
/** Sum of days in PENDING requests, for information. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_balance:read'])]
|
||||
private float $pending = 0.0;
|
||||
|
||||
/** Last month (format YYYY-MM) for which the monthly accrual was applied. */
|
||||
#[ORM\Column(length: 7, nullable: true)]
|
||||
private ?string $lastAccruedMonth = null;
|
||||
|
||||
/** Total entitlement for the period, both finalized (N-1) and in-progress (N). */
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getAcquiredTotal(): float
|
||||
{
|
||||
return $this->acquired + $this->acquiring;
|
||||
}
|
||||
|
||||
/**
|
||||
* Days the employee can still take: in this organisation the days being
|
||||
* accrued (N) are posable too, so they count towards what is available.
|
||||
*/
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getAvailable(): float
|
||||
{
|
||||
return $this->acquired + $this->acquiring - $this->taken;
|
||||
}
|
||||
|
||||
#[Groups(['absence_balance:read'])]
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->type->label();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPeriod(): ?string
|
||||
{
|
||||
return $this->period;
|
||||
}
|
||||
|
||||
public function setPeriod(string $period): static
|
||||
{
|
||||
$this->period = $period;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAcquired(): float
|
||||
{
|
||||
return $this->acquired;
|
||||
}
|
||||
|
||||
public function setAcquired(float $acquired): static
|
||||
{
|
||||
$this->acquired = $acquired;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAcquiring(): float
|
||||
{
|
||||
return $this->acquiring;
|
||||
}
|
||||
|
||||
public function setAcquiring(float $acquiring): static
|
||||
{
|
||||
$this->acquiring = $acquiring;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTaken(): float
|
||||
{
|
||||
return $this->taken;
|
||||
}
|
||||
|
||||
public function setTaken(float $taken): static
|
||||
{
|
||||
$this->taken = $taken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPending(): float
|
||||
{
|
||||
return $this->pending;
|
||||
}
|
||||
|
||||
public function setPending(float $pending): static
|
||||
{
|
||||
$this->pending = $pending;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastAccruedMonth(): ?string
|
||||
{
|
||||
return $this->lastAccruedMonth;
|
||||
}
|
||||
|
||||
public function setLastAccruedMonth(?string $lastAccruedMonth): static
|
||||
{
|
||||
$this->lastAccruedMonth = $lastAccruedMonth;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Per-type configuration of absence rules. Overrides the legal defaults and
|
||||
* lets an admin tune days/year, days/event, notice period, etc.
|
||||
*/
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_policy:read']],
|
||||
denormalizationContext: ['groups' => ['absence_policy:write']],
|
||||
order: ['type' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineAbsencePolicyRepository::class)]
|
||||
#[ORM\Table(name: 'absence_policy')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])]
|
||||
class AbsencePolicy implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_policy:read', 'absence_balance:read', 'absence_request:read'])]
|
||||
private AbsenceType $type;
|
||||
|
||||
/** Yearly entitlement (e.g. 25 for paid leave); null when not relevant. */
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private ?float $daysPerYear = null;
|
||||
|
||||
/** Days granted per event (e.g. 4 for marriage); null when not relevant. */
|
||||
#[ORM\Column(type: Types::FLOAT, nullable: true)]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private ?float $daysPerEvent = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $justificationRequired = false;
|
||||
|
||||
/** Minimum notice period in days (e.g. 30 for paid leave, 0 for sick leave). */
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private int $noticeDays = 0;
|
||||
|
||||
/** true => "jours ouvrés" (Mon-Fri), false => "jours ouvrables" (Mon-Sat). */
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $countWorkingDaysOnly = true;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_policy:read', 'absence_policy:write'])]
|
||||
private bool $active = true;
|
||||
|
||||
#[Groups(['absence_policy:read'])]
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->type->label();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getType(): AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDaysPerYear(): ?float
|
||||
{
|
||||
return $this->daysPerYear;
|
||||
}
|
||||
|
||||
public function setDaysPerYear(?float $daysPerYear): static
|
||||
{
|
||||
$this->daysPerYear = $daysPerYear;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDaysPerEvent(): ?float
|
||||
{
|
||||
return $this->daysPerEvent;
|
||||
}
|
||||
|
||||
public function setDaysPerEvent(?float $daysPerEvent): static
|
||||
{
|
||||
$this->daysPerEvent = $daysPerEvent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isJustificationRequired(): bool
|
||||
{
|
||||
return $this->justificationRequired;
|
||||
}
|
||||
|
||||
public function setJustificationRequired(bool $justificationRequired): static
|
||||
{
|
||||
$this->justificationRequired = $justificationRequired;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNoticeDays(): int
|
||||
{
|
||||
return $this->noticeDays;
|
||||
}
|
||||
|
||||
public function setNoticeDays(int $noticeDays): static
|
||||
{
|
||||
$this->noticeDays = $noticeDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isCountWorkingDaysOnly(): bool
|
||||
{
|
||||
return $this->countWorkingDaysOnly;
|
||||
}
|
||||
|
||||
public function setCountWorkingDaysOnly(bool $countWorkingDaysOnly): static
|
||||
{
|
||||
$this->countWorkingDaysOnly = $countWorkingDaysOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active;
|
||||
}
|
||||
|
||||
public function setActive(bool $active): static
|
||||
{
|
||||
$this->active = $active;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\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\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;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_USER')",
|
||||
processor: AbsenceRequestProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/approve',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: AbsenceReviewProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/reject',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: AbsenceReviewProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/absence_requests/{id}/cancel',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
processor: AbsenceCancelProcessor::class,
|
||||
provider: AbsenceRequestProvider::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['absence_request:read']],
|
||||
denormalizationContext: ['groups' => ['absence_request:write']],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineAbsenceRequestRepository::class)]
|
||||
#[ORM\Table(name: 'absence_request')]
|
||||
class AbsenceRequest
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?AbsenceType $type = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?DateTimeImmutable $startDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
#[Assert\NotNull]
|
||||
private ?DateTimeImmutable $endDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?HalfDay $startHalfDay = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?HalfDay $endHalfDay = null;
|
||||
|
||||
/** Number of deducted days, computed server-side at creation. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private float $countedDays = 0.0;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?string $reason = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?string $justificationFileName = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, enumType: AbsenceStatus::class)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private AbsenceStatus $status = AbsenceStatus::Pending;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['absence_request:read', 'absence_request:write'])]
|
||||
private ?string $rejectionReason = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?DateTimeImmutable $reviewedAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['absence_request:read'])]
|
||||
private ?UserInterface $reviewedBy = null;
|
||||
|
||||
#[Groups(['absence_request:read'])]
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->type?->label();
|
||||
}
|
||||
|
||||
#[Groups(['absence_request:read'])]
|
||||
public function getJustificationUrl(): ?string
|
||||
{
|
||||
if (null === $this->justificationFileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/api/absence_requests/'.$this->id.'/justificatif';
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?AbsenceType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?AbsenceType $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
public function setStartDate(?DateTimeImmutable $startDate): static
|
||||
{
|
||||
$this->startDate = $startDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(?DateTimeImmutable $endDate): static
|
||||
{
|
||||
$this->endDate = $endDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartHalfDay(): ?HalfDay
|
||||
{
|
||||
return $this->startHalfDay;
|
||||
}
|
||||
|
||||
public function setStartHalfDay(?HalfDay $startHalfDay): static
|
||||
{
|
||||
$this->startHalfDay = $startHalfDay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndHalfDay(): ?HalfDay
|
||||
{
|
||||
return $this->endHalfDay;
|
||||
}
|
||||
|
||||
public function setEndHalfDay(?HalfDay $endHalfDay): static
|
||||
{
|
||||
$this->endHalfDay = $endHalfDay;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCountedDays(): float
|
||||
{
|
||||
return $this->countedDays;
|
||||
}
|
||||
|
||||
public function setCountedDays(float $countedDays): static
|
||||
{
|
||||
$this->countedDays = $countedDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): static
|
||||
{
|
||||
$this->reason = $reason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getJustificationFileName(): ?string
|
||||
{
|
||||
return $this->justificationFileName;
|
||||
}
|
||||
|
||||
public function setJustificationFileName(?string $justificationFileName): static
|
||||
{
|
||||
$this->justificationFileName = $justificationFileName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): AbsenceStatus
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(AbsenceStatus $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRejectionReason(): ?string
|
||||
{
|
||||
return $this->rejectionReason;
|
||||
}
|
||||
|
||||
public function setRejectionReason(?string $rejectionReason): static
|
||||
{
|
||||
$this->rejectionReason = $rejectionReason;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->reviewedAt;
|
||||
}
|
||||
|
||||
public function setReviewedAt(?DateTimeImmutable $reviewedAt): static
|
||||
{
|
||||
$this->reviewedAt = $reviewedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReviewedBy(): ?UserInterface
|
||||
{
|
||||
return $this->reviewedBy;
|
||||
}
|
||||
|
||||
public function setReviewedBy(?UserInterface $reviewedBy): static
|
||||
{
|
||||
$this->reviewedBy = $reviewedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum AbsenceStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case Rejected = 'rejected';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'En attente',
|
||||
self::Approved => 'Approuvée',
|
||||
self::Rejected => 'Refusée',
|
||||
self::Cancelled => 'Annulée',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum AbsenceType: string
|
||||
{
|
||||
case PaidLeave = 'cp';
|
||||
case MarriagePacs = 'mariage_pacs';
|
||||
case Birth = 'naissance';
|
||||
case ParentalLeave = 'conge_parental';
|
||||
case Bereavement = 'deces';
|
||||
case SickLeave = 'maladie';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PaidLeave => 'Congés payés',
|
||||
self::MarriagePacs => 'Mariage / PACS',
|
||||
self::Birth => 'Naissance',
|
||||
self::ParentalLeave => 'Congé parental',
|
||||
self::Bereavement => 'Décès proche',
|
||||
self::SickLeave => 'Arrêt maladie',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether taking this absence decrements a balance.
|
||||
*
|
||||
* Only paid leave (CP) draws down an acquired balance. Family-event leaves
|
||||
* (marriage/PACS, birth, bereavement) are per-event entitlements, parental
|
||||
* leave is a contract suspension, and sick leave is handled by social
|
||||
* security — none of them decrement a balance.
|
||||
*/
|
||||
public function decrementsBalance(): bool
|
||||
{
|
||||
return self::PaidLeave === $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Enum;
|
||||
|
||||
enum HalfDay: string
|
||||
{
|
||||
case Morning = 'matin';
|
||||
case Afternoon = 'apres_midi';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Morning => 'Matin',
|
||||
self::Afternoon => 'Après-midi',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Service;
|
||||
|
||||
use App\Module\Absence\Domain\Enum\HalfDay;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
use DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Computes the number of days deducted for an absence request, following the
|
||||
* business rules of the spec (§5.1): weekends and public holidays are skipped,
|
||||
* and half-days on the boundaries subtract 0.5 each.
|
||||
*/
|
||||
final readonly class AbsenceDayCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private PublicHolidayProvider $holidayProvider,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param bool $workingDaysOnly true => "jours ouvrés" (Mon-Fri),
|
||||
* false => "jours ouvrables" (Mon-Sat, Sunday excluded)
|
||||
*/
|
||||
public function countWorkingDays(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
?HalfDay $startHalfDay = null,
|
||||
?HalfDay $endHalfDay = null,
|
||||
bool $workingDaysOnly = true,
|
||||
): float {
|
||||
$start = $start->setTime(0, 0);
|
||||
$end = $end->setTime(0, 0);
|
||||
|
||||
if ($end < $start) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$days = 0.0;
|
||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
|
||||
|
||||
foreach ($period as $day) {
|
||||
if ($this->isCountedDay($day, $workingDaysOnly)) {
|
||||
++$days;
|
||||
}
|
||||
}
|
||||
|
||||
if ($days <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// A half-day only subtracts 0.5 when its boundary day is actually
|
||||
// counted (otherwise a half-day posted on a weekend/holiday would
|
||||
// wrongly under-count the absence).
|
||||
if ($start->getTimestamp() === $end->getTimestamp()) {
|
||||
// Single-day request: both boundaries collapse onto the same day,
|
||||
// so a half-day must subtract 0.5 once, never twice.
|
||||
if ((null !== $startHalfDay || null !== $endHalfDay) && $this->isCountedDay($start, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
} else {
|
||||
if (null !== $startHalfDay && $this->isCountedDay($start, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
if (null !== $endHalfDay && $this->isCountedDay($end, $workingDaysOnly)) {
|
||||
$days -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return max(0.0, $days);
|
||||
}
|
||||
|
||||
private function isCountedDay(DateTimeImmutable $day, bool $workingDaysOnly): bool
|
||||
{
|
||||
$weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun)
|
||||
|
||||
if (7 === $weekday) {
|
||||
return false; // Sunday: never counted
|
||||
}
|
||||
if (6 === $weekday && $workingDaysOnly) {
|
||||
return false; // Saturday: only counted for "jours ouvrables"
|
||||
}
|
||||
|
||||
return !$this->holidayProvider->isHoliday($day);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Domain\Service;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Provides French métropole public holidays.
|
||||
*
|
||||
* Dates are computed in pure PHP: fixed-date holidays are hardcoded and
|
||||
* Easter-based ones are derived from the Computus (Meeus/Jones/Butcher
|
||||
* Gregorian algorithm), so the provider has no runtime dependency and is
|
||||
* fully deterministic. Alsace-Moselle / DOM specifics are out of scope.
|
||||
*/
|
||||
final class PublicHolidayProvider
|
||||
{
|
||||
/** @var array<int, array<string, string>> cache of holidays per year */
|
||||
private array $cache = [];
|
||||
|
||||
/**
|
||||
* @return array<string, string> map of 'Y-m-d' => label, sorted by date
|
||||
*/
|
||||
public function getHolidays(int $year): array
|
||||
{
|
||||
if (isset($this->cache[$year])) {
|
||||
return $this->cache[$year];
|
||||
}
|
||||
|
||||
$easter = $this->easterSunday($year);
|
||||
$easterMonday = $easter->modify('+1 day');
|
||||
$ascension = $easter->modify('+39 days');
|
||||
$whitMonday = $easter->modify('+50 days');
|
||||
|
||||
$holidays = [
|
||||
sprintf('%d-01-01', $year) => 'Jour de l\'an',
|
||||
$easterMonday->format('Y-m-d') => 'Lundi de Pâques',
|
||||
sprintf('%d-05-01', $year) => 'Fête du Travail',
|
||||
sprintf('%d-05-08', $year) => 'Victoire 1945',
|
||||
$ascension->format('Y-m-d') => 'Ascension',
|
||||
$whitMonday->format('Y-m-d') => 'Lundi de Pentecôte',
|
||||
sprintf('%d-07-14', $year) => 'Fête nationale',
|
||||
sprintf('%d-08-15', $year) => 'Assomption',
|
||||
sprintf('%d-11-01', $year) => 'Toussaint',
|
||||
sprintf('%d-11-11', $year) => 'Armistice 1918',
|
||||
sprintf('%d-12-25', $year) => 'Noël',
|
||||
];
|
||||
|
||||
ksort($holidays);
|
||||
|
||||
return $this->cache[$year] = $holidays;
|
||||
}
|
||||
|
||||
public function isHoliday(DateTimeInterface $date): bool
|
||||
{
|
||||
$holidays = $this->getHolidays((int) $date->format('Y'));
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Easter Sunday date for the given year (Gregorian Computus).
|
||||
*/
|
||||
private function easterSunday(int $year): DateTimeImmutable
|
||||
{
|
||||
$a = $year % 19;
|
||||
$b = intdiv($year, 100);
|
||||
$c = $year % 100;
|
||||
$d = intdiv($b, 4);
|
||||
$e = $b % 4;
|
||||
$f = intdiv($b + 8, 25);
|
||||
$g = intdiv($b - $f + 1, 3);
|
||||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||
$i = intdiv($c, 4);
|
||||
$k = $c % 4;
|
||||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||
|
||||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||
|
||||
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<AbsenceBalance>
|
||||
*/
|
||||
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 UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
if (isset($uriVariables['id'])) {
|
||||
$balance = $this->balanceRepository->findById((int) $uriVariables['id']);
|
||||
if (null === $balance) {
|
||||
return null;
|
||||
}
|
||||
if (!$isAdmin && $balance->getUser() !== $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $balance;
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(AbsenceBalance::class)
|
||||
->createQueryBuilder('b')
|
||||
->orderBy('b.type', 'ASC')
|
||||
;
|
||||
|
||||
if (!$isAdmin) {
|
||||
$qb->andWhere('b.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (isset($filters['type'])) {
|
||||
$qb->andWhere('b.type = :type')->setParameter('type', $filters['type']);
|
||||
}
|
||||
if (isset($filters['period'])) {
|
||||
$qb->andWhere('b.period = :period')->setParameter('period', $filters['period']);
|
||||
}
|
||||
if ($isAdmin && isset($filters['user'])) {
|
||||
$qb->andWhere('b.user = :filterUser')
|
||||
->setParameter('filterUser', self::extractId($filters['user']))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private static function extractId(string $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : (int) basename($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
|
||||
/**
|
||||
* Cancellation of an absence request. An employee may cancel their own PENDING
|
||||
* request; an admin may additionally cancel an APPROVED one, which credits the
|
||||
* deducted days back to the balance.
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceCancelProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
// Cancellation carries no payload: keep the persisted content intact.
|
||||
$previous = $context['previous_data'] ?? null;
|
||||
if ($previous instanceof AbsenceRequest) {
|
||||
$data->setType($previous->getType());
|
||||
$data->setStartDate($previous->getStartDate());
|
||||
$data->setEndDate($previous->getEndDate());
|
||||
$data->setStartHalfDay($previous->getStartHalfDay());
|
||||
$data->setEndHalfDay($previous->getEndHalfDay());
|
||||
$data->setReason($previous->getReason());
|
||||
$data->setCountedDays($previous->getCountedDays());
|
||||
$data->setStatus($previous->getStatus());
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$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) {
|
||||
if (!$isAdmin) {
|
||||
throw new AccessDeniedHttpException('Only an admin can cancel an approved request.');
|
||||
}
|
||||
$this->balanceService->release($data, true);
|
||||
} else {
|
||||
throw new ConflictHttpException('This request can no longer be cancelled.');
|
||||
}
|
||||
|
||||
$data->setStatus(AbsenceStatus::Cancelled);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Handles creation of an absence request: computes the deducted days, enforces
|
||||
* the overlap rule, and reserves the days in the employee's pending balance.
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceRequestProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceDayCalculator $calculator,
|
||||
private AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$type = $data->getType();
|
||||
$startDate = $data->getStartDate();
|
||||
$endDate = $data->getEndDate();
|
||||
|
||||
if (null === $type || null === $startDate || null === $endDate) {
|
||||
throw new UnprocessableEntityHttpException('Type, start date and end date are required.');
|
||||
}
|
||||
|
||||
if ($endDate < $startDate) {
|
||||
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($type);
|
||||
if (null === $policy || !$policy->isActive()) {
|
||||
throw new UnprocessableEntityHttpException('This absence type is not available.');
|
||||
}
|
||||
|
||||
// Bereavement has no fixed entitlement: the relationship to the deceased
|
||||
// drives the legal number of days, so the reason is mandatory.
|
||||
if (AbsenceType::Bereavement === $type && '' === trim((string) $data->getReason())) {
|
||||
throw new UnprocessableEntityHttpException('A reason (relationship to the deceased) is required for bereavement leave.');
|
||||
}
|
||||
|
||||
if ($this->requestRepository->hasOverlap($user, $startDate, $endDate)) {
|
||||
throw new ConflictHttpException('This request overlaps an existing absence.');
|
||||
}
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays(
|
||||
$startDate,
|
||||
$endDate,
|
||||
$data->getStartHalfDay(),
|
||||
$data->getEndHalfDay(),
|
||||
$policy->isCountWorkingDaysOnly(),
|
||||
);
|
||||
|
||||
if ($countedDays <= 0.0) {
|
||||
throw new UnprocessableEntityHttpException('The selected range contains no working day.');
|
||||
}
|
||||
|
||||
$data->setUser($user);
|
||||
$data->setCountedDays($countedDays);
|
||||
$data->setStatus(AbsenceStatus::Pending);
|
||||
$data->setRejectionReason(null);
|
||||
$data->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($data);
|
||||
$this->balanceService->reservePending($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
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;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<AbsenceRequest>
|
||||
*/
|
||||
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 UserInterface);
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
|
||||
// Single item: owner or admin only
|
||||
if (isset($uriVariables['id'])) {
|
||||
$request = $this->requestRepository->findById((int) $uriVariables['id']);
|
||||
if (null === $request) {
|
||||
return null;
|
||||
}
|
||||
if (!$isAdmin && $request->getUser() !== $user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(AbsenceRequest::class)
|
||||
->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
;
|
||||
|
||||
if (!$isAdmin) {
|
||||
$qb->andWhere('a.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
|
||||
$filters = $context['filters'] ?? [];
|
||||
|
||||
if (isset($filters['status'])) {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $filters['status']);
|
||||
}
|
||||
if (isset($filters['type'])) {
|
||||
$qb->andWhere('a.type = :type')->setParameter('type', $filters['type']);
|
||||
}
|
||||
if (isset($filters['year']) && is_numeric($filters['year'])) {
|
||||
$year = (int) $filters['year'];
|
||||
$qb->andWhere('a.startDate <= :yearEnd')
|
||||
->andWhere('a.endDate >= :yearStart')
|
||||
->setParameter('yearStart', sprintf('%d-01-01', $year))
|
||||
->setParameter('yearEnd', sprintf('%d-12-31', $year))
|
||||
;
|
||||
}
|
||||
if ($isAdmin && isset($filters['user'])) {
|
||||
$qb->andWhere('a.user = :filterUser')
|
||||
->setParameter('filterUser', self::extractId($filters['user']))
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private static function extractId(string $value): int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : (int) basename($value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
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;
|
||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
|
||||
/**
|
||||
* Admin approval / rejection of a pending absence request. The target status
|
||||
* is derived from the operation URI (.../approve or .../reject).
|
||||
*
|
||||
* @implements ProcessorInterface<AbsenceRequest, AbsenceRequest>
|
||||
*/
|
||||
final readonly class AbsenceReviewProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest
|
||||
{
|
||||
assert($data instanceof AbsenceRequest);
|
||||
|
||||
$isApprove = str_contains((string) $operation->getUriTemplate(), 'approve');
|
||||
$newRejectionReason = $data->getRejectionReason();
|
||||
|
||||
// Reviewing must never alter the request content: restore everything
|
||||
// from the persisted state, only status/review fields may change.
|
||||
$previous = $context['previous_data'] ?? null;
|
||||
if ($previous instanceof AbsenceRequest) {
|
||||
$data->setType($previous->getType());
|
||||
$data->setStartDate($previous->getStartDate());
|
||||
$data->setEndDate($previous->getEndDate());
|
||||
$data->setStartHalfDay($previous->getStartHalfDay());
|
||||
$data->setEndHalfDay($previous->getEndHalfDay());
|
||||
$data->setReason($previous->getReason());
|
||||
$data->setCountedDays($previous->getCountedDays());
|
||||
}
|
||||
|
||||
if (AbsenceStatus::Pending !== $data->getStatus()) {
|
||||
throw new ConflictHttpException('Only a pending request can be reviewed.');
|
||||
}
|
||||
|
||||
$admin = $this->security->getUser();
|
||||
assert($admin instanceof UserInterface);
|
||||
|
||||
if ($isApprove) {
|
||||
// Never let an approval push the balance below zero (CP only): the
|
||||
// days being accrued (N) are posable, but not beyond the entitlement.
|
||||
$available = $this->balanceService->availableForRequest($data);
|
||||
if (null !== $available && $data->getCountedDays() > $available + 1e-9) {
|
||||
throw new UnprocessableEntityHttpException(sprintf(
|
||||
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||
$data->getCountedDays(),
|
||||
$available,
|
||||
));
|
||||
}
|
||||
|
||||
$data->setStatus(AbsenceStatus::Approved);
|
||||
$data->setRejectionReason(null);
|
||||
$this->balanceService->applyApproval($data);
|
||||
} else {
|
||||
if (null === $newRejectionReason || '' === trim($newRejectionReason)) {
|
||||
throw new UnprocessableEntityHttpException('A reason is required when rejecting a request.');
|
||||
}
|
||||
$data->setStatus(AbsenceStatus::Rejected);
|
||||
$data->setRejectionReason($newRejectionReason);
|
||||
$this->balanceService->release($data, false);
|
||||
}
|
||||
|
||||
$data->setReviewedAt(new DateTimeImmutable());
|
||||
$data->setReviewedBy($admin);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Command;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function preg_match;
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Monthly paid-leave accrual. For each active employee it credits one twelfth
|
||||
* of their yearly entitlement (prorated by work-time ratio) to the current
|
||||
* reference-period balance. Idempotent per month thanks to lastAccruedMonth,
|
||||
* and it seeds the initial balance when the period balance is first created.
|
||||
*
|
||||
* Intended to run on the 1st of each month (cron). Notifications are out of
|
||||
* scope for now.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:absences:accrue-leave',
|
||||
description: 'Credit the monthly paid-leave accrual to every active employee',
|
||||
)]
|
||||
class AccrueLeaveCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('month', null, InputOption::VALUE_REQUIRED, 'Target month (YYYY-MM), defaults to the current month')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Compute and display without persisting')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$monthOpt = $input->getOption('month');
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
try {
|
||||
$firstDay = $monthOpt
|
||||
? new DateTimeImmutable($monthOpt.'-01')
|
||||
: new DateTimeImmutable('first day of this month');
|
||||
} catch (Exception) {
|
||||
$io->error('Invalid --month, expected format YYYY-MM.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$firstDay = $firstDay->setTime(0, 0);
|
||||
$lastDay = $firstDay->modify('last day of this month');
|
||||
$monthKey = $firstDay->format('Y-m');
|
||||
|
||||
$io->title(sprintf('Acquisition CP — %s%s', $monthKey, $dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$employees = $this->userRepository->findActiveEmployees($lastDay);
|
||||
if ([] === $employees) {
|
||||
$io->warning('Aucun salarié actif pour ce mois.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$accrued = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($employees as $user) {
|
||||
// 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);
|
||||
$isNew = null === $balance;
|
||||
|
||||
if ($isNew) {
|
||||
$balance = $this->balanceService->getOrCreateBalance($user, AbsenceType::PaidLeave, $period);
|
||||
// On a new period, the previous period's "en cours d'acquisition" (N)
|
||||
// becomes this period's acquired (N-1). At roll-out (no prior balance)
|
||||
// seed the configured initial balance instead.
|
||||
$previousPeriod = self::previousPeriod($period);
|
||||
$previousBalance = null !== $previousPeriod
|
||||
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
|
||||
: null;
|
||||
$balance->setAcquired(
|
||||
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($monthKey === $balance->getLastAccruedMonth()) {
|
||||
++$skipped;
|
||||
$rows[] = [$user->getUsername(), $period, number_format($balance->getAcquired(), 2), number_format($balance->getAcquiring(), 2), 'déjà fait'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance->setAcquiring($balance->getAcquiring() + $rate);
|
||||
$balance->setLastAccruedMonth($monthKey);
|
||||
++$accrued;
|
||||
|
||||
$seeded = $isNew && (null !== self::previousPeriod($period) || $profile->getInitialLeaveBalance() > 0);
|
||||
$rows[] = [
|
||||
$user->getUsername(),
|
||||
$period,
|
||||
number_format($balance->getAcquired(), 2),
|
||||
number_format($balance->getAcquiring(), 2),
|
||||
sprintf('+%s%s', number_format($rate, 2), $seeded && $balance->getAcquired() > 0 ? ' (N-1 reporté)' : ''),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
|
||||
$io->table(['Salarié', 'Période', 'Acquis (N-1)', 'En cours (N)', 'Action'], $rows);
|
||||
$io->success(sprintf('%d crédité(s), %d ignoré(s)%s.', $accrued, $skipped, $dryRun ? ' (dry-run, rien enregistré)' : ''));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/** Previous reference period for a "YYYY-YYYY" paid-leave period, or null. */
|
||||
private static function previousPeriod(string $period): ?string
|
||||
{
|
||||
if (1 !== preg_match('/^(\d{4})-(\d{4})$/', $period, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf('%d-%d', (int) $m[1] - 1, (int) $m[2] - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Admin calendar view: all pending/approved absences overlapping a date range.
|
||||
*/
|
||||
class AbsenceCalendarController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/admin/absences/calendar', name: 'absence_calendar', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$fromRaw = (string) $request->query->get('from', '');
|
||||
$toRaw = (string) $request->query->get('to', '');
|
||||
|
||||
if ('' === $fromRaw || '' === $toRaw) {
|
||||
throw new UnprocessableEntityHttpException('Query parameters "from" and "to" are required.');
|
||||
}
|
||||
|
||||
$from = new DateTimeImmutable($fromRaw);
|
||||
$to = new DateTimeImmutable($toRaw);
|
||||
|
||||
$absences = $this->requestRepository->findInRange($from, $to);
|
||||
|
||||
return $this->json($absences, context: ['groups' => ['absence_request:read']]);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Streams the justification file of an absence request. Owner or admin only.
|
||||
*/
|
||||
class AbsenceJustificationDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id): BinaryFileResponse
|
||||
{
|
||||
$absence = $this->requestRepository->findById($id);
|
||||
if (null === $absence) {
|
||||
throw new NotFoundHttpException('Absence request not found.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException('You do not have access to this file.');
|
||||
}
|
||||
|
||||
$fileName = $absence->getJustificationFileName();
|
||||
if (null === $fileName) {
|
||||
throw new NotFoundHttpException('No justification file for this request.');
|
||||
}
|
||||
|
||||
$filePath = $this->uploadDir.'/'.$fileName;
|
||||
if (!file_exists($filePath)) {
|
||||
throw new NotFoundHttpException('File not found on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
|
||||
|
||||
$disposition = (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType)
|
||||
? ResponseHeaderBag::DISPOSITION_INLINE
|
||||
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
|
||||
|
||||
$response->setContentDisposition($disposition, $fileName);
|
||||
$response->headers->set('Content-Type', $mimeType);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
/**
|
||||
* Uploads a justification file (PDF / image) for an absence request. The owner
|
||||
* or an admin may upload; the server-detected MIME type is validated.
|
||||
*/
|
||||
class AbsenceJustificationUploadController extends AbstractController
|
||||
{
|
||||
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
private const MIME_TO_EXTENSION = [
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/webp' => 'webp',
|
||||
'application/pdf' => 'pdf',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
private readonly string $uploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_upload', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$absence = $this->requestRepository->findById($id);
|
||||
if (null === $absence) {
|
||||
throw new NotFoundHttpException('Absence request not found.');
|
||||
}
|
||||
|
||||
if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) {
|
||||
throw new AccessDeniedHttpException('You can only attach a file to your own request.');
|
||||
}
|
||||
|
||||
$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 10 MB limit.');
|
||||
}
|
||||
|
||||
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
|
||||
if (!isset(self::MIME_TO_EXTENSION[$mimeType])) {
|
||||
throw new BadRequestHttpException(sprintf('File type "%s" is not allowed (PDF or image only).', $mimeType));
|
||||
}
|
||||
|
||||
$fileName = Uuid::v4()->toRfc4122().'.'.self::MIME_TO_EXTENSION[$mimeType];
|
||||
|
||||
if (!is_dir($this->uploadDir)) {
|
||||
mkdir($this->uploadDir, 0o775, true);
|
||||
}
|
||||
|
||||
// Remove a previously uploaded file if any
|
||||
$previous = $absence->getJustificationFileName();
|
||||
if (null !== $previous && file_exists($this->uploadDir.'/'.$previous)) {
|
||||
unlink($this->uploadDir.'/'.$previous);
|
||||
}
|
||||
|
||||
$file->move($this->uploadDir, $fileName);
|
||||
|
||||
$absence->setJustificationFileName($fileName);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($absence, context: ['groups' => ['absence_request:read']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
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;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Dry-run endpoint for the "new request" form: returns the number of deducted
|
||||
* days and the projected balance without creating anything. Required because
|
||||
* public holidays are computed server-side.
|
||||
*/
|
||||
class AbsencePreviewController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly AbsenceDayCalculator $calculator,
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
) {}
|
||||
|
||||
#[Route('/api/absence_requests/preview', name: 'absence_request_preview', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = json_decode($request->getContent(), true) ?? [];
|
||||
|
||||
$type = AbsenceType::tryFrom((string) ($payload['type'] ?? ''));
|
||||
if (null === $type) {
|
||||
throw new UnprocessableEntityHttpException('Unknown absence type.');
|
||||
}
|
||||
|
||||
$startRaw = (string) ($payload['startDate'] ?? '');
|
||||
$endRaw = (string) ($payload['endDate'] ?? '');
|
||||
if ('' === $startRaw || '' === $endRaw) {
|
||||
throw new UnprocessableEntityHttpException('Start date and end date are required.');
|
||||
}
|
||||
|
||||
$start = new DateTimeImmutable($startRaw);
|
||||
$end = new DateTimeImmutable($endRaw);
|
||||
if ($end < $start) {
|
||||
throw new UnprocessableEntityHttpException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($type);
|
||||
$workingDaysOnly = $policy?->isCountWorkingDaysOnly() ?? true;
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays(
|
||||
$start,
|
||||
$end,
|
||||
isset($payload['startHalfDay']) ? HalfDay::tryFrom((string) $payload['startHalfDay']) : null,
|
||||
isset($payload['endHalfDay']) ? HalfDay::tryFrom((string) $payload['endHalfDay']) : null,
|
||||
$workingDaysOnly,
|
||||
);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof UserInterface);
|
||||
|
||||
$available = null;
|
||||
$projectedAvailable = null;
|
||||
$period = null;
|
||||
|
||||
if ($type->decrementsBalance()) {
|
||||
$period = $this->balanceService->periodFor($user, $type, $start);
|
||||
$balance = $this->balanceRepository->findOneForPeriod($user, $type, $period);
|
||||
$available = $balance?->getAvailable() ?? 0.0;
|
||||
$projectedAvailable = $available - $countedDays;
|
||||
}
|
||||
|
||||
return $this->json([
|
||||
'countedDays' => $countedDays,
|
||||
'period' => $period,
|
||||
'available' => $available,
|
||||
'projectedAvailable' => $projectedAvailable,
|
||||
'justificationRequired' => $policy?->isJustificationRequired() ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Absence\Domain\Service\PublicHolidayProvider;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Exposes French public holidays so the front (calendar, date pickers) can
|
||||
* display them — the dates are computed server-side in pure PHP.
|
||||
*/
|
||||
class PublicHolidayController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PublicHolidayProvider $holidayProvider,
|
||||
) {}
|
||||
|
||||
#[Route('/api/public_holidays', name: 'public_holidays', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$fromRaw = (string) $request->query->get('from', '');
|
||||
$toRaw = (string) $request->query->get('to', '');
|
||||
|
||||
if ('' !== $fromRaw && '' !== $toRaw) {
|
||||
$fromYear = (int) new DateTimeImmutable($fromRaw)->format('Y');
|
||||
$toYear = (int) new DateTimeImmutable($toRaw)->format('Y');
|
||||
} else {
|
||||
$fromYear = $toYear = (int) ($request->query->get('year') ?: date('Y'));
|
||||
}
|
||||
|
||||
$holidays = [];
|
||||
for ($year = $fromYear; $year <= $toYear; ++$year) {
|
||||
$holidays += $this->holidayProvider->getHolidays($year);
|
||||
}
|
||||
|
||||
return $this->json($holidays);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Doctrine;
|
||||
|
||||
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 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Doctrine;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AbsenceRequest>
|
||||
*/
|
||||
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(
|
||||
UserInterface $user,
|
||||
DateTimeInterface $startDate,
|
||||
DateTimeInterface $endDate,
|
||||
?int $excludeId = null,
|
||||
): bool {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
->andWhere('a.user = :user')
|
||||
->andWhere('a.status IN (:statuses)')
|
||||
->andWhere('a.startDate <= :endDate')
|
||||
->andWhere('a.endDate >= :startDate')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
|
||||
->setParameter('startDate', $startDate->format('Y-m-d'))
|
||||
->setParameter('endDate', $endDate->format('Y-m-d'))
|
||||
;
|
||||
|
||||
if (null !== $excludeId) {
|
||||
$qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId);
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absences (approved or pending) overlapping a date range, all employees —
|
||||
* used by the admin calendar view.
|
||||
*
|
||||
* @return AbsenceRequest[]
|
||||
*/
|
||||
public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('a')
|
||||
->andWhere('a.status IN (:statuses)')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->andWhere('a.endDate >= :from')
|
||||
->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved])
|
||||
->setParameter('from', $from->format('Y-m-d'))
|
||||
->setParameter('to', $to->format('Y-m-d'))
|
||||
->orderBy('a.startDate', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AbsenceRequest[]
|
||||
*/
|
||||
public function findFiltered(
|
||||
?UserInterface $user = null,
|
||||
?AbsenceStatus $status = null,
|
||||
?AbsenceType $type = null,
|
||||
?DateTimeInterface $from = null,
|
||||
?DateTimeInterface $to = null,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('a')->orderBy('a.startDate', 'DESC');
|
||||
|
||||
if (null !== $user) {
|
||||
$qb->andWhere('a.user = :user')->setParameter('user', $user);
|
||||
}
|
||||
if (null !== $status) {
|
||||
$qb->andWhere('a.status = :status')->setParameter('status', $status);
|
||||
}
|
||||
if (null !== $type) {
|
||||
$qb->andWhere('a.type = :type')->setParameter('type', $type);
|
||||
}
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.endDate >= :from')->setParameter('from', $from->format('Y-m-d'));
|
||||
}
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.startDate <= :to')->setParameter('to', $to->format('Y-m-d'));
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'cancel-absence-request', description: 'Cancel an absence request. A PENDING request releases its reserved days; an APPROVED request can only be cancelled by an admin and credits the taken days back. Already cancelled/rejected requests cannot be cancelled.')]
|
||||
class CancelAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
|
||||
$status = $request->getStatus();
|
||||
|
||||
if (AbsenceStatus::Pending === $status) {
|
||||
$this->balanceService->release($request, false);
|
||||
} elseif (AbsenceStatus::Approved === $status) {
|
||||
if (!$isAdmin) {
|
||||
throw new AccessDeniedException('Only an admin can cancel an approved request.');
|
||||
}
|
||||
$this->balanceService->release($request, true);
|
||||
} else {
|
||||
throw new InvalidArgumentException('This request can no longer be cancelled.');
|
||||
}
|
||||
|
||||
$request->setStatus(AbsenceStatus::Cancelled);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-absence-request', description: 'Create an absence request on behalf of an employee (userId). Validates active policy + no overlap, computes deducted working days, sets status=pending and reserves the days in the pending balance. type: cp|mariage_pacs|conge_parental|deces|maladie. Dates YYYY-MM-DD. halfDay (matin|apres_midi) on a boundary subtracts 0.5.')]
|
||||
class CreateAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceDayCalculator $calculator,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $userId,
|
||||
string $type,
|
||||
string $startDate,
|
||||
string $endDate,
|
||||
?string $startHalfDay = null,
|
||||
?string $endHalfDay = null,
|
||||
?string $reason = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->findById($userId)
|
||||
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
|
||||
$typeEnum = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
|
||||
$start = new DateTimeImmutable($startDate);
|
||||
$end = new DateTimeImmutable($endDate);
|
||||
if ($end < $start) {
|
||||
throw new InvalidArgumentException('End date must be on or after start date.');
|
||||
}
|
||||
|
||||
$startHd = null !== $startHalfDay
|
||||
? (HalfDay::tryFrom($startHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $startHalfDay)))
|
||||
: null;
|
||||
$endHd = null !== $endHalfDay
|
||||
? (HalfDay::tryFrom($endHalfDay) ?? throw new InvalidArgumentException(sprintf('Unknown half day "%s".', $endHalfDay)))
|
||||
: null;
|
||||
|
||||
$policy = $this->policyRepository->findOneByType($typeEnum);
|
||||
if (null === $policy || !$policy->isActive()) {
|
||||
throw new InvalidArgumentException('This absence type is not available.');
|
||||
}
|
||||
|
||||
// Bereavement has no fixed entitlement: the relationship to the deceased
|
||||
// drives the legal number of days, so the reason is mandatory.
|
||||
if (AbsenceType::Bereavement === $typeEnum && '' === trim((string) $reason)) {
|
||||
throw new InvalidArgumentException('A reason (relationship to the deceased) is required for bereavement leave.');
|
||||
}
|
||||
|
||||
if ($this->requestRepository->hasOverlap($user, $start, $end)) {
|
||||
throw new InvalidArgumentException('This request overlaps an existing absence.');
|
||||
}
|
||||
|
||||
$countedDays = $this->calculator->countWorkingDays($start, $end, $startHd, $endHd, $policy->isCountWorkingDaysOnly());
|
||||
if ($countedDays <= 0.0) {
|
||||
throw new InvalidArgumentException('The selected range contains no working day.');
|
||||
}
|
||||
|
||||
$request = new AbsenceRequest();
|
||||
$request->setUser($user);
|
||||
$request->setType($typeEnum);
|
||||
$request->setStartDate($start);
|
||||
$request->setEndDate($end);
|
||||
$request->setStartHalfDay($startHd);
|
||||
$request->setEndHalfDay($endHd);
|
||||
$request->setReason($reason);
|
||||
$request->setCountedDays($countedDays);
|
||||
$request->setStatus(AbsenceStatus::Pending);
|
||||
$request->setRejectionReason(null);
|
||||
$request->setCreatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($request);
|
||||
$this->balanceService->reservePending($request);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-absence-request', description: 'Permanently delete an absence request (admin). Does not adjust balances — use cancel-absence-request first if the days must be credited back.')]
|
||||
class DeleteAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($request);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(['success' => true, 'message' => sprintf('AbsenceRequest %d deleted.', $id)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-absence-request', description: 'Get a single absence request by ID.')]
|
||||
class GetAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'list-absence-balances', description: 'List leave balances. Optional filters: userId, type (cp, mariage_pacs, conge_parental, deces, maladie), period (e.g. "2025-2026" or "2025").')]
|
||||
class ListAbsenceBalancesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(?int $userId = null, ?string $type = null, ?string $period = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$criteria = [];
|
||||
if (null !== $userId) {
|
||||
$user = $this->userRepository->findById($userId);
|
||||
if (null === $user) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
}
|
||||
$criteria['user'] = $user;
|
||||
}
|
||||
if (null !== $type) {
|
||||
$criteria['type'] = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
}
|
||||
if (null !== $period) {
|
||||
$criteria['period'] = $period;
|
||||
}
|
||||
|
||||
$balances = $this->balanceRepository->findBy($criteria, ['period' => 'DESC']);
|
||||
|
||||
return json_encode(array_map(Serializer::absenceBalance(...), $balances));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
|
||||
#[McpTool(name: 'list-absence-policies', description: 'List all absence policies (one per absence type) with their rules: days/year, days/event, notice period, working-days mode, active flag.')]
|
||||
class ListAbsencePoliciesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$policies = $this->policyRepository->findBy([], ['type' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(Serializer::absencePolicy(...), $policies));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'list-absence-requests', description: 'List absence requests. Optional filters: userId, status (pending, approved, rejected, cancelled), type (cp, mariage_pacs, conge_parental, deces, maladie), from/to (YYYY-MM-DD, overlap window). Without userId returns all employees.')]
|
||||
class ListAbsenceRequestsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly UserRepositoryInterface $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $userId = null,
|
||||
?string $status = null,
|
||||
?string $type = null,
|
||||
?string $from = null,
|
||||
?string $to = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$user = null;
|
||||
if (null !== $userId) {
|
||||
$user = $this->userRepository->findById($userId)
|
||||
?? throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
}
|
||||
|
||||
$statusEnum = null;
|
||||
if (null !== $status) {
|
||||
$statusEnum = AbsenceStatus::tryFrom($status)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown status "%s".', $status));
|
||||
}
|
||||
|
||||
$typeEnum = null;
|
||||
if (null !== $type) {
|
||||
$typeEnum = AbsenceType::tryFrom($type)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown absence type "%s".', $type));
|
||||
}
|
||||
|
||||
$requests = $this->requestRepository->findFiltered(
|
||||
$user,
|
||||
$statusEnum,
|
||||
$typeEnum,
|
||||
null !== $from ? new DateTimeImmutable($from) : null,
|
||||
null !== $to ? new DateTimeImmutable($to) : null,
|
||||
);
|
||||
|
||||
return json_encode(array_map(Serializer::absenceRequest(...), $requests));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function assert;
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'review-absence-request', description: 'Approve or reject a PENDING absence request (admin). decision = "approve" or "reject"; rejectionReason is required when rejecting. Approving moves the days from pending to taken; rejecting releases the reserved days.')]
|
||||
class ReviewAbsenceRequestTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AbsenceRequestRepositoryInterface $requestRepository,
|
||||
private readonly AbsenceBalanceService $balanceService,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, string $decision, ?string $rejectionReason = null): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
if (!in_array($decision, ['approve', 'reject'], true)) {
|
||||
throw new InvalidArgumentException('decision must be "approve" or "reject".');
|
||||
}
|
||||
|
||||
$request = $this->requestRepository->findById($id);
|
||||
if (null === $request) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceRequest with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (AbsenceStatus::Pending !== $request->getStatus()) {
|
||||
throw new InvalidArgumentException('Only a pending request can be reviewed.');
|
||||
}
|
||||
|
||||
$admin = $this->security->getUser();
|
||||
assert($admin instanceof UserInterface);
|
||||
|
||||
if ('approve' === $decision) {
|
||||
// Never let an approval push the balance below zero (CP only).
|
||||
$available = $this->balanceService->availableForRequest($request);
|
||||
if (null !== $available && $request->getCountedDays() > $available + 1e-9) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Approving this request would put the balance below zero: %g day(s) requested but only %g available.',
|
||||
$request->getCountedDays(),
|
||||
$available,
|
||||
));
|
||||
}
|
||||
|
||||
$request->setStatus(AbsenceStatus::Approved);
|
||||
$request->setRejectionReason(null);
|
||||
$this->balanceService->applyApproval($request);
|
||||
} else {
|
||||
if (null === $rejectionReason || '' === trim($rejectionReason)) {
|
||||
throw new InvalidArgumentException('A reason is required when rejecting a request.');
|
||||
}
|
||||
$request->setStatus(AbsenceStatus::Rejected);
|
||||
$request->setRejectionReason($rejectionReason);
|
||||
$this->balanceService->release($request, false);
|
||||
}
|
||||
|
||||
$request->setReviewedAt(new DateTimeImmutable());
|
||||
$request->setReviewedBy($admin);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceRequest($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-absence-balance', description: 'Manually adjust a leave balance (admin regularisation). Only provided buckets change: acquired (N-1), acquiring (N), taken.')]
|
||||
class UpdateAbsenceBalanceTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsenceBalanceRepositoryInterface $balanceRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?float $acquired = null,
|
||||
?float $acquiring = null,
|
||||
?float $taken = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$balance = $this->balanceRepository->findById($id);
|
||||
if (null === $balance) {
|
||||
throw new InvalidArgumentException(sprintf('AbsenceBalance with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $acquired) {
|
||||
$balance->setAcquired($acquired);
|
||||
}
|
||||
if (null !== $acquiring) {
|
||||
$balance->setAcquiring($acquiring);
|
||||
}
|
||||
if (null !== $taken) {
|
||||
$balance->setTaken($taken);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absenceBalance($balance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Absence\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface;
|
||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-absence-policy', description: 'Update an absence policy (admin). Only provided fields change.')]
|
||||
class UpdateAbsencePolicyTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AbsencePolicyRepositoryInterface $policyRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?float $daysPerYear = null,
|
||||
?float $daysPerEvent = null,
|
||||
?bool $justificationRequired = null,
|
||||
?int $noticeDays = null,
|
||||
?bool $countWorkingDaysOnly = null,
|
||||
?bool $active = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$policy = $this->policyRepository->findById($id);
|
||||
if (null === $policy) {
|
||||
throw new InvalidArgumentException(sprintf('AbsencePolicy with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $daysPerYear) {
|
||||
$policy->setDaysPerYear($daysPerYear);
|
||||
}
|
||||
if (null !== $daysPerEvent) {
|
||||
$policy->setDaysPerEvent($daysPerEvent);
|
||||
}
|
||||
if (null !== $justificationRequired) {
|
||||
$policy->setJustificationRequired($justificationRequired);
|
||||
}
|
||||
if (null !== $noticeDays) {
|
||||
$policy->setNoticeDays($noticeDays);
|
||||
}
|
||||
if (null !== $countWorkingDaysOnly) {
|
||||
$policy->setCountWorkingDaysOnly($countWorkingDaysOnly);
|
||||
}
|
||||
if (null !== $active) {
|
||||
$policy->setActive($active);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::absencePolicy($policy));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
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;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
provider: NotificationProvider::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['notification:read']],
|
||||
denormalizationContext: ['groups' => ['notification:write']],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[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
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\Column(length: 50)]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?string $type = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?string $message = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['notification:read', 'notification:write'])]
|
||||
private bool $isRead = false;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['notification:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): static
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMessage(): ?string
|
||||
{
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
public function setMessage(string $message): static
|
||||
{
|
||||
$this->message = $message;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->isRead;
|
||||
}
|
||||
|
||||
public function setIsRead(bool $isRead): static
|
||||
{
|
||||
$this->isRead = $isRead;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
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\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;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/me',
|
||||
provider: MeProvider::class,
|
||||
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']],
|
||||
)]
|
||||
#[Auditable]
|
||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
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', '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', 'commercial_report:read'])]
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 100, nullable: true)]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?string $lastName = null;
|
||||
|
||||
/** @var list<string> */
|
||||
#[ORM\Column]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private array $roles = [];
|
||||
|
||||
#[ORM\Column]
|
||||
#[AuditIgnore]
|
||||
private ?string $password = null;
|
||||
|
||||
#[Groups(['user:write'])]
|
||||
#[AuditIgnore]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
||||
#[Groups(['me:read'])]
|
||||
#[AuditIgnore]
|
||||
private ?string $apiToken = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $avatarFileName = null;
|
||||
|
||||
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||
|
||||
/** Whether this user is an employee subject to absence management. */
|
||||
#[ORM\Column]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private bool $isEmployee = false;
|
||||
|
||||
/** Hiring date — start of paid-leave acquisition. */
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?DateTimeImmutable $hireDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?DateTimeImmutable $endDate = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private ?ContractType $contractType = null;
|
||||
|
||||
/** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private float $workTimeRatio = 1.0;
|
||||
|
||||
/** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private float $annualLeaveDays = 25.0;
|
||||
|
||||
/** Reference period start as MM-DD (default 06-01, 1st of June). */
|
||||
#[ORM\Column(length: 5)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||
private string $referencePeriodStart = '06-01';
|
||||
|
||||
/** Paid-leave already acquired when the module is rolled out. */
|
||||
#[ORM\Column(type: Types::FLOAT)]
|
||||
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
|
||||
#[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->rbacRoles = new ArrayCollection();
|
||||
$this->directPermissions = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(string $username): static
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
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 getUserIdentifier(): string
|
||||
{
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
|
||||
// Every authenticated user gets ROLE_USER.
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
/** @param list<string> $roles */
|
||||
public function setRoles(array $roles): static
|
||||
{
|
||||
$this->roles = $roles;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPassword(): ?string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): static
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): static
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getApiToken(): ?string
|
||||
{
|
||||
return $this->apiToken;
|
||||
}
|
||||
|
||||
public function setApiToken(?string $apiToken): static
|
||||
{
|
||||
$this->apiToken = $apiToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAvatarFileName(): ?string
|
||||
{
|
||||
return $this->avatarFileName;
|
||||
}
|
||||
|
||||
public function setAvatarFileName(?string $avatarFileName): static
|
||||
{
|
||||
$this->avatarFileName = $avatarFileName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
|
||||
public function getAvatarUrl(): ?string
|
||||
{
|
||||
if (null === $this->avatarFileName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '/api/users/'.$this->id.'/avatar';
|
||||
}
|
||||
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $plainPassword): static
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
public function getIsEmployee(): bool
|
||||
{
|
||||
return $this->isEmployee;
|
||||
}
|
||||
|
||||
public function setIsEmployee(bool $isEmployee): static
|
||||
{
|
||||
$this->isEmployee = $isEmployee;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHireDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->hireDate;
|
||||
}
|
||||
|
||||
public function setHireDate(?DateTimeImmutable $hireDate): static
|
||||
{
|
||||
$this->hireDate = $hireDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEndDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(?DateTimeImmutable $endDate): static
|
||||
{
|
||||
$this->endDate = $endDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContractType(): ?ContractType
|
||||
{
|
||||
return $this->contractType;
|
||||
}
|
||||
|
||||
public function setContractType(?ContractType $contractType): static
|
||||
{
|
||||
$this->contractType = $contractType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getWorkTimeRatio(): float
|
||||
{
|
||||
return $this->workTimeRatio;
|
||||
}
|
||||
|
||||
public function setWorkTimeRatio(float $workTimeRatio): static
|
||||
{
|
||||
$this->workTimeRatio = $workTimeRatio;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAnnualLeaveDays(): float
|
||||
{
|
||||
return $this->annualLeaveDays;
|
||||
}
|
||||
|
||||
public function setAnnualLeaveDays(float $annualLeaveDays): static
|
||||
{
|
||||
$this->annualLeaveDays = $annualLeaveDays;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReferencePeriodStart(): string
|
||||
{
|
||||
return $this->referencePeriodStart;
|
||||
}
|
||||
|
||||
public function setReferencePeriodStart(string $referencePeriodStart): static
|
||||
{
|
||||
$this->referencePeriodStart = $referencePeriodStart;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInitialLeaveBalance(): float
|
||||
{
|
||||
return $this->initialLeaveBalance;
|
||||
}
|
||||
|
||||
public function setInitialLeaveBalance(float $initialLeaveBalance): static
|
||||
{
|
||||
$this->initialLeaveBalance = $initialLeaveBalance;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Domain\Enum;
|
||||
|
||||
enum ContractType: string
|
||||
{
|
||||
case Cdi = 'CDI';
|
||||
case Cdd = 'CDD';
|
||||
case Internship = 'STAGE';
|
||||
case Apprentice = 'ALTERNANCE';
|
||||
case Other = 'AUTRE';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Cdi => 'CDI',
|
||||
self::Cdd => 'CDD',
|
||||
self::Internship => 'Stage',
|
||||
self::Apprentice => 'Alternance',
|
||||
self::Other => 'Autre',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<User>
|
||||
*/
|
||||
final readonly class MeProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): User
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof User) {
|
||||
throw new UnauthorizedHttpException('Bearer', 'Not authenticated.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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\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;
|
||||
|
||||
use function count;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Notification>
|
||||
*/
|
||||
final readonly class NotificationProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private DoctrineNotificationRepository $notificationRepository,
|
||||
private Pagination $pagination,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
[$page, $offset, $limit] = $this->pagination->getPagination($operation, $context);
|
||||
|
||||
$queryBuilder = $this->notificationRepository
|
||||
->createUserNotificationsQueryBuilder($user)
|
||||
->setFirstResult($offset)
|
||||
->setMaxResults($limit)
|
||||
;
|
||||
|
||||
$doctrinePaginator = new DoctrinePaginator($queryBuilder);
|
||||
|
||||
return new TraversablePaginator(
|
||||
new ArrayIterator(iterator_to_array($doctrinePaginator, false)),
|
||||
$page,
|
||||
$limit,
|
||||
(float) count($doctrinePaginator),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<User, User>
|
||||
*/
|
||||
final readonly class UserPasswordHasherProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* @param ProcessorInterface<User, User> $persistProcessor
|
||||
*/
|
||||
public function __construct(
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param User $data
|
||||
*/
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
$plainPassword = $data->getPlainPassword();
|
||||
|
||||
if (null !== $plainPassword && '' !== $plainPassword) {
|
||||
$data->setPassword($this->passwordHasher->hashPassword($data, $plainPassword));
|
||||
$data->setPlainPassword(null);
|
||||
}
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Controller;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class MarkAllReadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
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 UserInterface $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$this->notificationRepository->markAllReadByUser($user);
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Controller;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class NotificationUnreadCountController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
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 UserInterface $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$count = $this->notificationRepository->countUnreadByUser($user);
|
||||
|
||||
return new JsonResponse(['count' => $count]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function bin2hex;
|
||||
use function random_bytes;
|
||||
|
||||
class RegenerateApiTokenController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$user->setApiToken($token);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['apiToken' => $token]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Controller;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class UserAvatarController extends AbstractController
|
||||
{
|
||||
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly string $avatarUploadDir,
|
||||
) {}
|
||||
|
||||
#[Route('/api/users/{id}/avatar', name: 'user_avatar_upload', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function upload(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->findUserOrFail($id);
|
||||
$this->assertCanManageAvatar($user);
|
||||
|
||||
$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 5 MB limit.');
|
||||
}
|
||||
|
||||
$mimeType = $file->getMimeType();
|
||||
|
||||
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) {
|
||||
throw new BadRequestHttpException('Invalid file type. Allowed: JPEG, PNG, WebP, GIF.');
|
||||
}
|
||||
|
||||
// Delete previous avatar file if exists
|
||||
$this->deleteAvatarFile($user);
|
||||
|
||||
$extension = $file->guessExtension() ?? 'bin';
|
||||
$fileName = Uuid::v4()->toRfc4122().'.'.$extension;
|
||||
|
||||
if (!is_dir($this->avatarUploadDir)) {
|
||||
mkdir($this->avatarUploadDir, 0o775, true);
|
||||
}
|
||||
|
||||
$file->move($this->avatarUploadDir, $fileName);
|
||||
|
||||
$user->setAvatarFileName($fileName);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['avatarUrl' => $user->getAvatarUrl()]);
|
||||
}
|
||||
|
||||
#[Route('/api/users/{id}/avatar', name: 'user_avatar_serve', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function serve(int $id): BinaryFileResponse
|
||||
{
|
||||
$user = $this->findUserOrFail($id);
|
||||
|
||||
if (null === $user->getAvatarFileName()) {
|
||||
throw new NotFoundHttpException('No avatar set.');
|
||||
}
|
||||
|
||||
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
|
||||
|
||||
if (!file_exists($filePath)) {
|
||||
throw new NotFoundHttpException('Avatar file not found on disk.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($filePath);
|
||||
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $user->getAvatarFileName());
|
||||
$extension = pathinfo($user->getAvatarFileName(), PATHINFO_EXTENSION);
|
||||
$mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp', 'gif' => 'image/gif'];
|
||||
$response->headers->set('Content-Type', $mimeMap[$extension] ?? 'application/octet-stream');
|
||||
$response->headers->set('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/api/users/{id}/avatar', name: 'user_avatar_delete', methods: ['DELETE'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function delete(int $id): Response
|
||||
{
|
||||
$user = $this->findUserOrFail($id);
|
||||
$this->assertCanManageAvatar($user);
|
||||
|
||||
$this->deleteAvatarFile($user);
|
||||
$user->setAvatarFileName(null);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
private function findUserOrFail(int $id): User
|
||||
{
|
||||
$user = $this->entityManager->getRepository(User::class)->find($id);
|
||||
|
||||
if (null === $user) {
|
||||
throw new NotFoundHttpException('User not found.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function assertCanManageAvatar(User $user): void
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
|
||||
if ($currentUser !== $user && !$this->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedHttpException('You can only manage your own avatar.');
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAvatarFile(User $user): void
|
||||
{
|
||||
if (null === $user->getAvatarFileName()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$filePath = $this->avatarUploadDir.'/'.$user->getAvatarFileName();
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
unlink($filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Doctrine;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Notification>
|
||||
*/
|
||||
class DoctrineNotificationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Notification::class);
|
||||
}
|
||||
|
||||
public function createUserNotificationsQueryBuilder(UserInterface $user): QueryBuilder
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->where('n.user = :user')
|
||||
->setParameter('user', $user)
|
||||
->orderBy('n.createdAt', 'DESC')
|
||||
;
|
||||
}
|
||||
|
||||
public function countUnreadByUser(SharedUserInterface $user): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('n')
|
||||
->select('COUNT(n.id)')
|
||||
->where('n.user = :user')
|
||||
->andWhere('n.isRead = false')
|
||||
->setParameter('user', $user)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function markAllReadByUser(SharedUserInterface $user): int
|
||||
{
|
||||
return $this->createQueryBuilder('n')
|
||||
->update()
|
||||
->set('n.isRead', 'true')
|
||||
->where('n.user = :user')
|
||||
->andWhere('n.isRead = false')
|
||||
->setParameter('user', $user)
|
||||
->getQuery()
|
||||
->execute()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Doctrine;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
$sql = 'SELECT id FROM "user" WHERE roles::text LIKE :role';
|
||||
$ids = $conn->executeQuery($sql, ['role' => '%"'.$role.'"%'])->fetchFirstColumn();
|
||||
|
||||
if ([] === $ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->createQueryBuilder('u')
|
||||
->where('u.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Employees active on the given date (hired on/before it, not yet left).
|
||||
*
|
||||
* @return list<UserInterface>
|
||||
*/
|
||||
public function findActiveEmployees(DateTimeInterface $date): array
|
||||
{
|
||||
$dateStr = $date->format('Y-m-d');
|
||||
|
||||
return $this->createQueryBuilder('u')
|
||||
->where('u.isEmployee = true')
|
||||
->andWhere('u.hireDate IS NULL OR u.hireDate <= :date')
|
||||
->andWhere('u.endDate IS NULL OR u.endDate >= :date')
|
||||
->setParameter('date', $dateStr)
|
||||
->orderBy('u.username', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findOneByUsername(string $username): ?UserInterface
|
||||
{
|
||||
return $this->findOneBy(['username' => $username]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-user', description: 'Get a user by ID with full HR profile (employee flag, hire/end date, contract, work-time ratio, leave entitlement, reference period, family situation).')]
|
||||
class GetUserTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->find($id);
|
||||
if (null === $user) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
return json_encode(Serializer::userFull($user));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
|
||||
class ListUsersTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($user) => [
|
||||
'id' => $user->getId(),
|
||||
'username' => $user->getUsername(),
|
||||
], $users));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Mcp\Tool;
|
||||
|
||||
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;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
|
||||
class UpdateUserTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?bool $isEmployee = null,
|
||||
?string $hireDate = null,
|
||||
?string $endDate = null,
|
||||
?string $contractType = null,
|
||||
?float $workTimeRatio = null,
|
||||
?float $annualLeaveDays = null,
|
||||
?string $referencePeriodStart = null,
|
||||
?float $initialLeaveBalance = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->find($id);
|
||||
if (null === $user) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $isEmployee) {
|
||||
$user->setIsEmployee($isEmployee);
|
||||
}
|
||||
if (null !== $hireDate) {
|
||||
$user->setHireDate(new DateTimeImmutable($hireDate));
|
||||
}
|
||||
if (null !== $endDate) {
|
||||
$user->setEndDate(new DateTimeImmutable($endDate));
|
||||
}
|
||||
if (null !== $contractType) {
|
||||
$user->setContractType(
|
||||
ContractType::tryFrom($contractType)
|
||||
?? throw new InvalidArgumentException(sprintf('Unknown contract type "%s".', $contractType)),
|
||||
);
|
||||
}
|
||||
if (null !== $workTimeRatio) {
|
||||
$user->setWorkTimeRatio($workTimeRatio);
|
||||
}
|
||||
if (null !== $annualLeaveDays) {
|
||||
$user->setAnnualLeaveDays($annualLeaveDays);
|
||||
}
|
||||
if (null !== $referencePeriodStart) {
|
||||
$user->setReferencePeriodStart($referencePeriodStart);
|
||||
}
|
||||
if (null !== $initialLeaveBalance) {
|
||||
$user->setInitialLeaveBalance($initialLeaveBalance);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::userFull($user));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\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\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')"),
|
||||
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' => ['client:read']],
|
||||
denormalizationContext: ['groups' => ['client:write']],
|
||||
order: ['name' => 'ASC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: DoctrineClientRepository::class)]
|
||||
class Client implements ClientInterface, TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['client:read', 'project:read', 'user:list'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['client:read', 'client:write', 'project:read', 'user:list'])]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write'])]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column(length: 50, nullable: true)]
|
||||
#[Groups(['client:read', 'client:write'])]
|
||||
private ?string $phone = null;
|
||||
|
||||
/** @var Collection<int, ProjectInterface> */
|
||||
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
|
||||
private Collection $projects;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->projects = new ArrayCollection();
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
/** @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Directory\Infrastructure\Doctrine;
|
||||
|
||||
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<CommercialReport>
|
||||
*/
|
||||
final class DoctrineCommercialReportRepository extends ServiceEntityRepository implements CommercialReportRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, CommercialReport::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?CommercialReport
|
||||
{
|
||||
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\Contact;
|
||||
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Contact>
|
||||
*/
|
||||
final class DoctrineContactRepository extends ServiceEntityRepository implements ContactRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Contact::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Contact
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user