diff --git a/config/modules.php b/config/modules.php index dc628c1..8ca723b 100644 --- a/config/modules.php +++ b/config/modules.php @@ -8,7 +8,9 @@ declare(strict_types=1); */ use App\Module\Core\CoreModule; +use App\Module\TimeTracking\TimeTrackingModule; return [ CoreModule::class, + TimeTrackingModule::class, ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index e5da762..0baaedd 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index bf453f6..a2f46b5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/config/sidebar.php b/config/sidebar.php index afd9826..dcdeb4d 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -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'], ], ], [ diff --git a/migrations/Version20260620161036.php b/migrations/Version20260620161036.php new file mode 100644 index 0000000..b494795 --- /dev/null +++ b/migrations/Version20260620161036.php @@ -0,0 +1,52 @@ +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'); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index ebe5aa6..1649a55 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -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; diff --git a/src/Mcp/Tool/Serializer.php b/src/Mcp/Tool/Serializer.php index 400f278..964c451 100644 --- a/src/Mcp/Tool/Serializer.php +++ b/src/Mcp/Tool/Serializer.php @@ -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; /** diff --git a/src/Entity/TimeEntry.php b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php similarity index 90% rename from src/Entity/TimeEntry.php rename to src/Module/TimeTracking/Domain/Entity/TimeEntry.php index 6b6e724..4d8d1bb 100644 --- a/src/Entity/TimeEntry.php +++ b/src/Module/TimeTracking/Domain/Entity/TimeEntry.php @@ -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] diff --git a/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php b/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php new file mode 100644 index 0000000..ebaa188 --- /dev/null +++ b/src/Module/TimeTracking/Domain/Repository/TimeEntryRepositoryInterface.php @@ -0,0 +1,35 @@ + */ -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([ diff --git a/src/Service/TimeEntryExportService.php b/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php similarity index 99% rename from src/Service/TimeEntryExportService.php rename to src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php index f078f3a..031776c 100644 --- a/src/Service/TimeEntryExportService.php +++ b/src/Module/TimeTracking/Infrastructure/Export/TimeEntryExportService.php @@ -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; diff --git a/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/CreateTimeEntryTool.php similarity index 93% rename from src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php rename to src/Module/TimeTracking/Infrastructure/Mcp/Tool/CreateTimeEntryTool.php index ced019e..1055472 100644 --- a/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php +++ b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/CreateTimeEntryTool.php @@ -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, ) {} diff --git a/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/DeleteTimeEntryTool.php similarity index 80% rename from src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php rename to src/Module/TimeTracking/Infrastructure/Mcp/Tool/DeleteTimeEntryTool.php index 2e50a0f..dd5f13a 100644 --- a/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php +++ b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/DeleteTimeEntryTool.php @@ -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)); diff --git a/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/ListTimeEntriesTool.php similarity index 91% rename from src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php rename to src/Module/TimeTracking/Infrastructure/Mcp/Tool/ListTimeEntriesTool.php index 243e8a7..50a54b3 100644 --- a/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php +++ b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/ListTimeEntriesTool.php @@ -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, ) {} diff --git a/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/UpdateTimeEntryTool.php similarity index 92% rename from src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php rename to src/Module/TimeTracking/Infrastructure/Mcp/Tool/UpdateTimeEntryTool.php index c164fc9..fa67cf1 100644 --- a/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php +++ b/src/Module/TimeTracking/Infrastructure/Mcp/Tool/UpdateTimeEntryTool.php @@ -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)); diff --git a/src/Module/TimeTracking/TimeTrackingModule.php b/src/Module/TimeTracking/TimeTrackingModule.php new file mode 100644 index 0000000..d7982d0 --- /dev/null +++ b/src/Module/TimeTracking/TimeTrackingModule.php @@ -0,0 +1,41 @@ + + */ + 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'], + ]; + } +} diff --git a/tests/Functional/Module/TimeTracking/TimeEntryTimestampableTest.php b/tests/Functional/Module/TimeTracking/TimeEntryTimestampableTest.php new file mode 100644 index 0000000..fc8211a --- /dev/null +++ b/tests/Functional/Module/TimeTracking/TimeEntryTimestampableTest.php @@ -0,0 +1,111 @@ +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; + } +}