Compare commits

...

10 Commits

Author SHA1 Message Date
thibaud-leclere
353ffddeea feat: add popover to confirm abandon 2026-04-01 14:36:41 +02:00
thibaud-leclere
fb13a8819d feat: use DB awards instead of live Wikidata calls for hint generation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:29:36 +02:00
thibaud-leclere
0fd0b85b8f feat: import actor awards during film batch import 2026-04-01 14:27:54 +02:00
thibaud-leclere
8aa33ccefc feat: add AwardImporter service with tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 14:27:15 +02:00
thibaud-leclere
d4d2272396 feat: add migration for award_type, award tables and actor.awards_imported
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:24:48 +02:00
thibaud-leclere
6c1e4cb38b feat: add awardsImported flag and awards relation to Actor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:23:56 +02:00
thibaud-leclere
859a5a1067 feat: add Award entity and repository 2026-04-01 14:23:23 +02:00
thibaud-leclere
acc266739d feat: add AwardType entity and repository 2026-04-01 14:22:33 +02:00
thibaud-leclere
76013afb1c docs: add implementation plan for awards in database
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:20:20 +02:00
thibaud-leclere
d2d211a228 docs: add design spec for persisting awards in database
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 14:17:04 +02:00
15 changed files with 1653 additions and 15 deletions

View File

@@ -631,6 +631,78 @@ body {
background: #fef2f2;
}
.abandon-wrapper {
position: relative;
}
.abandon-popover {
display: none;
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 16px;
box-shadow: 0 4px 16px var(--shadow-warm);
z-index: 100;
white-space: nowrap;
}
.abandon-popover.open {
display: block;
}
.abandon-popover-text {
margin: 0 0 10px;
font-size: 13px;
font-weight: 500;
color: var(--text);
text-align: center;
}
.abandon-popover-actions {
display: flex;
gap: 8px;
justify-content: center;
}
.btn-abandon-confirm {
padding: 6px 14px;
background: #dc2626;
color: #fff;
border: none;
border-radius: 100px;
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-abandon-confirm:hover {
background: #b91c1c;
}
.btn-abandon-cancel {
padding: 6px 14px;
background: none;
color: var(--text);
border: 1.5px solid var(--border);
border-radius: 100px;
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.btn-abandon-cancel:hover {
background: var(--surface-hover, #f5f5f5);
border-color: var(--text);
}
.game-start-container {
display: flex;
flex-direction: column;

View File

@@ -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
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardTypeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
class AwardType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $pattern;
/** @var Collection<int, Award> */
#[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<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}
```
- [ ] **Step 2: Create the repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AwardType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<AwardType> */
class AwardTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AwardType::class);
}
/** @return list<AwardType> */
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
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardRepository::class)]
class Award
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private AwardType $awardType;
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private Actor $actor;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(nullable: true)]
private ?int $year = null;
public function getId(): ?int
{
return $this->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
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Award;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Award> */
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<int, Award> */
#[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<int, Award> */
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
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use App\Service\AwardImporter;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class AwardImporterTest extends TestCase
{
private AwardImporter $importer;
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
protected function setUp(): void
{
$this->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
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
readonly class AwardImporter
{
public function __construct(
private WikidataGateway $wikidataGateway,
private AwardTypeRepository $awardTypeRepository,
private EntityManagerInterface $em,
private ?LoggerInterface $logger = null,
) {}
public function importForActor(Actor $actor): void
{
if ($actor->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<AwardType> $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<MovieRole>`. 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
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Service\GameGridGenerator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class GameGridGeneratorTest extends TestCase
{
public function testResolveHintTextForAward(): void
{
$awardType = new AwardType();
$awardType->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.

View File

@@ -0,0 +1,93 @@
# Awards en BDD — Design Spec
## Contexte
Actuellement, les récompenses des acteurs sont récupérées à la volée depuis Wikidata (SPARQL) lors de la génération d'une partie. Elles ne sont pas persistées en base de données, ce qui rend le système fragile (dépendance réseau à chaque partie) et empêche tout filtrage par type de récompense.
## Objectifs
1. Stocker les awards en BDD dès l'import des films
2. Introduire une notion de type de récompense (Oscar, Golden Globe, BAFTA...)
3. Adapter la génération des indices pour piocher en BDD au lieu d'appeler Wikidata
4. Préparer le terrain pour un futur filtrage par type (choix du joueur avant la partie) — hors scope de cette itération
## Modèle de données
### Nouvelle entité : `AwardType`
| Champ | Type | Description |
|-----------|------------|--------------------------------------------------------------------|
| `id` | int (PK) | Auto-increment |
| `name` | string | Nom affiché, ex: "Oscar", "Golden Globe", "BAFTA", "César" |
| `pattern` | string | Préfixe/mot-clé pour le matching sur les noms Wikidata, ex: "Academy Award" |
### Nouvelle entité : `Award`
| Champ | Type | Description |
|-------------|-------------------|--------------------------------------------------------------|
| `id` | int (PK) | Auto-increment |
| `awardType` | ManyToOne → AwardType | Type de la récompense |
| `actor` | ManyToOne → Actor | Acteur récompensé |
| `name` | string | Nom complet Wikidata, ex: "Academy Award for Best Actor" |
| `year` | int (nullable) | Année de la récompense |
### Modification entité `Actor`
| Champ | Type | Description |
|------------------|------|--------------------------------------------------|
| `awardsImported` | bool | `false` par défaut. Passe à `true` après import. |
### Relations
- `Actor` OneToMany → `Award`
- `AwardType` OneToMany → `Award`
## Flux d'import des awards
L'import se greffe sur le batch existant (`ImportFilmsBatchMessageHandler`), après `ActorSyncer::syncActorsForMovie()`.
### Étapes pour chaque acteur du film importé
1. Vérifier `actor.awardsImported`
2. Si `true` → skip
3. Si `false` → appeler `WikidataGateway::getAwards(actor)`
4. Pour chaque award retourné :
- Parcourir les `AwardType` existants et matcher le nom de l'award contre leur `pattern`
- Si un `AwardType` matche → l'utiliser
- Si aucun ne matche → créer un nouvel `AwardType` dynamiquement en extrayant le préfixe commun du nom (ex: "Screen Actors Guild Award for Outstanding Performance..." → type "Screen Actors Guild Award")
- Créer l'entité `Award` (name, year, actor, awardType)
5. Passer `actor.awardsImported = true`
6. Flush
### Gestion d'erreur
Si Wikidata est indisponible ou retourne une erreur :
- Ne **pas** mettre `awardsImported = true`
- L'import du film continue normalement (les awards seront retentés au prochain import contenant cet acteur)
- Log de l'erreur
## Génération des indices (hints)
### Changements dans `GameGridGenerator`
**Avant** : appel à `WikidataGateway::getAwards()` à la volée pour chaque acteur du grid.
**Après** :
1. Pour un hint de type "award", requêter les `Award` en BDD pour l'acteur
2. Si l'acteur a des awards → en choisir un au hasard
3. Si l'acteur n'a pas d'awards → fallback sur les types "film" ou "character" (comportement existant quand Wikidata échouait)
### Stockage du hint
- `GameRow.hintData` stocke l'**ID de l'`Award`** (au lieu d'une string brute comme avant)
- `resolveHintText()` récupère le nom complet + année depuis l'entité `Award`
### Pas de filtrage par `AwardType`
Pour cette itération, on pioche dans tous les awards de l'acteur sans filtrer par type. Le filtrage (choix du joueur : "mode Oscars", "mode Golden Globes"...) sera ajouté ultérieurement.
## Hors scope
- UI de choix du type de récompense avant la partie
- Filtrage des awards par type lors de la génération des indices
- Commande de re-sync des awards pour les acteurs déjà importés

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260401000001 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add award_type, award tables and actor.awards_imported column';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE award_type (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, pattern VARCHAR(255) NOT NULL)');
$this->addSql('CREATE TABLE award (id SERIAL PRIMARY KEY, award_type_id INT NOT NULL, actor_id INT NOT NULL, name VARCHAR(255) NOT NULL, year INT DEFAULT NULL, CONSTRAINT fk_award_award_type FOREIGN KEY (award_type_id) REFERENCES award_type (id), CONSTRAINT fk_award_actor FOREIGN KEY (actor_id) REFERENCES actor (id))');
$this->addSql('CREATE INDEX idx_award_award_type ON award (award_type_id)');
$this->addSql('CREATE INDEX idx_award_actor ON award (actor_id)');
$this->addSql('ALTER TABLE actor ADD awards_imported BOOLEAN NOT NULL DEFAULT false');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE award');
$this->addSql('DROP TABLE award_type');
$this->addSql('ALTER TABLE actor DROP COLUMN awards_imported');
}
}

View File

@@ -30,9 +30,17 @@ class Actor
#[ORM\Column(nullable: true)]
private ?int $tmdbId = null;
#[ORM\Column(options: ['default' => false])]
private bool $awardsImported = false;
/** @var Collection<int, Award> */
#[ORM\OneToMany(targetEntity: Award::class, mappedBy: 'actor')]
private Collection $awards;
public function __construct()
{
$this->movieRoles = new ArrayCollection();
$this->awards = new ArrayCollection();
}
public function getId(): ?int
@@ -105,4 +113,22 @@ class Actor
return $this;
}
public function isAwardsImported(): bool
{
return $this->awardsImported;
}
public function setAwardsImported(bool $awardsImported): static
{
$this->awardsImported = $awardsImported;
return $this;
}
/** @return Collection<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}

84
src/Entity/Award.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardRepository::class)]
class Award
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: AwardType::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private AwardType $awardType;
#[ORM\ManyToOne(targetEntity: Actor::class, inversedBy: 'awards')]
#[ORM\JoinColumn(nullable: false)]
private Actor $actor;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(nullable: true)]
private ?int $year = null;
public function getId(): ?int
{
return $this->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;
}
}

69
src/Entity/AwardType.php Normal file
View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AwardTypeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AwardTypeRepository::class)]
class AwardType
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $name;
#[ORM\Column(length: 255)]
private string $pattern;
/** @var Collection<int, Award> */
#[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<int, Award> */
public function getAwards(): Collection
{
return $this->awards;
}
}

View File

@@ -10,6 +10,7 @@ use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage;
use App\Repository\ImportRepository;
use App\Service\ActorSyncer;
use App\Service\AwardImporter;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
@@ -25,6 +26,7 @@ readonly class ImportFilmsBatchMessageHandler
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private ActorSyncer $actorSyncer,
private AwardImporter $awardImporter,
private ImportRepository $importRepository,
private LoggerInterface $logger,
) {}
@@ -62,6 +64,11 @@ readonly class ImportFilmsBatchMessageHandler
$this->actorSyncer->syncActorsForMovie($movie);
// Import awards for actors of this movie
foreach ($movie->getActors() as $role) {
$this->awardImporter->importForActor($role->getActor());
}
$user = $this->em->getReference(\App\Entity\User::class, $userId);
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
'user' => $user,

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Award;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Award> */
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();
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\AwardType;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<AwardType> */
class AwardTypeRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AwardType::class);
}
/** @return list<AwardType> */
public function findAll(): array
{
return parent::findAll();
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
readonly class AwardImporter
{
public function __construct(
private WikidataGateway $wikidataGateway,
private AwardTypeRepository $awardTypeRepository,
private EntityManagerInterface $em,
private ?LoggerInterface $logger = null,
) {}
public function importForActor(Actor $actor): void
{
if ($actor->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<AwardType> $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;
}
}

View File

@@ -11,7 +11,7 @@ use App\Entity\User;
use App\Repository\ActorRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Gateway\WikidataGateway;
use App\Repository\AwardRepository;
use Doctrine\ORM\EntityManagerInterface;
class GameGridGenerator
@@ -20,7 +20,7 @@ class GameGridGenerator
private readonly ActorRepository $actorRepository,
private readonly MovieRoleRepository $movieRoleRepository,
private readonly MovieRepository $movieRepository,
private readonly WikidataGateway $wikidataGateway,
private readonly AwardRepository $awardRepository,
private readonly EntityManagerInterface $em,
) {}
@@ -173,16 +173,11 @@ class GameGridGenerator
return ['type' => 'character', 'data' => (string) $role->getId()];
case 'award':
try {
$awards = $this->wikidataGateway->getAwards($rowActor);
} catch (\Throwable) {
$award = $this->awardRepository->findOneRandomByActor($rowActor->getId());
if ($award === null) {
return null;
}
if (!empty($awards)) {
$award = $awards[array_rand($awards)];
return ['type' => 'award', 'data' => $award['name'] . ' (' . $award['year'] . ')'];
}
return null;
return ['type' => 'award', 'data' => (string) $award->getId()];
}
return null;
@@ -200,8 +195,23 @@ class GameGridGenerator
return match ($type) {
'film' => $this->movieRepository->find((int) $data)?->getTitle(),
'character' => $this->movieRoleRepository->find((int) $data)?->getCharacter(),
'award' => $data,
'award' => $this->resolveAwardHintText((int) $data),
default => null,
};
}
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;
}
}

View File

@@ -4,10 +4,37 @@
{% if game %}
<div class="game-container">
<div class="game-actions">
<div class="abandon-wrapper">
<button type="button" class="btn btn-abandon" id="abandon-trigger">Abandonner</button>
<div class="abandon-popover" id="abandon-popover">
<p class="abandon-popover-text">Êtes-vous sûr de vouloir abandonner ?</p>
<div class="abandon-popover-actions">
<form method="post" action="{{ path('app_game_abandon', {id: game.id}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('game_abandon') }}">
<button type="submit" class="btn btn-abandon">Abandonner</button>
<button type="submit" class="btn btn-abandon-confirm">Abandonner</button>
</form>
<button type="button" class="btn btn-abandon-cancel" id="abandon-cancel">Non, continuer</button>
</div>
</div>
</div>
<script>
(function() {
var trigger = document.getElementById('abandon-trigger');
var popover = document.getElementById('abandon-popover');
var cancel = document.getElementById('abandon-cancel');
trigger.addEventListener('click', function() {
popover.classList.toggle('open');
});
cancel.addEventListener('click', function() {
popover.classList.remove('open');
});
document.addEventListener('click', function(e) {
if (!popover.contains(e.target) && e.target !== trigger) {
popover.classList.remove('open');
}
});
})();
</script>
</div>
<div {{ react_component('GameGrid', {

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Gateway\WikidataGateway;
use App\Repository\AwardTypeRepository;
use App\Service\AwardImporter;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class AwardImporterTest extends TestCase
{
private AwardImporter $importer;
private WikidataGateway&\PHPUnit\Framework\MockObject\MockObject $wikidataGateway;
private AwardTypeRepository&\PHPUnit\Framework\MockObject\MockObject $awardTypeRepository;
private EntityManagerInterface&\PHPUnit\Framework\MockObject\MockObject $em;
protected function setUp(): void
{
$this->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;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Actor;
use App\Entity\Award;
use App\Entity\AwardType;
use App\Entity\GameRow;
use App\Repository\ActorRepository;
use App\Repository\AwardRepository;
use App\Repository\MovieRepository;
use App\Repository\MovieRoleRepository;
use App\Service\GameGridGenerator;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
class GameGridGeneratorTest extends TestCase
{
public function testResolveHintTextForAward(): void
{
$awardType = new AwardType();
$awardType->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);
}
}