# 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.