feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)

First business module of Phase 2 (LST-64, rodage). Strangler-style,
additive move — no behavioural change to the public API or MCP tools.

- New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking",
  declares time-tracking.entries.view/export permissions in the RBAC catalog;
  operation security left on ROLE_USER, not re-wired here).
- Move TimeEntry entity, repository (now interface + Doctrine impl bound in
  services.yaml), ActiveTimeEntryProvider, export service/controller and the
  4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security,
  uriTemplates /time_entries/*), filters and serialization groups preserved.
- Doctrine mapping "TimeTracking" added; table time_entry unchanged.
- Sidebar item gated with module "time-tracking" (SidebarFilter disables the
  route when the module is inactive).
- Timestampable/Blamable adopted (first adopter): additive migration adds
  created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) +
  COMMENT ON COLUMN. Functional test confirms created_at on persist and
  updated_at refresh on update — the suspected preUpdate recompute issue does
  not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate).

159 tests green, schema mapping valid, php-cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-20 16:16:13 +02:00
parent a88cb1bc35
commit d1516c3f5d
19 changed files with 298 additions and 36 deletions
+2
View File
@@ -8,7 +8,9 @@ declare(strict_types=1);
*/
use App\Module\Core\CoreModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [
CoreModule::class,
TimeTrackingModule::class,
];
+5
View File
@@ -34,6 +34,11 @@ doctrine:
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
TimeTracking:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
prefix: 'App\Module\TimeTracking\Domain\Entity'
controller_resolver:
auto_mapping: false
+2
View File
@@ -73,4 +73,6 @@ services:
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+1 -1
View File
@@ -22,7 +22,7 @@ return [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking'],
],
],
[
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* TimeTracking module (2.1): add Timestampable/Blamable columns to time_entry.
*
* TimeEntry is the first adopter of TimestampableBlamableTrait. This migration
* is purely additive — nullable columns + nullable FK to "user" with
* ON DELETE SET NULL. No DROP/ALTER on existing data. Columns are lowercase
* snake_case. Hand-written to guarantee zero destructive instruction.
*/
final class Version20260620161036 extends AbstractMigration
{
public function getDescription(): string
{
return 'TimeTracking: add timestampable/blamable columns to time_entry (additive)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE time_entry ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD updated_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0CDE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C16FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E537C0CDE12AB56 ON time_entry (created_by)');
$this->addSql('CREATE INDEX IDX_6E537C0C16FE72E1 ON time_entry (updated_by)');
$this->addSql("COMMENT ON COLUMN time_entry.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN time_entry.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN time_entry.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN time_entry.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0CDE12AB56');
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C16FE72E1');
$this->addSql('DROP INDEX IDX_6E537C0CDE12AB56');
$this->addSql('DROP INDEX IDX_6E537C0C16FE72E1');
$this->addSql('ALTER TABLE time_entry DROP created_at');
$this->addSql('ALTER TABLE time_entry DROP updated_at');
$this->addSql('ALTER TABLE time_entry DROP created_by');
$this->addSql('ALTER TABLE time_entry DROP updated_by');
}
}
+1 -1
View File
@@ -17,7 +17,6 @@ use App\Entity\TaskPriority;
use App\Entity\TaskRecurrence;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Entity\Workflow;
use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
@@ -27,6 +26,7 @@ use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\Bundle\FixturesBundle\Fixture;
+1 -1
View File
@@ -16,8 +16,8 @@ use App\Entity\TaskGroup;
use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\TaskTag;
use App\Entity\TimeEntry;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use Doctrine\Common\Collections\Collection;
/**
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Entity;
namespace App\Module\TimeTracking\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
@@ -13,9 +13,15 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\TimeEntryRepository;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskTag;
use App\Module\TimeTracking\Infrastructure\ApiPlatform\State\ActiveTimeEntryProvider;
use App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\State\ActiveTimeEntryProvider;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -52,10 +58,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
#[ORM\Entity(repositoryClass: TimeEntryRepository::class)]
#[ORM\Entity(repositoryClass: DoctrineTimeEntryRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
class TimeEntry
class TimeEntry implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Module\TimeTracking\Domain\Repository;
use App\Entity\Project;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\QueryBuilder;
interface TimeEntryRepositoryInterface
{
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder;
public function findById(int $id): ?TimeEntry;
public function findActiveByUser(UserInterface $user): ?TimeEntry;
/**
* @param null|UserInterface[] $users
* @param null|Project[] $projects
* @param null|int[] $tagIds
*
* @return TimeEntry[]
*/
public function findForExport(
DateTimeImmutable $after,
DateTimeImmutable $before,
?array $users = null,
?array $projects = null,
?array $tagIds = null,
): array;
}
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\State;
namespace App\Module\TimeTracking\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\TimeEntry;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
@@ -17,7 +17,7 @@ final readonly class ActiveTimeEntryProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private TimeEntryRepository $timeEntryRepository,
private TimeEntryRepositoryInterface $timeEntryRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
@@ -2,12 +2,12 @@
declare(strict_types=1);
namespace App\Controller;
namespace App\Module\TimeTracking\Infrastructure\Controller;
use App\Entity\Project;
use App\Module\Core\Domain\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
@@ -2,10 +2,11 @@
declare(strict_types=1);
namespace App\Repository;
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
use App\Entity\Project;
use App\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -14,13 +15,18 @@ use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<TimeEntry>
*/
class TimeEntryRepository extends ServiceEntityRepository
class DoctrineTimeEntryRepository extends ServiceEntityRepository implements TimeEntryRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TimeEntry::class);
}
public function findById(int $id): ?TimeEntry
{
return $this->find($id);
}
public function findActiveByUser(UserInterface $user): ?TimeEntry
{
return $this->findOneBy([
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Service;
namespace App\Module\TimeTracking\Infrastructure\Export;
use App\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -2,15 +2,15 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Entity\TimeEntry;
use App\Mcp\Tool\Serializer;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -29,7 +29,7 @@ class CreateTimeEntryTool
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly Security $security,
) {}
@@ -2,9 +2,9 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
@@ -17,7 +17,7 @@ use function sprintf;
class DeleteTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
@@ -28,7 +28,7 @@ class DeleteTimeEntryTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
$entry = $this->timeEntryRepository->findById($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
@@ -2,10 +2,10 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Repository\TimeEntryRepository;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use DateTimeImmutable;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
class ListTimeEntriesTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly Security $security,
) {}
@@ -2,13 +2,13 @@
declare(strict_types=1);
namespace App\Mcp\Tool\TimeEntry;
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
use App\Mcp\Tool\Serializer;
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
use App\Repository\ProjectRepository;
use App\Repository\TaskRepository;
use App\Repository\TaskTagRepository;
use App\Repository\TimeEntryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -22,7 +22,7 @@ use function sprintf;
class UpdateTimeEntryTool
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
private readonly ProjectRepository $projectRepository,
private readonly TaskRepository $taskRepository,
private readonly TaskTagRepository $taskTagRepository,
@@ -47,7 +47,7 @@ class UpdateTimeEntryTool
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$entry = $this->timeEntryRepository->find($id);
$entry = $this->timeEntryRepository->findById($id);
if (null === $entry) {
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\TimeTracking;
use App\Shared\Domain\Module\ModuleInterface;
final class TimeTrackingModule implements ModuleInterface
{
public static function id(): string
{
return 'time-tracking';
}
public static function label(): string
{
return 'Suivi des temps';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module TimeTracking (2.1).
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste en ROLE_USER (non recâblée ici).
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
];
}
}
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\TimeTracking;
use App\Module\Core\Domain\Entity\User;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* TimeEntry is the first adopter of TimestampableBlamableTrait.
* Verifies the TimestampableBlamableSubscriber populates created_at on
* persist and refreshes updated_at on update.
*
* @internal
*/
final class TimeEntryTimestampableTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
// Clean slate for deterministic assertions: these tests are not wrapped
// in a rolled-back transaction.
$this->em->getConnection()->executeStatement("DELETE FROM time_entry WHERE title = 'ts-test-entry'");
$this->em->getConnection()->executeStatement("DELETE FROM \"user\" WHERE username = 'ts_test_user'");
}
protected function tearDown(): void
{
$this->em->getConnection()->executeStatement("DELETE FROM time_entry WHERE title = 'ts-test-entry'");
$this->em->getConnection()->executeStatement("DELETE FROM \"user\" WHERE username = 'ts_test_user'");
parent::tearDown();
unset($this->em);
}
public function testCreatedAtIsSetOnPersist(): void
{
$entry = $this->makeEntry();
$this->em->persist($entry);
$this->em->flush();
self::assertInstanceOf(DateTimeImmutable::class, $entry->getCreatedAt(), 'createdAt must be set after flush');
self::assertInstanceOf(DateTimeImmutable::class, $entry->getUpdatedAt(), 'updatedAt must be set after flush');
// Confirm it was actually persisted to the DB, not just the in-memory object.
$this->em->clear();
$reloaded = $this->em->getRepository(TimeEntry::class)->find($entry->getId());
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCreatedAt(), 'created_at must be persisted in DB');
}
public function testUpdatedAtIsRefreshedOnUpdate(): void
{
$entry = $this->makeEntry();
$this->em->persist($entry);
$this->em->flush();
$entryId = $entry->getId();
$initialUpdatedAt = $entry->getUpdatedAt();
self::assertNotNull($initialUpdatedAt);
// Force a distinguishable timestamp by backdating the persisted value,
// so the preUpdate refresh is observable even within the same second.
$this->em->getConnection()->executeStatement(
"UPDATE time_entry SET updated_at = '2000-01-01 00:00:00' WHERE id = :id",
['id' => $entryId],
);
$this->em->clear();
/** @var TimeEntry $managed */
$managed = $this->em->getRepository(TimeEntry::class)->find($entryId);
self::assertSame('2000-01-01 00:00:00', $managed->getUpdatedAt()?->format('Y-m-d H:i:s'));
// Mutate a tracked field and flush -> preUpdate must refresh updated_at.
$managed->setTitle('ts-test-entry');
$managed->setDescription('changed description');
$this->em->flush();
$this->em->clear();
/** @var TimeEntry $reloaded */
$reloaded = $this->em->getRepository(TimeEntry::class)->find($entryId);
self::assertNotNull($reloaded->getUpdatedAt());
self::assertNotSame(
'2000-01-01 00:00:00',
$reloaded->getUpdatedAt()->format('Y-m-d H:i:s'),
'updated_at must be refreshed and persisted on UPDATE',
);
}
private function makeEntry(): TimeEntry
{
$user = new User();
$user->setUsername('ts_test_user');
$user->setPassword('hashed-secret');
$user->setRoles(['ROLE_USER']);
$this->em->persist($user);
$entry = new TimeEntry();
$entry->setUser($user);
$entry->setTitle('ts-test-entry');
$entry->setStartedAt(new DateTimeImmutable());
return $entry;
}
}