From 76013afb1ccf116fb8d15422dee5a524b023de1e Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Wed, 1 Apr 2026 14:20:20 +0200 Subject: [PATCH] docs: add implementation plan for awards in database Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-01-awards-bdd.md | 881 ++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-01-awards-bdd.md diff --git a/docs/superpowers/plans/2026-04-01-awards-bdd.md b/docs/superpowers/plans/2026-04-01-awards-bdd.md new file mode 100644 index 0000000..cedb83f --- /dev/null +++ b/docs/superpowers/plans/2026-04-01-awards-bdd.md @@ -0,0 +1,881 @@ +# Awards en BDD — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist actor awards in the database during film import and use them for hint generation instead of live Wikidata calls. + +**Architecture:** Two new Doctrine entities (`AwardType`, `Award`) plus a `awardsImported` flag on `Actor`. A new `AwardImporter` service handles Wikidata fetching + pattern-based type resolution during the existing batch import flow. `GameGridGenerator` switches from Wikidata calls to DB queries for award hints. + +**Tech Stack:** Symfony 7, Doctrine ORM, PHPUnit 12, PostgreSQL (RANDOM() function used in existing queries) + +--- + +## File Structure + +| Action | Path | Responsibility | +|--------|------|---------------| +| Create | `src/Entity/AwardType.php` | Doctrine entity for award types | +| Create | `src/Entity/Award.php` | Doctrine entity for individual awards | +| Create | `src/Repository/AwardTypeRepository.php` | AwardType queries | +| Create | `src/Repository/AwardRepository.php` | Award queries (random by actor) | +| Create | `src/Service/AwardImporter.php` | Orchestrates Wikidata fetch + type resolution + persist | +| Create | `tests/Service/AwardImporterTest.php` | Unit tests for import logic | +| Create | `tests/Service/GameGridGeneratorTest.php` | Unit tests for updated hint generation | +| Modify | `src/Entity/Actor.php` | Add `awardsImported` bool field + `awards` collection | +| Modify | `src/MessageHandler/ImportFilmsBatchMessageHandler.php` | Call `AwardImporter` after actor sync | +| Modify | `src/Service/GameGridGenerator.php` | Replace Wikidata call with DB query | +| Create | `migrations/VersionXXX.php` | Generated by doctrine:migrations:diff | + +--- + +### Task 1: Create `AwardType` entity + +**Files:** +- Create: `src/Entity/AwardType.php` +- Create: `src/Repository/AwardTypeRepository.php` + +- [ ] **Step 1: Create the entity** + +```php + */ + #[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'awardType')] + private Collection $awards; + + public function __construct() + { + $this->awards = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getPattern(): string + { + return $this->pattern; + } + + public function setPattern(string $pattern): static + { + $this->pattern = $pattern; + + return $this; + } + + /** @return Collection */ + public function getAwards(): Collection + { + return $this->awards; + } +} +``` + +- [ ] **Step 2: Create the repository** + +```php + */ +class AwardTypeRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AwardType::class); + } + + /** @return list */ + public function findAll(): array + { + return parent::findAll(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/AwardType.php src/Repository/AwardTypeRepository.php +git commit -m "feat: add AwardType entity and repository" +``` + +--- + +### Task 2: Create `Award` entity + +**Files:** +- Create: `src/Entity/Award.php` +- Create: `src/Repository/AwardRepository.php` + +- [ ] **Step 1: Create the entity** + +```php +id; + } + + public function getAwardType(): AwardType + { + return $this->awardType; + } + + public function setAwardType(AwardType $awardType): static + { + $this->awardType = $awardType; + + return $this; + } + + public function getActor(): Actor + { + return $this->actor; + } + + public function setActor(Actor $actor): static + { + $this->actor = $actor; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getYear(): ?int + { + return $this->year; + } + + public function setYear(?int $year): static + { + $this->year = $year; + + return $this; + } +} +``` + +- [ ] **Step 2: Create the repository with `findOneRandomByActor`** + +```php + */ +class AwardRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Award::class); + } + + public function findOneRandomByActor(int $actorId): ?Award + { + return $this->createQueryBuilder('a') + ->andWhere('a.actor = :actorId') + ->setParameter('actorId', $actorId) + ->orderBy('RANDOM()') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/Award.php src/Repository/AwardRepository.php +git commit -m "feat: add Award entity and repository" +``` + +--- + +### Task 3: Add `awardsImported` flag and `awards` collection to `Actor` + +**Files:** +- Modify: `src/Entity/Actor.php` + +- [ ] **Step 1: Add the `awardsImported` column and `awards` OneToMany relation** + +Add these properties after the existing `$tmdbId` property: + +```php +#[ORM\Column(options: ['default' => false])] +private bool $awardsImported = false; + +/** @var Collection */ +#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')] +private Collection $awards; +``` + +Add `use App\Entity\Award;` import if not already present (it's in the same namespace, so not needed). + +In the constructor, add: + +```php +$this->awards = new ArrayCollection(); +``` + +Add these methods at the end of the class: + +```php +public function isAwardsImported(): bool +{ + return $this->awardsImported; +} + +public function setAwardsImported(bool $awardsImported): static +{ + $this->awardsImported = $awardsImported; + + return $this; +} + +/** @return Collection */ +public function getAwards(): Collection +{ + return $this->awards; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Actor.php +git commit -m "feat: add awardsImported flag and awards relation to Actor" +``` + +--- + +### Task 4: Generate and run the migration + +**Files:** +- Create: `migrations/VersionXXX.php` (auto-generated) + +- [ ] **Step 1: Generate the migration** + +```bash +php bin/console doctrine:migrations:diff +``` + +Expected: a new migration file is created in `migrations/` with CREATE TABLE for `award_type` and `award`, and ALTER TABLE for `actor` adding `awards_imported`. + +- [ ] **Step 2: Review the generated migration** + +Open the generated file and verify it contains: +- `CREATE TABLE award_type (id SERIAL, name VARCHAR(255), pattern VARCHAR(255), PRIMARY KEY(id))` +- `CREATE TABLE award (id SERIAL, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255), year INT DEFAULT NULL, PRIMARY KEY(id))` with foreign keys +- `ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false` + +- [ ] **Step 3: Run the migration** + +```bash +php bin/console doctrine:migrations:migrate --no-interaction +``` + +Expected: migration executes successfully. + +- [ ] **Step 4: Commit** + +```bash +git add migrations/ +git commit -m "feat: add migration for award_type, award tables and actor.awards_imported" +``` + +--- + +### Task 5: Create `AwardImporter` service + +**Files:** +- Create: `src/Service/AwardImporter.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Service/AwardImporterTest.php`: + +```php +wikidataGateway = $this->createMock(WikidataGateway::class); + $this->awardTypeRepository = $this->createMock(AwardTypeRepository::class); + $this->em = $this->createMock(EntityManagerInterface::class); + + $this->importer = new AwardImporter( + $this->wikidataGateway, + $this->awardTypeRepository, + $this->em, + ); + } + + public function testSkipsActorWithAwardsAlreadyImported(): void + { + $actor = $this->createActorWithFlag(awardsImported: true); + + $this->wikidataGateway->expects($this->never())->method('getAwards'); + + $this->importer->importForActor($actor); + } + + public function testImportsAwardsAndSetsFlag(): void + { + $actor = $this->createActorWithFlag(awardsImported: false); + + $this->wikidataGateway->method('getAwards')->willReturn([ + ['name' => 'Academy Award for Best Actor', 'year' => 2020], + ]); + + $existingType = new AwardType(); + $existingType->setName('Oscar')->setPattern('Academy Award'); + + $this->awardTypeRepository->method('findAll')->willReturn([$existingType]); + + $persisted = []; + $this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) { + $persisted[] = $entity; + }); + + $this->importer->importForActor($actor); + + $this->assertTrue($actor->isAwardsImported()); + $this->assertCount(1, $persisted); + $this->assertInstanceOf(Award::class, $persisted[0]); + $this->assertSame('Academy Award for Best Actor', $persisted[0]->getName()); + $this->assertSame(2020, $persisted[0]->getYear()); + $this->assertSame($existingType, $persisted[0]->getAwardType()); + $this->assertSame($actor, $persisted[0]->getActor()); + } + + public function testCreatesNewAwardTypeWhenNoPatternMatches(): void + { + $actor = $this->createActorWithFlag(awardsImported: false); + + $this->wikidataGateway->method('getAwards')->willReturn([ + ['name' => 'Screen Actors Guild Award for Outstanding Performance', 'year' => 2019], + ]); + + $this->awardTypeRepository->method('findAll')->willReturn([]); + + $persisted = []; + $this->em->method('persist')->willReturnCallback(function ($entity) use (&$persisted) { + $persisted[] = $entity; + }); + + $this->importer->importForActor($actor); + + $this->assertTrue($actor->isAwardsImported()); + // Should persist both a new AwardType and the Award + $this->assertCount(2, $persisted); + + $newType = $persisted[0]; + $this->assertInstanceOf(AwardType::class, $newType); + $this->assertSame('Screen Actors Guild Award', $newType->getName()); + $this->assertSame('Screen Actors Guild Award', $newType->getPattern()); + + $award = $persisted[1]; + $this->assertInstanceOf(Award::class, $award); + $this->assertSame($newType, $award->getAwardType()); + } + + public function testDoesNotSetFlagOnWikidataError(): void + { + $actor = $this->createActorWithFlag(awardsImported: false); + + $this->wikidataGateway->method('getAwards') + ->willThrowException(new \RuntimeException('Wikidata timeout')); + + $this->importer->importForActor($actor); + + $this->assertFalse($actor->isAwardsImported()); + } + + public function testHandlesActorWithNoAwards(): void + { + $actor = $this->createActorWithFlag(awardsImported: false); + + $this->wikidataGateway->method('getAwards')->willReturn([]); + $this->awardTypeRepository->method('findAll')->willReturn([]); + + $this->em->expects($this->never())->method('persist'); + + $this->importer->importForActor($actor); + + $this->assertTrue($actor->isAwardsImported()); + } + + private function createActorWithFlag(bool $awardsImported): Actor + { + $actor = new Actor(); + $actor->setName('Test Actor'); + $actor->setAwardsImported($awardsImported); + + return $actor; + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +php bin/phpunit tests/Service/AwardImporterTest.php +``` + +Expected: FAIL — `AwardImporter` class not found. + +- [ ] **Step 3: Write the implementation** + +Create `src/Service/AwardImporter.php`: + +```php +isAwardsImported()) { + return; + } + + try { + $wikidataAwards = $this->wikidataGateway->getAwards($actor); + } catch (\Throwable $e) { + $this->logger?->warning('Failed to fetch awards from Wikidata', [ + 'actor' => $actor->getName(), + 'error' => $e->getMessage(), + ]); + + return; + } + + $knownTypes = $this->awardTypeRepository->findAll(); + + foreach ($wikidataAwards as $wikidataAward) { + $awardType = $this->resolveAwardType($wikidataAward['name'], $knownTypes); + + $award = new Award(); + $award->setName($wikidataAward['name']); + $award->setYear($wikidataAward['year']); + $award->setActor($actor); + $award->setAwardType($awardType); + + $this->em->persist($award); + } + + $actor->setAwardsImported(true); + } + + /** + * @param list $knownTypes + */ + private function resolveAwardType(string $awardName, array &$knownTypes): AwardType + { + foreach ($knownTypes as $type) { + if (str_contains($awardName, $type->getPattern())) { + return $type; + } + } + + $newType = new AwardType(); + $prefix = $this->extractPrefix($awardName); + $newType->setName($prefix); + $newType->setPattern($prefix); + + $this->em->persist($newType); + $knownTypes[] = $newType; + + return $newType; + } + + private function extractPrefix(string $awardName): string + { + // Extract text before " for " or " pour " (common patterns in award names) + if (preg_match('/^(.+?)\s+(?:for|pour)\s+/i', $awardName, $matches)) { + return trim($matches[1]); + } + + return $awardName; + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +php bin/phpunit tests/Service/AwardImporterTest.php +``` + +Expected: all 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Service/AwardImporter.php tests/Service/AwardImporterTest.php +git commit -m "feat: add AwardImporter service with tests" +``` + +--- + +### Task 6: Integrate `AwardImporter` into the import batch handler + +**Files:** +- Modify: `src/MessageHandler/ImportFilmsBatchMessageHandler.php` + +- [ ] **Step 1: Add `AwardImporter` dependency** + +In the constructor, add: + +```php +private AwardImporter $awardImporter, +``` + +Add the import at the top: + +```php +use App\Service\AwardImporter; +``` + +- [ ] **Step 2: Call `AwardImporter` after actor sync** + +In the `__invoke` method, after `$this->actorSyncer->syncActorsForMovie($movie);` (line 63) and **before** the existing `$this->em->flush()` (line 77), add: + +```php +$this->actorSyncer->syncActorsForMovie($movie); + +// Import awards for actors of this movie +foreach ($movie->getActors() as $role) { + $this->awardImporter->importForActor($role->getActor()); +} +``` + +`Movie::getActors()` returns `Collection`. The `ActorSyncer` persists actors/roles in memory before flush, so the collection is hydrated at this point. The existing `$this->em->flush()` on line 77 will persist both the roles and the new awards in a single flush. The `$this->em->clear()` on line 87 happens after, so all entities are still available. + +- [ ] **Step 3: Commit** + +```bash +git add src/MessageHandler/ImportFilmsBatchMessageHandler.php +git commit -m "feat: import actor awards during film batch import" +``` + +--- + +### Task 7: Update `GameGridGenerator` to use DB for award hints + +**Files:** +- Modify: `src/Service/GameGridGenerator.php` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Service/GameGridGeneratorTest.php` — test only the hint resolution, not the full game generation (which requires DB): + +```php +setName('Oscar')->setPattern('Academy Award'); + + $actor = new Actor(); + $actor->setName('Test Actor'); + + $award = new Award(); + $award->setName('Academy Award for Best Actor'); + $award->setYear(2020); + $award->setActor($actor); + $award->setAwardType($awardType); + + $awardRepository = $this->createMock(AwardRepository::class); + $awardRepository->method('find')->with(42)->willReturn($award); + + $generator = new GameGridGenerator( + $this->createMock(ActorRepository::class), + $this->createMock(MovieRoleRepository::class), + $this->createMock(MovieRepository::class), + $awardRepository, + $this->createMock(EntityManagerInterface::class), + ); + + $row = new GameRow(); + $row->setHintType('award'); + $row->setHintData('42'); + + // Use reflection to test the private resolveHintText method + $method = new \ReflectionMethod($generator, 'resolveHintText'); + $result = $method->invoke($generator, $row); + + $this->assertSame('Academy Award for Best Actor (2020)', $result); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +php bin/phpunit tests/Service/GameGridGeneratorTest.php +``` + +Expected: FAIL — constructor signature mismatch (no `AwardRepository` param yet). + +- [ ] **Step 3: Update `GameGridGenerator`** + +Replace `WikidataGateway` with `AwardRepository` in the constructor. Full changes to `src/Service/GameGridGenerator.php`: + +**Replace the imports:** +- Remove: `use App\Gateway\WikidataGateway;` +- Add: `use App\Repository\AwardRepository;` + +**Replace the constructor:** + +```php +public function __construct( + private readonly ActorRepository $actorRepository, + private readonly MovieRoleRepository $movieRoleRepository, + private readonly MovieRepository $movieRepository, + private readonly AwardRepository $awardRepository, + private readonly EntityManagerInterface $em, +) {} +``` + +**Replace the `case 'award':` block in `resolveHint()`** (lines 175-185): + +```php +case 'award': + $award = $this->awardRepository->findOneRandomByActor($rowActor->getId()); + if ($award === null) { + return null; + } + return ['type' => 'award', 'data' => (string) $award->getId()]; +``` + +**Replace the `'award'` case in `resolveHintText()`** (line 203): + +```php +'award' => $this->resolveAwardHintText((int) $data), +``` + +**Add this private method:** + +```php +private function resolveAwardHintText(int $awardId): ?string +{ + $award = $this->awardRepository->find($awardId); + if ($award === null) { + return null; + } + + $text = $award->getName(); + if ($award->getYear() !== null) { + $text .= ' (' . $award->getYear() . ')'; + } + + return $text; +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +php bin/phpunit tests/Service/GameGridGeneratorTest.php +``` + +Expected: PASS. + +- [ ] **Step 5: Run all tests** + +```bash +php bin/phpunit +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/Service/GameGridGenerator.php tests/Service/GameGridGeneratorTest.php +git commit -m "feat: use DB awards instead of live Wikidata calls for hint generation" +``` + +--- + +### Task 8: Manual verification + +- [ ] **Step 1: Verify the schema is in sync** + +```bash +php bin/console doctrine:schema:validate +``` + +Expected: schema is in sync with mapping files. + +- [ ] **Step 2: Verify the app boots** + +```bash +php bin/console cache:clear +``` + +Expected: no errors. + +- [ ] **Step 3: Run all tests one final time** + +```bash +php bin/phpunit +``` + +Expected: all tests PASS. + +- [ ] **Step 4: Final commit if any fixes were needed** + +Only if adjustments were made during verification.