feat(project-management) : add timestampable/blamable to Task and Project (additive)

Tranche 3 of LST-65. Task and Project adopt TimestampableBlamableTrait.

- Additive migration on task and project: created_at/updated_at (nullable),
  created_by/updated_by (nullable INT, FK to "user" ON DELETE SET NULL) +
  indexes + COMMENT ON COLUMN. down() drops only the added objects.
- Trait fields stay out of the existing API groups (trait carries its own).
- Functional test (TaskTimestampableTest) confirms created_at on persist and
  updated_at refresh on update.

161 tests green, no destructive migration.
This commit is contained in:
Matthieu
2026-06-20 17:05:47 +02:00
parent 23809f165e
commit c90d91d6c4
4 changed files with 193 additions and 2 deletions
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* ProjectManagement module (LST-65 slice 3): add Timestampable/Blamable columns
* to task and project.
*
* Task and Project adopt TimestampableBlamableTrait (after TimeEntry). 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 Version20260620161500 extends AbstractMigration
{
public function getDescription(): string
{
return 'ProjectManagement: add timestampable/blamable columns to task and project (additive)';
}
public function up(Schema $schema): void
{
// task
$this->addSql('ALTER TABLE task ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD updated_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB25DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB2516FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_527EDB25DE12AB56 ON task (created_by)');
$this->addSql('CREATE INDEX IDX_527EDB2516FE72E1 ON task (updated_by)');
$this->addSql("COMMENT ON COLUMN task.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN task.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN task.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN task.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
// project
$this->addSql('ALTER TABLE project ADD created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD updated_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EEDE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_2FB3D0EE16FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_2FB3D0EEDE12AB56 ON project (created_by)');
$this->addSql('CREATE INDEX IDX_2FB3D0EE16FE72E1 ON project (updated_by)');
$this->addSql("COMMENT ON COLUMN project.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN project.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN project.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN project.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
}
public function down(Schema $schema): void
{
// task
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB25DE12AB56');
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB2516FE72E1');
$this->addSql('DROP INDEX IDX_527EDB25DE12AB56');
$this->addSql('DROP INDEX IDX_527EDB2516FE72E1');
$this->addSql('ALTER TABLE task DROP created_at');
$this->addSql('ALTER TABLE task DROP updated_at');
$this->addSql('ALTER TABLE task DROP created_by');
$this->addSql('ALTER TABLE task DROP updated_by');
// project
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EEDE12AB56');
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_2FB3D0EE16FE72E1');
$this->addSql('DROP INDEX IDX_2FB3D0EEDE12AB56');
$this->addSql('DROP INDEX IDX_2FB3D0EE16FE72E1');
$this->addSql('ALTER TABLE project DROP created_at');
$this->addSql('ALTER TABLE project DROP updated_at');
$this->addSql('ALTER TABLE project DROP created_by');
$this->addSql('ALTER TABLE project DROP updated_by');
}
}
@@ -16,8 +16,11 @@ use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\Resource\SwitchWorkflowOutput;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\SwitchProjectWorkflowProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
@@ -56,8 +59,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
#[ORM\Entity(repositoryClass: DoctrineProjectRepository::class)]
#[UniqueEntity(fields: ['code'], message: 'Ce code de projet est déjà utilisé.')]
class Project implements ProjectInterface
class Project implements ProjectInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -18,8 +18,11 @@ use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -47,8 +50,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: DoctrineTaskRepository::class)]
#[ORM\Table(name: 'task')]
#[ORM\UniqueConstraint(name: 'uniq_task_project_number', columns: ['project_id', 'number'])]
class Task implements TaskInterface
class Task implements TaskInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ProjectManagement;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Entity\Task;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Task adopts TimestampableBlamableTrait (LST-65 slice 3).
* Verifies the TimestampableBlamableSubscriber populates created_at on
* persist and refreshes updated_at on update.
*
* @internal
*/
final class TaskTimestampableTest extends KernelTestCase
{
private EntityManagerInterface $em;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
}
protected function tearDown(): void
{
$this->em->getConnection()->executeStatement("DELETE FROM task WHERE title = 'ts-test-task'");
parent::tearDown();
unset($this->em);
}
public function testCreatedAtIsSetOnPersist(): void
{
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
self::assertInstanceOf(DateTimeImmutable::class, $task->getCreatedAt(), 'createdAt must be set after flush');
self::assertInstanceOf(DateTimeImmutable::class, $task->getUpdatedAt(), 'updatedAt must be set after flush');
$taskId = $task->getId();
$this->em->clear();
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCreatedAt(), 'created_at must be persisted in DB');
}
public function testUpdatedAtIsRefreshedOnUpdate(): void
{
$task = $this->makeTask();
$this->em->persist($task);
$this->em->flush();
$taskId = $task->getId();
$initialUpdatedAt = $task->getUpdatedAt();
self::assertNotNull($initialUpdatedAt);
// Backdate the persisted value so the preUpdate refresh is observable
// even within the same second.
$this->em->getConnection()->executeStatement(
"UPDATE task SET updated_at = '2000-01-01 00:00:00' WHERE id = :id",
['id' => $taskId],
);
$this->em->clear();
/** @var Task $managed */
$managed = $this->em->getRepository(Task::class)->find($taskId);
self::assertSame('2000-01-01 00:00:00', $managed->getUpdatedAt()?->format('Y-m-d H:i:s'));
$managed->setDescription('changed description');
$this->em->flush();
$this->em->clear();
/** @var Task $reloaded */
$reloaded = $this->em->getRepository(Task::class)->find($taskId);
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 makeTask(): Task
{
$project = $this->em->getRepository(Project::class)->findOneBy([]);
self::assertNotNull($project, 'Les fixtures doivent fournir au moins un projet.');
$task = new Task();
$task->setNumber(random_int(100000, 999999));
$task->setTitle('ts-test-task');
$task->setProject($project);
return $task;
}
}