# Mission + Badge DB Migration & Badge Gallery — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Migrate missions from JSON to Doctrine DB, create Badge entity, add public badge gallery page + home section.

**Architecture:** Create Mission and Badge Doctrine entities. Mission stores flat fields + JSON columns for nested data (preparation, steps, tags). Badge is a separate entity with OneToMany to Mission. Refactor MissionService to use Doctrine. Update MissionReview to use FK to Mission. Add import/export CLI commands. Add public BadgeController with /oznaki page and home section.

**Tech Stack:** PHP 8.4, Symfony 8.0, Doctrine ORM, MySQL 5.7, Twig

---

### Task 1: Create Badge entity

**Files:**
- Create: `src/Entity/Badge.php`
- Create: `src/Repository/BadgeRepository.php`

**Step 1: Create the Badge entity**

Create `src/Entity/Badge.php`:

```php
<?php

namespace App\Entity;

use App\Repository\BadgeRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: BadgeRepository::class)]
class Badge
{
    #[ORM\Id]
    #[ORM\Column(length: 100)]
    private string $id;

    #[ORM\Column(length: 255)]
    private string $name;

    #[ORM\Column(type: 'smallint')]
    private int $xpReward;

    /** @var Collection<int, Mission> */
    #[ORM\OneToMany(targetEntity: Mission::class, mappedBy: 'badge')]
    private Collection $missions;

    public function __construct(string $id, string $name, int $xpReward)
    {
        $this->id = $id;
        $this->name = $name;
        $this->xpReward = $xpReward;
        $this->missions = new ArrayCollection();
    }

    public function getId(): string { return $this->id; }
    public function getName(): string { return $this->name; }
    public function setName(string $name): static { $this->name = $name; return $this; }
    public function getXpReward(): int { return $this->xpReward; }
    public function setXpReward(int $xpReward): static { $this->xpReward = $xpReward; return $this; }

    /** @return Collection<int, Mission> */
    public function getMissions(): Collection { return $this->missions; }

    /** Derive character from first associated mission */
    public function getCharacter(): ?string
    {
        return $this->missions->isEmpty() ? null : $this->missions->first()->getCharacter();
    }

    /** Derive location from first associated mission */
    public function getLocation(): ?string
    {
        return $this->missions->isEmpty() ? null : $this->missions->first()->getLocation();
    }
}
```

Create `src/Repository/BadgeRepository.php`:

```php
<?php

namespace App\Repository;

use App\Entity\Badge;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Badge>
 */
class BadgeRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Badge::class);
    }

    /**
     * @return Badge[]
     */
    public function findAllWithMissions(array $filters = []): array
    {
        $qb = $this->createQueryBuilder('b')
            ->join('b.missions', 'm')
            ->addSelect('m')
            ->orderBy('b.name', 'ASC');

        if (!empty($filters['character'])) {
            $qb->andWhere('m.character = :character')
               ->setParameter('character', $filters['character']);
        }
        if (!empty($filters['location'])) {
            $qb->andWhere('m.location = :location')
               ->setParameter('location', $filters['location']);
        }

        return $qb->getQuery()->getResult();
    }
}
```

**Step 2: Verify entity is valid**

Run:
```bash
php bin/console doctrine:schema:validate 2>&1 | head -20
```

Expected: Badge entity recognized (will show errors about missing Mission entity — that's fine, we create it next).

**Step 3: Commit**

```bash
git add src/Entity/Badge.php src/Repository/BadgeRepository.php
git commit -m "Add Badge entity and repository"
```

---

### Task 2: Create Mission entity

**Files:**
- Create: `src/Entity/Mission.php`
- Create: `src/Repository/MissionRepository.php`

**Step 1: Create the Mission entity**

Create `src/Entity/Mission.php`:

```php
<?php

namespace App\Entity;

use App\Repository\MissionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: MissionRepository::class)]
class Mission
{
    #[ORM\Id]
    #[ORM\Column(length: 100)]
    private string $id;

    #[ORM\Column(type: 'smallint')]
    private int $version = 1;

    #[ORM\Column(length: 20)]
    private string $character;

    #[ORM\Column(length: 20)]
    private string $location;

    #[ORM\Column(length: 20)]
    private string $difficulty;

    #[ORM\Column(length: 255)]
    private string $title;

    #[ORM\Column(type: 'smallint')]
    private int $ageMin;

    #[ORM\Column(type: 'smallint')]
    private int $ageMax;

    #[ORM\Column(type: 'smallint')]
    private int $playersMin;

    #[ORM\Column(type: 'smallint')]
    private int $playersMax;

    #[ORM\Column(type: 'smallint')]
    private int $estimatedMinutes;

    #[ORM\Column(type: Types::JSON)]
    private array $tags = [];

    #[ORM\Column(type: Types::TEXT)]
    private string $introDialog;

    #[ORM\Column(type: Types::JSON)]
    private array $preparation = [];

    #[ORM\Column(type: Types::JSON)]
    private array $steps = [];

    #[ORM\Column(type: Types::TEXT)]
    private string $completionDialog;

    #[ORM\ManyToOne(targetEntity: Badge::class, inversedBy: 'missions')]
    #[ORM\JoinColumn(name: 'badge_id', referencedColumnName: 'id', nullable: true)]
    private ?Badge $badge = null;

    public function getId(): string { return $this->id; }
    public function setId(string $id): static { $this->id = $id; return $this; }

    public function getVersion(): int { return $this->version; }
    public function setVersion(int $version): static { $this->version = $version; return $this; }

    public function getCharacter(): string { return $this->character; }
    public function setCharacter(string $character): static { $this->character = $character; return $this; }

    public function getLocation(): string { return $this->location; }
    public function setLocation(string $location): static { $this->location = $location; return $this; }

    public function getDifficulty(): string { return $this->difficulty; }
    public function setDifficulty(string $difficulty): static { $this->difficulty = $difficulty; return $this; }

    public function getTitle(): string { return $this->title; }
    public function setTitle(string $title): static { $this->title = $title; return $this; }

    public function getAgeMin(): int { return $this->ageMin; }
    public function setAgeMin(int $ageMin): static { $this->ageMin = $ageMin; return $this; }

    public function getAgeMax(): int { return $this->ageMax; }
    public function setAgeMax(int $ageMax): static { $this->ageMax = $ageMax; return $this; }

    public function getPlayersMin(): int { return $this->playersMin; }
    public function setPlayersMin(int $playersMin): static { $this->playersMin = $playersMin; return $this; }

    public function getPlayersMax(): int { return $this->playersMax; }
    public function setPlayersMax(int $playersMax): static { $this->playersMax = $playersMax; return $this; }

    public function getEstimatedMinutes(): int { return $this->estimatedMinutes; }
    public function setEstimatedMinutes(int $estimatedMinutes): static { $this->estimatedMinutes = $estimatedMinutes; return $this; }

    public function getTags(): array { return $this->tags; }
    public function setTags(array $tags): static { $this->tags = $tags; return $this; }

    public function getIntroDialog(): string { return $this->introDialog; }
    public function setIntroDialog(string $introDialog): static { $this->introDialog = $introDialog; return $this; }

    public function getPreparation(): array { return $this->preparation; }
    public function setPreparation(array $preparation): static { $this->preparation = $preparation; return $this; }

    public function getSteps(): array { return $this->steps; }
    public function setSteps(array $steps): static { $this->steps = $steps; return $this; }

    public function getCompletionDialog(): string { return $this->completionDialog; }
    public function setCompletionDialog(string $completionDialog): static { $this->completionDialog = $completionDialog; return $this; }

    public function getBadge(): ?Badge { return $this->badge; }
    public function setBadge(?Badge $badge): static { $this->badge = $badge; return $this; }

    public function getAgeRange(): string
    {
        return $this->ageMin . '-' . $this->ageMax;
    }
}
```

Create `src/Repository/MissionRepository.php`:

```php
<?php

namespace App\Repository;

use App\Entity\Mission;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Mission>
 */
class MissionRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Mission::class);
    }

    /**
     * @return Mission[]
     */
    public function findFiltered(array $filters = []): array
    {
        $qb = $this->createQueryBuilder('m')
            ->leftJoin('m.badge', 'b')
            ->addSelect('b')
            ->orderBy('m.id', 'ASC');

        if (!empty($filters['character'])) {
            $qb->andWhere('m.character = :character')
               ->setParameter('character', $filters['character']);
        }
        if (!empty($filters['location'])) {
            $qb->andWhere('m.location = :location')
               ->setParameter('location', $filters['location']);
        }
        if (!empty($filters['difficulty'])) {
            $qb->andWhere('m.difficulty = :difficulty')
               ->setParameter('difficulty', $filters['difficulty']);
        }
        if (!empty($filters['search'])) {
            $qb->andWhere('m.title LIKE :search OR m.id LIKE :search')
               ->setParameter('search', '%' . $filters['search'] . '%');
        }

        return $qb->getQuery()->getResult();
    }
}
```

**Step 2: Verify both entities**

Run:
```bash
php bin/console doctrine:schema:validate
```

Expected: Schema is valid (may warn about sync — that's expected before migration).

**Step 3: Commit**

```bash
git add src/Entity/Mission.php src/Repository/MissionRepository.php
git commit -m "Add Mission entity and repository"
```

---

### Task 3: Generate and run migration

**Files:**
- Create: `migrations/VersionXXXX.php` (auto-generated)

**Step 1: Generate migration**

Run:
```bash
php bin/console doctrine:migrations:diff
```

Expected: New migration file created with CREATE TABLE badge, CREATE TABLE mission.

**Step 2: Run migration**

Run:
```bash
php bin/console doctrine:migrations:migrate --no-interaction
```

Expected: Tables created successfully.

**Step 3: Verify tables exist**

Run:
```bash
php bin/console doctrine:schema:validate
```

Expected: Schema is in sync with mapping.

**Step 4: Commit**

```bash
git add migrations/
git commit -m "Add migration for Mission and Badge tables"
```

---

### Task 4: Update MissionReview to use FK

**Files:**
- Modify: `src/Entity/MissionReview.php`
- Modify: `src/Repository/MissionReviewRepository.php`

**Step 1: Update MissionReview entity**

In `src/Entity/MissionReview.php`, replace the `missionId` string column with a proper relation:

Replace:
```php
#[ORM\Column(length: 50)]
private ?string $missionId = null;
```

With:
```php
#[ORM\ManyToOne(targetEntity: Mission::class)]
#[ORM\JoinColumn(name: 'mission_id', referencedColumnName: 'id', nullable: false)]
private ?Mission $mission = null;
```

Update getter/setter — replace `getMissionId/setMissionId` with:
```php
public function getMission(): ?Mission { return $this->mission; }
public function setMission(Mission $mission): static { $this->mission = $mission; return $this; }
public function getMissionId(): ?string { return $this->mission?->getId(); }
```

**Step 2: Update MissionReviewRepository**

In `src/Repository/MissionReviewRepository.php`:

Replace `findByMissionId`:
```php
public function findByMissionId(string $missionId): ?MissionReview
{
    return $this->createQueryBuilder('r')
        ->join('r.mission', 'm')
        ->where('m.id = :id')
        ->setParameter('id', $missionId)
        ->getQuery()
        ->getOneOrNullResult();
}
```

Replace `findAllIndexedByMissionId`:
```php
/** @return array<string, MissionReview> */
public function findAllIndexedByMissionId(): array
{
    $reviews = $this->findAll();
    $indexed = [];
    foreach ($reviews as $review) {
        $indexed[$review->getMissionId()] = $review;
    }
    return $indexed;
}
```

**Step 3: Generate and run migration**

Run:
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```

Note: This may require clearing old MissionReview rows if they reference non-existent missions. If migration fails, truncate mission_review table first (no production data yet).

**Step 4: Commit**

```bash
git add src/Entity/MissionReview.php src/Repository/MissionReviewRepository.php migrations/
git commit -m "Update MissionReview to use FK to Mission entity"
```

---

### Task 5: Create import command

**Files:**
- Create: `src/Command/ImportMissionsCommand.php`

**Step 1: Create the command**

Create `src/Command/ImportMissionsCommand.php`:

```php
<?php

namespace App\Command;

use App\Entity\Badge;
use App\Entity\Mission;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:import-missions', description: 'Import missions from JSON file into database')]
class ImportMissionsCommand extends Command
{
    public function __construct(private EntityManagerInterface $em)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('file', InputArgument::REQUIRED, 'Path to missions JSON file');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $file = $input->getArgument('file');

        if (!file_exists($file)) {
            $io->error("File not found: $file");
            return Command::FAILURE;
        }

        $data = json_decode(file_get_contents($file), true);
        if (!is_array($data)) {
            $io->error('Invalid JSON — expected array of missions.');
            return Command::FAILURE;
        }

        $badgeRepo = $this->em->getRepository(Badge::class);
        $missionRepo = $this->em->getRepository(Mission::class);
        $added = 0;
        $skipped = 0;

        foreach ($data as $item) {
            $id = $item['id'] ?? null;
            if (!$id) { $skipped++; continue; }

            if ($missionRepo->find($id)) { $skipped++; continue; }

            // Upsert badge
            $badge = null;
            $badgeId = $item['completion']['badge_id'] ?? null;
            if ($badgeId) {
                $badge = $badgeRepo->find($badgeId);
                if (!$badge) {
                    $badge = new Badge(
                        $badgeId,
                        $item['completion']['badge_name'] ?? $badgeId,
                        $item['completion']['xp_reward'] ?? 0,
                    );
                    $this->em->persist($badge);
                }
            }

            $mission = new Mission();
            $mission->setId($id);
            $mission->setVersion($item['version'] ?? 1);
            $mission->setCharacter($item['character'] ?? '');
            $mission->setLocation($item['location'] ?? '');
            $mission->setDifficulty($item['difficulty'] ?? 'easy');
            $mission->setTitle($item['title'] ?? '');
            $mission->setAgeMin($item['age_min'] ?? 6);
            $mission->setAgeMax($item['age_max'] ?? 9);
            $mission->setPlayersMin($item['players_min'] ?? 1);
            $mission->setPlayersMax($item['players_max'] ?? 2);
            $mission->setEstimatedMinutes($item['estimated_minutes'] ?? 15);
            $mission->setTags($item['tags'] ?? []);
            $mission->setIntroDialog($item['intro_dialog'] ?? '');
            $mission->setPreparation($item['preparation'] ?? []);
            $mission->setSteps($item['steps'] ?? []);
            $mission->setCompletionDialog($item['completion']['dialog'] ?? '');
            $mission->setBadge($badge);

            $this->em->persist($mission);
            $added++;
        }

        $this->em->flush();
        $io->success("Import done: $added added, $skipped skipped.");

        return Command::SUCCESS;
    }
}
```

**Step 2: Test import**

Run:
```bash
php bin/console app:import-missions public/data/missions.json
```

Expected: `Import done: 121 added, 0 skipped.`

**Step 3: Verify data**

Run:
```bash
php bin/console doctrine:query:sql "SELECT COUNT(*) FROM mission"
php bin/console doctrine:query:sql "SELECT COUNT(*) FROM badge"
```

Expected: 121 missions, ~99 badges.

**Step 4: Commit**

```bash
git add src/Command/ImportMissionsCommand.php
git commit -m "Add CLI command to import missions from JSON"
```

---

### Task 6: Create export command

**Files:**
- Create: `src/Command/ExportMissionsCommand.php`

**Step 1: Create the command**

Create `src/Command/ExportMissionsCommand.php`:

```php
<?php

namespace App\Command;

use App\Repository\MissionRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:export-missions', description: 'Export missions from database to JSON file')]
class ExportMissionsCommand extends Command
{
    public function __construct(private MissionRepository $missionRepo)
    {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this->addArgument('file', InputArgument::OPTIONAL, 'Output file path', 'public/data/missions.json');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $missions = $this->missionRepo->findFiltered();

        $data = [];
        foreach ($missions as $mission) {
            $item = [
                'id' => $mission->getId(),
                'version' => $mission->getVersion(),
                'character' => $mission->getCharacter(),
                'location' => $mission->getLocation(),
                'difficulty' => $mission->getDifficulty(),
                'age_min' => $mission->getAgeMin(),
                'age_max' => $mission->getAgeMax(),
                'players_min' => $mission->getPlayersMin(),
                'players_max' => $mission->getPlayersMax(),
                'estimated_minutes' => $mission->getEstimatedMinutes(),
                'title' => $mission->getTitle(),
                'tags' => $mission->getTags(),
                'intro_dialog' => $mission->getIntroDialog(),
                'preparation' => $mission->getPreparation(),
                'steps' => $mission->getSteps(),
                'completion' => [
                    'dialog' => $mission->getCompletionDialog(),
                    'badge_id' => $mission->getBadge()?->getId(),
                    'badge_name' => $mission->getBadge()?->getName(),
                    'xp_reward' => $mission->getBadge()?->getXpReward() ?? 0,
                ],
            ];
            $data[] = $item;
        }

        $file = $input->getArgument('file');
        file_put_contents($file, json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE));
        $io->success(sprintf('Exported %d missions to %s', count($data), $file));

        return Command::SUCCESS;
    }
}
```

**Step 2: Test export**

Run:
```bash
php bin/console app:export-missions /tmp/test-export.json
head -20 /tmp/test-export.json
```

Expected: JSON structure matches original format.

**Step 3: Commit**

```bash
git add src/Command/ExportMissionsCommand.php
git commit -m "Add CLI command to export missions to JSON"
```

---

### Task 7: Refactor MissionService to use Doctrine

**Files:**
- Modify: `src/Service/MissionService.php`

**Step 1: Rewrite MissionService**

Replace entire `src/Service/MissionService.php`:

```php
<?php

namespace App\Service;

use App\Entity\Badge;
use App\Entity\Mission;
use App\Repository\MissionRepository;
use Doctrine\ORM\EntityManagerInterface;

class MissionService
{
    public function __construct(
        private MissionRepository $missionRepo,
        private EntityManagerInterface $em,
    ) {}

    /** @return Mission[] */
    public function getAll(array $filters = []): array
    {
        return $this->missionRepo->findFiltered($filters);
    }

    public function getById(string $id): ?Mission
    {
        return $this->missionRepo->find($id);
    }

    /**
     * Import missions from JSON array, skipping duplicates by id.
     * @return array{added: int, skipped: int}
     */
    public function importMissions(array $newMissions): array
    {
        $badgeRepo = $this->em->getRepository(Badge::class);
        $added = 0;
        $skipped = 0;

        foreach ($newMissions as $item) {
            $id = $item['id'] ?? null;
            if (!$id || $this->missionRepo->find($id)) {
                $skipped++;
                continue;
            }

            $badge = null;
            $badgeId = $item['completion']['badge_id'] ?? null;
            if ($badgeId) {
                $badge = $badgeRepo->find($badgeId);
                if (!$badge) {
                    $badge = new Badge(
                        $badgeId,
                        $item['completion']['badge_name'] ?? $badgeId,
                        $item['completion']['xp_reward'] ?? 0,
                    );
                    $this->em->persist($badge);
                }
            }

            $mission = new Mission();
            $mission->setId($id);
            $mission->setVersion($item['version'] ?? 1);
            $mission->setCharacter($item['character'] ?? '');
            $mission->setLocation($item['location'] ?? '');
            $mission->setDifficulty($item['difficulty'] ?? 'easy');
            $mission->setTitle($item['title'] ?? '');
            $mission->setAgeMin($item['age_min'] ?? 6);
            $mission->setAgeMax($item['age_max'] ?? 9);
            $mission->setPlayersMin($item['players_min'] ?? 1);
            $mission->setPlayersMax($item['players_max'] ?? 2);
            $mission->setEstimatedMinutes($item['estimated_minutes'] ?? 15);
            $mission->setTags($item['tags'] ?? []);
            $mission->setIntroDialog($item['intro_dialog'] ?? '');
            $mission->setPreparation($item['preparation'] ?? []);
            $mission->setSteps($item['steps'] ?? []);
            $mission->setCompletionDialog($item['completion']['dialog'] ?? '');
            $mission->setBadge($badge);

            $this->em->persist($mission);
            $added++;
        }

        $this->em->flush();

        return ['added' => $added, 'skipped' => $skipped];
    }
}
```

**Step 2: Verify service works**

Run:
```bash
php bin/console cache:clear
```

Expected: No errors.

**Step 3: Commit**

```bash
git add src/Service/MissionService.php
git commit -m "Refactor MissionService from JSON to Doctrine"
```

---

### Task 8: Update Admin MissionController

**Files:**
- Modify: `src/Controller/Admin/MissionController.php`
- Modify: `templates/admin/mission/index.html.twig`
- Modify: `templates/admin/mission/show.html.twig`

**Step 1: Update controller**

The controller now receives Mission entities instead of arrays. Update `src/Controller/Admin/MissionController.php`:

```php
<?php

namespace App\Controller\Admin;

use App\Entity\Mission;
use App\Entity\MissionReview;
use App\Repository\MissionReviewRepository;
use App\Service\MissionService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/admin/misje')]
#[IsGranted('ROLE_ADMIN')]
class MissionController extends AbstractController
{
    public function __construct(
        private MissionService $missionService,
        private MissionReviewRepository $reviewRepo,
    ) {}

    #[Route('', name: 'app_admin_missions')]
    public function index(Request $request): Response
    {
        $filters = [
            'character' => $request->query->get('character'),
            'location' => $request->query->get('location'),
            'difficulty' => $request->query->get('difficulty'),
            'search' => $request->query->get('search'),
        ];

        $reviews = $this->reviewRepo->findAllIndexedByMissionId();

        return $this->render('admin/mission/index.html.twig', [
            'missions' => $this->missionService->getAll($filters),
            'filters' => $filters,
            'reviews' => $reviews,
        ]);
    }

    #[Route('/import', name: 'app_admin_missions_import', methods: ['POST'])]
    public function import(Request $request): Response
    {
        /** @var UploadedFile|null $file */
        $file = $request->files->get('json_file');

        if (!$file || !$file->isValid()) {
            $this->addFlash('error', 'Nie przesłano pliku lub plik jest uszkodzony.');
            return $this->redirectToRoute('app_admin_missions');
        }

        $content = file_get_contents($file->getPathname());
        $data = json_decode($content, true);

        if (!is_array($data)) {
            $this->addFlash('error', 'Plik nie zawiera poprawnego JSON (oczekiwana tablica misji).');
            return $this->redirectToRoute('app_admin_missions');
        }

        $result = $this->missionService->importMissions($data);

        $this->addFlash('success', sprintf(
            'Import zakończony: dodano %d nowych misji, pominięto %d duplikatów.',
            $result['added'],
            $result['skipped'],
        ));

        return $this->redirectToRoute('app_admin_missions');
    }

    #[Route('/{id}', name: 'app_admin_mission_show')]
    public function show(string $id): Response
    {
        $mission = $this->missionService->getById($id);
        if (!$mission) {
            throw $this->createNotFoundException();
        }

        $review = $this->reviewRepo->findByMissionId($id);

        return $this->render('admin/mission/show.html.twig', [
            'mission' => $mission,
            'review' => $review,
        ]);
    }

    #[Route('/{id}/review', name: 'app_admin_mission_review', methods: ['POST'])]
    public function review(string $id, Request $request, EntityManagerInterface $em): Response
    {
        $mission = $this->missionService->getById($id);
        if (!$mission) {
            throw $this->createNotFoundException();
        }

        $review = $this->reviewRepo->findByMissionId($id) ?? new MissionReview();
        $review->setMission($mission);
        $review->setStatus($request->request->get('status', 'approved'));
        $review->setNotes($request->request->get('notes'));
        $review->setReviewedAt(new \DateTimeImmutable());
        $review->setReviewedBy($this->getUser()?->getUserIdentifier());

        $em->persist($review);
        $em->flush();

        $this->addFlash('success', 'Status misji zaktualizowany.');

        return $this->redirectToRoute('app_admin_mission_show', ['id' => $id]);
    }
}
```

**Step 2: Update templates**

Templates now receive Mission entities (object accessors instead of array keys).

In `templates/admin/mission/index.html.twig`, update the table body — array access `mission.id` stays the same thanks to Twig's property accessor, but `mission.steps|length` needs `mission.steps|length` (works with array from JSON column). Main changes:
- `mission.age_range` → `mission.ageRange` (method call)
- Template logic stays mostly the same since Twig resolves both array keys and getters

In `templates/admin/mission/show.html.twig`, update field access:
- `mission.intro_dialog` → `mission.introDialog`
- `mission.parent_instructions` → `mission.preparation.parent_instructions`
- `mission.completion_dialog` → `mission.completionDialog`
- `mission.badge` → `mission.badge.name`
- `mission.xp` → `mission.badge.xpReward`

(Full template code provided to implementer — replace property access patterns.)

**Step 3: Verify admin pages work**

Run `symfony serve`, visit `/admin/misje`. Verify listing loads, click into a mission detail.

**Step 4: Commit**

```bash
git add src/Controller/Admin/MissionController.php templates/admin/mission/
git commit -m "Update admin mission pages for Doctrine entities"
```

---

### Task 9: Create public BadgeController + /oznaki page

**Files:**
- Create: `src/Controller/BadgeController.php`
- Create: `templates/badge/index.html.twig`

**Step 1: Create controller**

Create `src/Controller/BadgeController.php`:

```php
<?php

namespace App\Controller;

use App\Repository\BadgeRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BadgeController extends AbstractController
{
    #[Route('/oznaki', name: 'app_badges')]
    public function index(Request $request, BadgeRepository $badgeRepo): Response
    {
        $filters = [
            'character' => $request->query->get('character'),
            'location' => $request->query->get('location'),
        ];

        $badges = $badgeRepo->findAllWithMissions($filters);

        return $this->render('badge/index.html.twig', [
            'badges' => $badges,
            'filters' => $filters,
            'totalCount' => $badgeRepo->count(),
        ]);
    }
}
```

**Step 2: Create template**

Create `templates/badge/index.html.twig`:

```twig
{% extends 'base.html.twig' %}
{% block title %}Oznaki — 99 oznak do zdobycia | TROPO{% endblock %}
{% block meta_description %}Zbieraj oznaki w TROPO! 99 unikalnych odznak za ukończone misje. Tropiciel, Budowniczy, Poeta Przyrody i więcej.{% endblock %}

{% block body %}
{% from '_macros/image.html.twig' import picture %}
{% set charColors = {scout: 'scout', blitz: 'blitz', nova: 'nova', echo: 'echo'} %}
{% set charNames = {scout: 'Scout', blitz: 'Blitz', nova: 'Nova', echo: 'Echo'} %}
{% set locNames = {home: 'Dom', park: 'Park'} %}

{# Hero #}
<section class="py-20 bg-gradient-to-b from-cream to-white">
    <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
        <h1 class="text-4xl sm:text-5xl font-extrabold text-dark">Zbieraj oznaki</h1>
        <p class="mt-4 text-lg text-gray-500 max-w-2xl mx-auto">
            Każda ukończona misja to nowa odznaka w Twojej kolekcji.
            {{ totalCount }} unikalnych oznak czeka na odkrycie!
        </p>
        <div class="mt-10 flex justify-center gap-12">
            <div>
                <p class="text-4xl font-extrabold text-dark">{{ totalCount }}</p>
                <p class="text-sm text-gray-500">oznak</p>
            </div>
            <div>
                <p class="text-4xl font-extrabold text-dark">4</p>
                <p class="text-sm text-gray-500">postacie</p>
            </div>
            <div>
                <p class="text-4xl font-extrabold text-dark">2</p>
                <p class="text-sm text-gray-500">lokalizacje</p>
            </div>
        </div>
    </div>
</section>

{# Filters #}
<section class="bg-white border-b border-gray-100">
    <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
        <div class="flex flex-wrap gap-3 justify-center">
            <a href="{{ path('app_badges') }}"
               class="px-4 py-2 rounded-full text-sm font-semibold border-2 transition {{ not filters.character and not filters.location ? 'bg-dark text-white border-dark' : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' }}">
                Wszystkie
            </a>
            {% for slug, name in charNames %}
            <a href="{{ path('app_badges', {character: slug}) }}"
               class="px-4 py-2 rounded-full text-sm font-semibold border-2 transition"
               style="{{ filters.character == slug ? 'background-color: var(--color-' ~ slug ~ '); color: white; border-color: var(--color-' ~ slug ~ ')' : 'border-color: var(--color-' ~ slug ~ '); color: var(--color-' ~ slug ~ ')' }}">
                {{ name }}
            </a>
            {% endfor %}
            <span class="border-l border-gray-200 mx-1"></span>
            {% for locSlug, locName in locNames %}
            <a href="{{ path('app_badges', filters|merge({location: locSlug})) }}"
               class="px-4 py-2 rounded-full text-sm font-semibold border-2 transition {{ filters.location == locSlug ? 'bg-dark text-white border-dark' : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300' }}">
                {{ locName }}
            </a>
            {% endfor %}
        </div>
    </div>
</section>

{# Badge grid #}
<section class="py-16 bg-cream">
    <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
        <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
            {% for badge in badges %}
            {% set char = badge.character %}
            {% set color = charColors[char] ?? 'gray-400' %}
            <div class="bg-white rounded-2xl p-5 border-2 hover:shadow-lg transition-shadow" style="border-color: var(--color-{{ color }})">
                <div class="flex items-center gap-3 mb-3">
                    <div class="w-10 h-10 rounded-full overflow-hidden border-2 shrink-0" style="border-color: var(--color-{{ color }})">
                        {{ picture('images/characters/' ~ char ~ '_ikona.png', 'char_icon', charNames[char] ?? char, 'w-full h-full object-cover rounded-full') }}
                    </div>
                    <span class="text-2xl">🏅</span>
                </div>
                <h3 class="font-bold text-dark text-sm">{{ badge.name }}</h3>
                <div class="mt-2 flex flex-wrap gap-1">
                    <span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{{ locNames[badge.location] ?? badge.location }}</span>
                    <span class="text-xs px-2 py-0.5 rounded-full font-semibold" style="background-color: var(--color-{{ color }}); color: white; opacity: 0.8">{{ badge.xpReward }} XP</span>
                </div>
            </div>
            {% endfor %}
        </div>

        {% if badges is empty %}
        <div class="text-center py-12">
            <p class="text-gray-500">Brak oznak pasujących do filtrów.</p>
        </div>
        {% endif %}
    </div>
</section>

{# CTA #}
<section class="py-16 bg-white">
    <div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
        <h2 class="text-2xl font-bold text-dark">Chcesz zacząć kolekcję?</h2>
        <a href="{{ path('app_download') }}" class="mt-6 inline-flex items-center px-8 py-4 bg-blitz text-white text-lg font-bold rounded-full hover:bg-blitz/90 transition shadow-lg">
            Pobierz TROPO
        </a>
    </div>
</section>
{% endblock %}
```

**Step 3: Verify page works**

Visit `http://localhost:8000/oznaki`. Verify grid renders with badge cards, filters work.

**Step 4: Commit**

```bash
git add src/Controller/BadgeController.php templates/badge/index.html.twig
git commit -m "Add public badge gallery page at /oznaki"
```

---

### Task 10: Add badge section to home page

**Files:**
- Modify: `src/Controller/HomeController.php` (pass badge count)
- Modify: `templates/home/index.html.twig`

**Step 1: Update HomeController**

Read the existing HomeController first. Add BadgeRepository injection and pass `badgeCount` to template.

**Step 2: Add section to home template**

In `templates/home/index.html.twig`, add new section between "Jak to działa" (section 2) and "Poznaj drużynę" (section 3):

```twig
{# SEKCJA 2.5 — ZBIERAJ OZNAKI #}
<section class="py-20 bg-cream">
    <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
        <h2 class="text-3xl sm:text-4xl font-bold text-dark">Zbieraj oznaki</h2>
        <p class="mt-4 text-gray-500 max-w-xl mx-auto">Każda misja to nowa odznaka w Twojej kolekcji — {{ badgeCount }} unikalnych trofeów!</p>

        <div class="mt-10 flex justify-center gap-12">
            <div>
                <p class="text-4xl font-extrabold text-blitz">{{ badgeCount }}</p>
                <p class="text-sm text-gray-500">oznak</p>
            </div>
            <div>
                <p class="text-4xl font-extrabold text-scout">4</p>
                <p class="text-sm text-gray-500">postacie</p>
            </div>
            <div>
                <p class="text-4xl font-extrabold text-echo">2</p>
                <p class="text-sm text-gray-500">lokalizacje</p>
            </div>
        </div>

        <div class="mt-12 grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-2xl mx-auto">
            {% set sampleBadges = [
                {name: 'Tropiciel', char: 'scout'},
                {name: 'Domowy Olimpijczyk', char: 'blitz'},
                {name: 'Budowniczy', char: 'nova'},
                {name: 'Poeta Przyrody', char: 'echo'}
            ] %}
            {% for b in sampleBadges %}
            <div class="bg-white rounded-xl p-4 border-2 text-center" style="border-color: var(--color-{{ b.char }})">
                <span class="text-2xl">🏅</span>
                <p class="mt-1 text-sm font-bold text-dark">{{ b.name }}</p>
            </div>
            {% endfor %}
        </div>

        <a href="{{ path('app_badges') }}" class="mt-8 inline-block text-scout font-semibold hover:underline">
            Zobacz wszystkie oznaki →
        </a>
    </div>
</section>
```

**Step 3: Verify home page**

Visit `http://localhost:8000`. Verify new section appears between "Jak to działa" and "Poznaj drużynę".

**Step 4: Commit**

```bash
git add src/Controller/HomeController.php templates/home/index.html.twig
git commit -m "Add badge collection section to home page"
```

---

### Task 11: Add navigation link

**Files:**
- Modify: `templates/base.html.twig`

**Step 1: Add "Oznaki" to navbar**

In `templates/base.html.twig`, add link to `/oznaki` in both desktop nav and mobile menu, after "Postacie":

Desktop (after line with `Postacie` link):
```twig
<a href="{{ path('app_badges') }}" class="text-sm font-medium text-gray-600 hover:text-dark transition">Oznaki</a>
```

Mobile (same pattern):
```twig
<a href="{{ path('app_badges') }}" class="block text-sm font-medium text-gray-600">Oznaki</a>
```

Also add in footer "Odkrywaj" section.

**Step 2: Commit**

```bash
git add templates/base.html.twig
git commit -m "Add Oznaki link to navigation"
```

---

### Task 12: Add Capistrano deploy hook for migration

**Files:**
- Modify: `lib/capistrano/tasks/symfony.rake`

**Step 1: Ensure migration runs on deploy**

The existing `symfony.rake` already runs `doctrine:migrations:migrate` after deploy. No changes needed — just verify the hook order is correct and migrations will run automatically.

**Step 2: Rebuild Tailwind**

Run:
```bash
php bin/console tailwind:build
```

This is needed because new templates may introduce Tailwind classes that weren't in the previous build.

**Step 3: Final smoke test**

- Visit `/oznaki` — badge gallery with filters
- Visit `/` — badge section on home page
- Visit `/admin/misje` — admin still works with Doctrine
- Test import via admin (upload JSON)
- Test CLI: `php bin/console app:export-missions /tmp/test.json`

**Step 4: Commit any remaining fixes**

```bash
git add -A
git commit -m "Final adjustments for mission/badge DB migration"
```
