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

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

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

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

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

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

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

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

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

---

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

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

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

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

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

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

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-06-23 13:50:42 +00:00
parent d0a49322e1
commit 8313c759c6
622 changed files with 24802 additions and 2864 deletions
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Shared\Application;
use App\Shared\Domain\Contract\UserInterface;
interface CurrentUserProviderInterface
{
public function getCurrentUser(): ?UserInterface;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore {}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable {}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface BlamableInterface
{
public function getCreatedBy(): ?UserInterface;
public function setCreatedBy(?UserInterface $user): void;
public function getUpdatedBy(): ?UserInterface;
public function setUpdatedBy(?UserInterface $user): void;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un client, consommé hors du module qui le possède.
*/
interface ClientInterface
{
public function getId(): ?int;
public function getName(): ?string;
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* HR leave profile of an employee, consumed by the Absence module without
* coupling it to the concrete Core User entity.
*
* Exposes only the RH getters actually used by AbsenceBalanceService and
* AccrueLeaveCommand. This is a service-level typing contract, not a Doctrine
* relation (no resolve_target_entities entry).
*/
interface LeaveProfileInterface
{
public function getWorkTimeRatio(): float;
public function getAnnualLeaveDays(): float;
public function getReferencePeriodStart(): string;
public function getInitialLeaveBalance(): float;
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(
UserInterface $user,
string $type,
string $title,
string $message,
): void;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un projet, consommé hors du module qui le possède.
*/
interface ProjectInterface
{
public function getId(): ?int;
public function getCode(): ?string;
public function getName(): ?string;
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'une tâche, consommé hors du module qui la possède.
*/
interface TaskInterface
{
public function getId(): ?int;
public function getNumber(): ?int;
public function getTitle(): ?string;
public function getProject(): ?ProjectInterface;
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un tag de tâche, consommé hors du module qui le possède.
*/
interface TaskTagInterface
{
public function getId(): ?int;
public function getLabel(): ?string;
public function getColor(): ?string;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use DateTimeImmutable;
interface TimestampableInterface
{
public function getCreatedAt(): ?DateTimeImmutable;
public function setCreatedAt(DateTimeImmutable $createdAt): void;
public function getUpdatedAt(): ?DateTimeImmutable;
public function setUpdatedAt(DateTimeImmutable $updatedAt): void;
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): ?string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function getIsEmployee(): bool;
/** @return list<string> */
public function getEffectivePermissions(): array;
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
/**
* Implemented by every `*Module` declaration class. The set of active modules
* is listed in config/modules.php and exposed via GET /api/modules.
*/
interface ModuleInterface
{
public static function id(): string;
public static function label(): string;
public static function isRequired(): bool;
/**
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array;
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Module;
use InvalidArgumentException;
final class ModuleRegistry
{
/**
* @param list<class-string> $moduleClasses
*
* @return list<string>
*/
public static function ids(array $moduleClasses): array
{
$ids = [];
foreach ($moduleClasses as $moduleClass) {
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$id = $moduleClass::id();
if (in_array($id, $ids, true)) {
throw new InvalidArgumentException(sprintf('Module ID "%s" déclaré plusieurs fois dans la configuration des modules.', $id));
}
$ids[] = $id;
}
return $ids;
}
/**
* @param list<class-string> $moduleClasses
*
* @return list<array{code: string, label: string, module: string}>
*/
public static function permissions(array $moduleClasses): array
{
$out = [];
foreach ($moduleClasses as $moduleClass) {
if (!is_a($moduleClass, ModuleInterface::class, true)) {
continue;
}
$moduleId = $moduleClass::id();
foreach ($moduleClass::permissions() as $perm) {
$code = $perm['code'];
if (!str_starts_with($code, $moduleId.'.')) {
throw new InvalidArgumentException(sprintf('Permission "%s" du module "%s" doit être préfixée par "%s.".', $code, $moduleId, $moduleId));
}
$out[] = ['code' => $code, 'label' => $perm['label'], 'module' => $moduleId];
}
}
return $out;
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Sidebar;
final class SidebarFilter
{
/**
* @param list<array{label:string, icon:string, roles?:list<string>, permission?:string, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>, permission?:string}>}> $sections
* @param list<string> $activeModuleIds
* @param list<string> $activeRoles
* @param list<string> $activePermissions
*
* @return array{sections: list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>, disabledRoutes: list<string>}
*/
public static function filter(array $sections, array $activeModuleIds, array $activeRoles = [], array $activePermissions = []): array
{
$outSections = [];
$disabledRoutes = [];
foreach ($sections as $section) {
// Gate de rôle au niveau section (ne pollue pas disabledRoutes : réservé au filtrage module).
if (!self::rolesSatisfied($section['roles'] ?? null, $activeRoles)) {
continue;
}
// Gate de permission au niveau section (RBAC fin).
if (!self::permissionSatisfied($section['permission'] ?? null, $activePermissions)) {
continue;
}
$items = [];
foreach ($section['items'] as $item) {
// Gate de rôle au niveau item.
if (!self::rolesSatisfied($item['roles'] ?? null, $activeRoles)) {
continue;
}
// Gate de permission au niveau item (RBAC fin).
if (!self::permissionSatisfied($item['permission'] ?? null, $activePermissions)) {
continue;
}
// Filtrage par module actif (pilote la redirection front via disabledRoutes).
$module = $item['module'] ?? null;
if (null !== $module && !in_array($module, $activeModuleIds, true)) {
$disabledRoutes[] = $item['to'];
continue;
}
$items[] = ['label' => $item['label'], 'to' => $item['to'], 'icon' => $item['icon']];
}
if ([] !== $items) {
$outSections[] = ['label' => $section['label'], 'icon' => $section['icon'], 'items' => $items];
}
}
return ['sections' => $outSections, 'disabledRoutes' => $disabledRoutes];
}
/**
* @param null|list<string> $required
* @param list<string> $activeRoles
*/
private static function rolesSatisfied(?array $required, array $activeRoles): bool
{
if (null === $required || [] === $required) {
return true;
}
foreach ($required as $role) {
if (in_array($role, $activeRoles, true)) {
return true;
}
}
return false;
}
/**
* @param list<string> $activePermissions
*/
private static function permissionSatisfied(?string $required, array $activePermissions): bool
{
if (null === $required || '' === $required) {
return true;
}
return in_array($required, $activePermissions, true);
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Trait;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
trait TimestampableBlamableTrait
{
#[ORM\Column(name: 'created_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\Column(name: 'updated_at', type: 'datetime_immutable', nullable: true)]
#[Groups(['timestampable:read'])]
private ?DateTimeImmutable $updatedAt = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'created_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $createdBy = null;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'updated_by', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['blamable:read'])]
private ?UserInterface $updatedBy = null;
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): void
{
$this->createdAt = $createdAt;
}
public function getUpdatedAt(): ?DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
public function getCreatedBy(): ?UserInterface
{
return $this->createdBy;
}
public function setCreatedBy(?UserInterface $user): void
{
$this->createdBy = $user;
}
public function getUpdatedBy(): ?UserInterface
{
return $this->updatedBy;
}
public function setUpdatedBy(?UserInterface $user): void
{
$this->updatedBy = $user;
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\AppVersionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/version',
normalizationContext: ['groups' => ['version:read']],
provider: AppVersionProvider::class,
),
],
)]
final class AppVersion
{
#[Groups(['version:read'])]
public string $version = '';
}
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\ModulesProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/modules',
normalizationContext: ['groups' => ['modules:read']],
provider: ModulesProvider::class,
),
],
)]
final class ModulesResource
{
/**
* @var list<string>
*/
#[Groups(['modules:read'])]
public array $modules = [];
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Shared\Infrastructure\ApiPlatform\State\SidebarProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/sidebar',
normalizationContext: ['groups' => ['sidebar:read']],
provider: SidebarProvider::class,
),
],
)]
final class SidebarResource
{
/**
* @var list<array{label:string, icon:string, items: list<array{label:string, to:string, icon:string}>}>
*/
#[Groups(['sidebar:read'])]
public array $sections = [];
/**
* @var list<string>
*/
#[Groups(['sidebar:read'])]
public array $disabledRoutes = [];
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Infrastructure\ApiPlatform\Resource\AppVersion;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class AppVersionProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%app.version%')]
private string $version,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AppVersion
{
$dto = new AppVersion();
$dto->version = $this->version;
return $dto;
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Infrastructure\ApiPlatform\Resource\ModulesResource;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class ModulesProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ModulesResource
{
/** @var list<class-string> $classes */
$classes = require $this->projectDir.'/config/modules.php';
$dto = new ModulesResource();
$dto->modules = ModuleRegistry::ids($classes);
return $dto;
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Module\ModuleRegistry;
use App\Shared\Domain\Sidebar\SidebarFilter;
use App\Shared\Infrastructure\ApiPlatform\Resource\SidebarResource;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class SidebarProvider implements ProviderInterface
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private string $projectDir,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): SidebarResource
{
/** @var list<class-string> $moduleClasses */
$moduleClasses = require $this->projectDir.'/config/modules.php';
/** @var list<array{label:string, icon:string, roles?:list<string>, items: list<array{label:string, to:string, icon:string, module?:string, roles?:list<string>}>}> $sidebar */
$sidebar = require $this->projectDir.'/config/sidebar.php';
$user = $this->security->getUser();
$roles = null !== $user ? $user->getRoles() : [];
// RBAC fin : permissions effectives du contrat. ROLE_ADMIN bypasse tout (Décision 1) :
// on lui injecte le catalogue complet des permissions déclarées pour satisfaire les gates.
if (in_array('ROLE_ADMIN', $roles, true)) {
$permissions = array_column(ModuleRegistry::permissions($moduleClasses), 'code');
} else {
$permissions = $user instanceof UserInterface ? $user->getEffectivePermissions() : [];
}
$filtered = SidebarFilter::filter($sidebar, ModuleRegistry::ids($moduleClasses), array_values($roles), $permissions);
$dto = new SidebarResource();
$dto->sections = $filtered['sections'];
$dto->disabledRoutes = $filtered['disabledRoutes'];
return $dto;
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Database;
final class ColumnCommentsCatalog
{
/**
* SQL `COMMENT ON COLUMN` statements for the 4 standard Timestampable/Blamable columns.
* Call from a migration: foreach (...) { $this->addSql($statement); }.
*
* @return list<string>
*/
public static function timestampableBlamableComments(string $table): array
{
return [
"COMMENT ON COLUMN {$table}.created_at IS 'Date de creation (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.updated_at IS 'Date de derniere modification (UTC). Rempli automatiquement (Timestampable).'",
"COMMENT ON COLUMN {$table}.created_by IS 'Auteur de la creation (FK user, SET NULL). Rempli automatiquement (Blamable).'",
"COMMENT ON COLUMN {$table}.updated_by IS 'Auteur de la derniere modification (FK user, SET NULL). Rempli automatiquement (Blamable).'",
];
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::prePersist)]
#[AsDoctrineListener(event: Events::preUpdate)]
final readonly class TimestampableBlamableSubscriber
{
public function __construct(
private CurrentUserProviderInterface $currentUserProvider,
) {}
public function prePersist(PrePersistEventArgs $args): void
{
$this->applyOnCreate($args->getObject());
}
public function preUpdate(PreUpdateEventArgs $args): void
{
$this->applyOnUpdate($args->getObject());
}
public function applyOnCreate(object $entity): void
{
$now = new DateTimeImmutable();
if ($entity instanceof TimestampableInterface) {
if (null === $entity->getCreatedAt()) {
$entity->setCreatedAt($now);
}
$entity->setUpdatedAt($now);
}
if ($entity instanceof BlamableInterface) {
$user = $this->currentUserProvider->getCurrentUser();
if (null === $entity->getCreatedBy()) {
$entity->setCreatedBy($user);
}
$entity->setUpdatedBy($user);
}
}
public function applyOnUpdate(object $entity): void
{
if ($entity instanceof TimestampableInterface) {
$entity->setUpdatedAt(new DateTimeImmutable());
}
if ($entity instanceof BlamableInterface) {
$entity->setUpdatedBy($this->currentUserProvider->getCurrentUser());
}
}
}
@@ -0,0 +1,421 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Mcp;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
use App\Module\ProjectManagement\Domain\Entity\TaskGroup;
use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TaskTagInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Common\Collections\Collection;
/**
* Shared serialization helpers for MCP tools.
*
* Keeps JSON output consistent across all tools.
*/
final class Serializer
{
/**
* @return array{id: ?int, code: ?string, name: ?string}
*/
public static function projectRef(ProjectInterface $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
];
}
/**
* @return array<string, mixed>
*/
public static function project(Project $project): array
{
return [
'id' => $project->getId(),
'code' => $project->getCode(),
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function status(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string, isFinal: bool}
*/
public static function statusFull(?TaskStatus $status): ?array
{
if (null === $status) {
return null;
}
return [
'id' => $status->getId(),
'label' => $status->getLabel(),
'color' => $status->getColor(),
'isFinal' => $status->getIsFinal(),
];
}
/**
* @return null|array{id: ?int, label: ?string, color: ?string}
*/
public static function priority(?TaskPriority $priority): ?array
{
if (null === $priority) {
return null;
}
return [
'id' => $priority->getId(),
'label' => $priority->getLabel(),
'color' => $priority->getColor(),
];
}
/**
* @return null|array{id: ?int, label: ?string}
*/
public static function effort(?TaskEffort $effort): ?array
{
if (null === $effort) {
return null;
}
return [
'id' => $effort->getId(),
'label' => $effort->getLabel(),
];
}
/**
* @return null|array{id: ?int, username: ?string}
*/
public static function user(?UserInterface $user): ?array
{
if (null === $user) {
return null;
}
return [
'id' => $user->getId(),
'username' => $user->getUsername(),
];
}
/**
* @param Collection<int, UserInterface> $users
*
* @return list<array{id: ?int, username: ?string}>
*/
public static function users(Collection $users): array
{
return $users->map(fn (UserInterface $u) => [
'id' => $u->getId(),
'username' => $u->getUsername(),
])->toArray();
}
/**
* @return null|array{id: ?int, title: ?string, color: ?string}
*/
public static function group(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'color' => $group->getColor(),
];
}
/**
* @return null|array{id: ?int, title: ?string}
*/
public static function groupRef(?TaskGroup $group): ?array
{
if (null === $group) {
return null;
}
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
];
}
/**
* Full group serialization for MCP group tools (includes description, project, archived).
*
* @return array<string, mixed>
*/
public static function groupFull(TaskGroup $group): array
{
return [
'id' => $group->getId(),
'title' => $group->getTitle(),
'description' => $group->getDescription(),
'color' => $group->getColor(),
'project' => self::projectRef($group->getProject()),
'archived' => $group->isArchived(),
];
}
/**
* @param Collection<int, TaskTagInterface> $tags
*
* @return list<array{id: ?int, label: ?string}>
*/
public static function tags(Collection $tags): array
{
return $tags->map(fn (TaskTagInterface $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
])->toArray();
}
/**
* @param Collection<int, TaskTag> $tags
*
* @return list<array{id: ?int, label: ?string, color: ?string}>
*/
public static function tagsWithColor(Collection $tags): array
{
return $tags->map(fn (TaskTag $t) => [
'id' => $t->getId(),
'label' => $t->getLabel(),
'color' => $t->getColor(),
])->toArray();
}
/**
* Compute duration in minutes between two timestamps, or null if still active.
*/
public static function durationMinutes(TimeEntry $entry): ?int
{
$started = $entry->getStartedAt();
$stopped = $entry->getStoppedAt();
if (null === $stopped || null === $started) {
return null;
}
return (int) round(($stopped->getTimestamp() - $started->getTimestamp()) / 60);
}
/**
* @return null|array{id: ?int, number: ?int, title: ?string}
*/
public static function taskRef(?TaskInterface $task): ?array
{
if (null === $task) {
return null;
}
return [
'id' => $task->getId(),
'number' => $task->getNumber(),
'title' => $task->getTitle(),
];
}
/**
* @return array<string, mixed>
*/
public static function timeEntry(TimeEntry $entry): array
{
return [
'id' => $entry->getId(),
'title' => $entry->getTitle(),
'description' => $entry->getDescription(),
'startedAt' => $entry->getStartedAt()?->format('c'),
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
'duration' => self::durationMinutes($entry),
'user' => self::user($entry->getUser()),
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
'task' => self::taskRef($entry->getTask()),
'tags' => self::tags($entry->getTags()),
];
}
/**
* @param Collection<int, TaskDocument> $documents
*
* @return list<array<string, mixed>>
*/
public static function documents(Collection $documents): array
{
return $documents->map(fn (TaskDocument $doc) => [
'id' => $doc->getId(),
'originalName' => $doc->getOriginalName(),
'mimeType' => $doc->getMimeType(),
'size' => $doc->getSize(),
'createdAt' => $doc->getCreatedAt()?->format('c'),
'uploadedBy' => self::user($doc->getUploadedBy()),
])->toArray();
}
/**
* @return array<string, mixed>
*/
public static function absenceRequest(AbsenceRequest $r): array
{
return [
'id' => $r->getId(),
'user' => self::user($r->getUser()),
'type' => $r->getType()?->value,
'typeLabel' => $r->getType()?->label(),
'startDate' => $r->getStartDate()?->format('Y-m-d'),
'endDate' => $r->getEndDate()?->format('Y-m-d'),
'startHalfDay' => $r->getStartHalfDay()?->value,
'endHalfDay' => $r->getEndHalfDay()?->value,
'countedDays' => $r->getCountedDays(),
'reason' => $r->getReason(),
'status' => $r->getStatus()->value,
'statusLabel' => $r->getStatus()->label(),
'rejectionReason' => $r->getRejectionReason(),
'justificationFileName' => $r->getJustificationFileName(),
'createdAt' => $r->getCreatedAt()?->format('c'),
'reviewedAt' => $r->getReviewedAt()?->format('c'),
'reviewedBy' => self::user($r->getReviewedBy()),
];
}
/**
* @return array<string, mixed>
*/
public static function absencePolicy(AbsencePolicy $p): array
{
return [
'id' => $p->getId(),
'type' => $p->getType()->value,
'typeLabel' => $p->getType()->label(),
'daysPerYear' => $p->getDaysPerYear(),
'daysPerEvent' => $p->getDaysPerEvent(),
'justificationRequired' => $p->isJustificationRequired(),
'noticeDays' => $p->getNoticeDays(),
'countWorkingDaysOnly' => $p->isCountWorkingDaysOnly(),
'active' => $p->isActive(),
];
}
/**
* @return array<string, mixed>
*/
public static function absenceBalance(AbsenceBalance $b): array
{
return [
'id' => $b->getId(),
'user' => self::user($b->getUser()),
'type' => $b->getType()->value,
'typeLabel' => $b->getType()->label(),
'period' => $b->getPeriod(),
'acquired' => $b->getAcquired(),
'acquiring' => $b->getAcquiring(),
'taken' => $b->getTaken(),
'pending' => $b->getPending(),
'acquiredTotal' => $b->getAcquiredTotal(),
'available' => $b->getAvailable(),
];
}
/**
* @return array<string, mixed>
*/
public static function client(Client $c): array
{
return [
'id' => $c->getId(),
'name' => $c->getName(),
'email' => $c->getEmail(),
'phone' => $c->getPhone(),
];
}
/**
* @return array<string, mixed>
*/
public static function prospect(Prospect $p): array
{
$client = $p->getConvertedClient();
return [
'id' => $p->getId(),
'name' => $p->getName(),
'company' => $p->getCompany(),
'email' => $p->getEmail(),
'phone' => $p->getPhone(),
'status' => $p->getStatus()->value,
'statusLabel' => $p->getStatus()->label(),
'source' => $p->getSource(),
'notes' => $p->getNotes(),
'convertedClient' => null === $client ? null : [
'id' => $client->getId(),
'name' => $client->getName(),
],
'createdAt' => $p->getCreatedAt()?->format('c'),
'updatedAt' => $p->getUpdatedAt()?->format('c'),
];
}
/**
* @return array<string, mixed>
*/
public static function userFull(User $u): array
{
return [
'id' => $u->getId(),
'username' => $u->getUsername(),
'roles' => $u->getRoles(),
'isEmployee' => $u->getIsEmployee(),
'hireDate' => $u->getHireDate()?->format('Y-m-d'),
'endDate' => $u->getEndDate()?->format('Y-m-d'),
'contractType' => $u->getContractType()?->value,
'workTimeRatio' => $u->getWorkTimeRatio(),
'annualLeaveDays' => $u->getAnnualLeaveDays(),
'referencePeriodStart' => $u->getReferencePeriodStart(),
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
];
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Security;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\UserInterface;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class SecurityCurrentUserProvider implements CurrentUserProviderInterface
{
public function __construct(
private Security $security,
) {}
public function getCurrentUser(): ?UserInterface
{
$user = $this->security->getUser();
return $user instanceof UserInterface ? $user : null;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Shared\Infrastructure\Service;
use RuntimeException;
use SodiumException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final class TokenEncryptor
{
private readonly string $key;
private readonly bool $configured;
public function __construct(
#[Autowire('%env(ENCRYPTION_KEY)%')]
string $encryptionKey,
) {
$key = $this->tryDecodeKey($encryptionKey);
$this->key = $key ?? '';
$this->configured = null !== $key;
}
public function encrypt(string $plaintext): string
{
$this->assertConfigured();
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key);
return sodium_bin2hex($nonce.$ciphertext);
}
public function decrypt(string $encrypted): string
{
$this->assertConfigured();
$decoded = sodium_hex2bin($encrypted);
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
if (false === $plaintext) {
throw new RuntimeException('Failed to decrypt token.');
}
return $plaintext;
}
private function tryDecodeKey(string $encryptionKey): ?string
{
if ('' === $encryptionKey) {
return null;
}
try {
$key = sodium_hex2bin($encryptionKey);
} catch (SodiumException) {
return null;
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== mb_strlen($key, '8bit')) {
return null;
}
return $key;
}
private function assertConfigured(): void
{
if (!$this->configured) {
throw new RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.');
}
}
}