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:
@@ -8,7 +8,9 @@ declare(strict_types=1);
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Module\Core\CoreModule;
|
use App\Module\Core\CoreModule;
|
||||||
|
use App\Module\TimeTracking\TimeTrackingModule;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
CoreModule::class,
|
CoreModule::class,
|
||||||
|
TimeTrackingModule::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ doctrine:
|
|||||||
is_bundle: false
|
is_bundle: false
|
||||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||||
prefix: 'App\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:
|
controller_resolver:
|
||||||
auto_mapping: false
|
auto_mapping: false
|
||||||
|
|
||||||
|
|||||||
@@ -73,4 +73,6 @@ services:
|
|||||||
|
|
||||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
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'
|
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ return [
|
|||||||
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
['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.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
|
||||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-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'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ use App\Entity\TaskPriority;
|
|||||||
use App\Entity\TaskRecurrence;
|
use App\Entity\TaskRecurrence;
|
||||||
use App\Entity\TaskStatus;
|
use App\Entity\TaskStatus;
|
||||||
use App\Entity\TaskTag;
|
use App\Entity\TaskTag;
|
||||||
use App\Entity\TimeEntry;
|
|
||||||
use App\Entity\Workflow;
|
use App\Entity\Workflow;
|
||||||
use App\Entity\ZimbraConfiguration;
|
use App\Entity\ZimbraConfiguration;
|
||||||
use App\Enum\AbsenceStatus;
|
use App\Enum\AbsenceStatus;
|
||||||
@@ -27,6 +26,7 @@ use App\Enum\RecurrenceType;
|
|||||||
use App\Enum\StatusCategory;
|
use App\Enum\StatusCategory;
|
||||||
use App\Module\Core\Application\Rbac\RbacSeeder;
|
use App\Module\Core\Application\Rbac\RbacSeeder;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use App\Entity\TaskGroup;
|
|||||||
use App\Entity\TaskPriority;
|
use App\Entity\TaskPriority;
|
||||||
use App\Entity\TaskStatus;
|
use App\Entity\TaskStatus;
|
||||||
use App\Entity\TaskTag;
|
use App\Entity\TaskTag;
|
||||||
use App\Entity\TimeEntry;
|
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Module\TimeTracking\Domain\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
@@ -13,9 +13,15 @@ use ApiPlatform\Metadata\Get;
|
|||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
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\Shared\Domain\Contract\UserInterface;
|
||||||
use App\State\ActiveTimeEntryProvider;
|
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
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(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
|
#[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)'])]
|
#[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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[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;
|
||||||
|
}
|
||||||
+4
-4
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\State;
|
namespace App\Module\TimeTracking\Infrastructure\ApiPlatform\State;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Entity\TimeEntry;
|
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||||
use App\Repository\TimeEntryRepository;
|
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +17,7 @@ final readonly class ActiveTimeEntryProvider implements ProviderInterface
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private TimeEntryRepository $timeEntryRepository,
|
private TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
+4
-4
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Module\TimeTracking\Infrastructure\Controller;
|
||||||
|
|
||||||
use App\Entity\Project;
|
use App\Entity\Project;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use App\Repository\TimeEntryRepository;
|
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||||
use App\Service\TimeEntryExportService;
|
use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -23,7 +23,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
class TimeEntryExportController extends AbstractController
|
class TimeEntryExportController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
private readonly TimeEntryExportService $exportService,
|
private readonly TimeEntryExportService $exportService,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
+9
-3
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
|
||||||
|
|
||||||
use App\Entity\Project;
|
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 App\Shared\Domain\Contract\UserInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
@@ -14,13 +15,18 @@ use Doctrine\Persistence\ManagerRegistry;
|
|||||||
/**
|
/**
|
||||||
* @extends ServiceEntityRepository<TimeEntry>
|
* @extends ServiceEntityRepository<TimeEntry>
|
||||||
*/
|
*/
|
||||||
class TimeEntryRepository extends ServiceEntityRepository
|
class DoctrineTimeEntryRepository extends ServiceEntityRepository implements TimeEntryRepositoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(ManagerRegistry $registry)
|
public function __construct(ManagerRegistry $registry)
|
||||||
{
|
{
|
||||||
parent::__construct($registry, TimeEntry::class);
|
parent::__construct($registry, TimeEntry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?TimeEntry
|
||||||
|
{
|
||||||
|
return $this->find($id);
|
||||||
|
}
|
||||||
|
|
||||||
public function findActiveByUser(UserInterface $user): ?TimeEntry
|
public function findActiveByUser(UserInterface $user): ?TimeEntry
|
||||||
{
|
{
|
||||||
return $this->findOneBy([
|
return $this->findOneBy([
|
||||||
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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 DateTimeImmutable;
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
+4
-4
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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\Mcp\Tool\Serializer;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
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\ProjectRepository;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
use App\Repository\TimeEntryRepository;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -29,7 +29,7 @@ class CreateTimeEntryTool
|
|||||||
private readonly ProjectRepository $projectRepository,
|
private readonly ProjectRepository $projectRepository,
|
||||||
private readonly TaskRepository $taskRepository,
|
private readonly TaskRepository $taskRepository,
|
||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
+4
-4
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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 Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -17,7 +17,7 @@ use function sprintf;
|
|||||||
class DeleteTimeEntryTool
|
class DeleteTimeEntryTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
@@ -28,7 +28,7 @@ class DeleteTimeEntryTool
|
|||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$entry = $this->timeEntryRepository->find($id);
|
$entry = $this->timeEntryRepository->findById($id);
|
||||||
|
|
||||||
if (null === $entry) {
|
if (null === $entry) {
|
||||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
||||||
+3
-3
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Mcp\Tool\TimeEntry;
|
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
use App\Mcp\Tool\Serializer;
|
use App\Mcp\Tool\Serializer;
|
||||||
use App\Repository\TimeEntryRepository;
|
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
class ListTimeEntriesTool
|
class ListTimeEntriesTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
+4
-4
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Mcp\Tool\TimeEntry;
|
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
use App\Mcp\Tool\Serializer;
|
use App\Mcp\Tool\Serializer;
|
||||||
|
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||||
use App\Repository\ProjectRepository;
|
use App\Repository\ProjectRepository;
|
||||||
use App\Repository\TaskRepository;
|
use App\Repository\TaskRepository;
|
||||||
use App\Repository\TaskTagRepository;
|
use App\Repository\TaskTagRepository;
|
||||||
use App\Repository\TimeEntryRepository;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -22,7 +22,7 @@ use function sprintf;
|
|||||||
class UpdateTimeEntryTool
|
class UpdateTimeEntryTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TimeEntryRepository $timeEntryRepository,
|
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||||
private readonly ProjectRepository $projectRepository,
|
private readonly ProjectRepository $projectRepository,
|
||||||
private readonly TaskRepository $taskRepository,
|
private readonly TaskRepository $taskRepository,
|
||||||
private readonly TaskTagRepository $taskTagRepository,
|
private readonly TaskTagRepository $taskTagRepository,
|
||||||
@@ -47,7 +47,7 @@ class UpdateTimeEntryTool
|
|||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$entry = $this->timeEntryRepository->find($id);
|
$entry = $this->timeEntryRepository->findById($id);
|
||||||
|
|
||||||
if (null === $entry) {
|
if (null === $entry) {
|
||||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user