Files
ltbxd-actorle/docs/superpowers/plans/2026-03-29-user-import-notifications.md
2026-03-29 10:06:32 +02:00

2064 lines
51 KiB
Markdown

# User Film Import, Navbar & Notifications — 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:** Allow authenticated users to import their Letterboxd CSV, process it asynchronously (films + actors via TMDB), and receive notifications when done — all through a navbar with user dropdown and notification system.
**Architecture:** Symfony Messenger handles async processing in batches of 50 films. Files are stored on SeaweedFS (S3-compatible) via Flysystem. A polling-based notification system (Stimulus controller, 30s interval) provides feedback. Three new entities: `UserMovie`, `Import`, `Notification`. Logic from existing sync commands is extracted into reusable services.
**Tech Stack:** Symfony 8, Doctrine ORM, Symfony Messenger (Doctrine transport), Flysystem S3, Symfony Secrets, Stimulus controllers, Twig
---
## File Structure
### New Files
**Entities & Repositories:**
- `src/Entity/UserMovie.php` — join entity User ↔ Movie
- `src/Entity/Import.php` — import job tracking
- `src/Entity/Notification.php` — user notifications
- `src/Repository/UserMovieRepository.php`
- `src/Repository/ImportRepository.php`
- `src/Repository/NotificationRepository.php`
**Services (extracted from commands):**
- `src/Service/FilmImporter.php` — find or create a Movie from a parsed CSV row via TMDB
- `src/Service/ActorSyncer.php` — sync actors for a Movie via TMDB
**Messenger:**
- `src/Message/ProcessImportMessage.php`
- `src/Message/ImportFilmsBatchMessage.php`
- `src/MessageHandler/ProcessImportMessageHandler.php`
- `src/MessageHandler/ImportFilmsBatchMessageHandler.php`
**Controllers:**
- `src/Controller/ImportController.php``POST /api/imports`
- `src/Controller/NotificationController.php``GET /api/notifications`, `POST /api/notifications/read`
**Frontend:**
- `assets/controllers/dropdown_controller.js` — generic dropdown toggle
- `assets/controllers/notifications_controller.js` — notification polling, badge, title
- `assets/controllers/import_modal_controller.js` — modal + file upload
- `templates/_navbar.html.twig` — navbar partial
**Flysystem config:**
- `config/packages/flysystem.yaml`
### Modified Files
- `src/Entity/User.php` — add OneToMany relations
- `src/Command/SyncFilmsCommands.php` — refactor to use `FilmImporter`
- `src/Command/SyncActorsCommand.php` — refactor to use `ActorSyncer`
- `config/packages/messenger.yaml` — add message routing
- `templates/base.html.twig` — include navbar
- `assets/styles/app.css` — navbar, dropdown, modal, notification styles
---
## Task 1: Install Flysystem S3 & Configure SeaweedFS
**Files:**
- Modify: `composer.json`
- Create: `config/packages/flysystem.yaml`
- [ ] **Step 1: Install Flysystem packages**
```bash
composer require league/flysystem-bundle league/flysystem-aws-s3-v3
```
- [ ] **Step 2: Generate Symfony Secrets for S3 credentials**
```bash
php bin/console secrets:set S3_ACCESS_KEY
php bin/console secrets:set S3_SECRET_KEY
```
Enter the actual credentials when prompted. This creates `config/secrets/dev/` vault files.
- [ ] **Step 3: Create Flysystem config**
Create `config/packages/flysystem.yaml`:
```yaml
flysystem:
storages:
default.storage:
adapter: 'aws'
options:
client: 's3_client'
bucket: 'ltbxd-actorle'
services:
s3_client:
class: Aws\S3\S3Client
arguments:
- endpoint: 'https://s3.lclr.dev'
credentials:
key: '%env(secret:S3_ACCESS_KEY)%'
secret: '%env(secret:S3_SECRET_KEY)%'
region: 'us-east-1'
version: 'latest'
use_path_style_endpoint: true
```
Note: `use_path_style_endpoint: true` is needed for SeaweedFS (not a real AWS endpoint). The `region` is required by the S3 client but ignored by SeaweedFS.
- [ ] **Step 4: Verify Flysystem is wired**
```bash
php bin/console debug:container default.storage
```
Expected: shows the Flysystem storage service.
- [ ] **Step 5: Commit**
```bash
git add composer.json composer.lock symfony.lock config/packages/flysystem.yaml config/secrets/
git commit -m "feat: add Flysystem S3 storage for SeaweedFS"
```
---
## Task 2: Create UserMovie Entity
**Files:**
- Create: `src/Entity/UserMovie.php`
- Create: `src/Repository/UserMovieRepository.php`
- Modify: `src/Entity/User.php`
- [ ] **Step 1: Create the UserMovie entity**
Create `src/Entity/UserMovie.php`:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\UserMovieRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserMovieRepository::class)]
#[ORM\UniqueConstraint(name: 'user_movie_unique', columns: ['user_id', 'movie_id'])]
class UserMovie
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(targetEntity: Movie::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Movie $movie = null;
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getMovie(): ?Movie
{
return $this->movie;
}
public function setMovie(?Movie $movie): static
{
$this->movie = $movie;
return $this;
}
}
```
- [ ] **Step 2: Create the repository**
Create `src/Repository/UserMovieRepository.php`:
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\UserMovie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserMovie>
*/
class UserMovieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserMovie::class);
}
}
```
- [ ] **Step 3: Generate and run migration**
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 4: Verify the table**
```bash
php bin/console doctrine:schema:validate
```
Expected: no errors about the `user_movie` table.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/UserMovie.php src/Repository/UserMovieRepository.php migrations/
git commit -m "feat: add UserMovie join entity"
```
---
## Task 3: Create Import Entity
**Files:**
- Create: `src/Entity/Import.php`
- Create: `src/Repository/ImportRepository.php`
- [ ] **Step 1: Create the Import entity**
Create `src/Entity/Import.php`:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\ImportRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ImportRepository::class)]
class Import
{
public const string STATUS_PENDING = 'pending';
public const string STATUS_PROCESSING = 'processing';
public const string STATUS_COMPLETED = 'completed';
public const string STATUS_FAILED = 'failed';
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $filePath = null;
#[ORM\Column(length: 20)]
private string $status = self::STATUS_PENDING;
#[ORM\Column]
private int $totalBatches = 0;
#[ORM\Column]
private int $processedBatches = 0;
#[ORM\Column]
private int $totalFilms = 0;
#[ORM\Column]
private int $failedFilms = 0;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $completedAt = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getFilePath(): ?string
{
return $this->filePath;
}
public function setFilePath(string $filePath): static
{
$this->filePath = $filePath;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getTotalBatches(): int
{
return $this->totalBatches;
}
public function setTotalBatches(int $totalBatches): static
{
$this->totalBatches = $totalBatches;
return $this;
}
public function getProcessedBatches(): int
{
return $this->processedBatches;
}
public function setProcessedBatches(int $processedBatches): static
{
$this->processedBatches = $processedBatches;
return $this;
}
public function getTotalFilms(): int
{
return $this->totalFilms;
}
public function setTotalFilms(int $totalFilms): static
{
$this->totalFilms = $totalFilms;
return $this;
}
public function getFailedFilms(): int
{
return $this->failedFilms;
}
public function setFailedFilms(int $failedFilms): static
{
$this->failedFilms = $failedFilms;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getCompletedAt(): ?\DateTimeImmutable
{
return $this->completedAt;
}
public function setCompletedAt(?\DateTimeImmutable $completedAt): static
{
$this->completedAt = $completedAt;
return $this;
}
}
```
- [ ] **Step 2: Create the repository**
Create `src/Repository/ImportRepository.php`:
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Import;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Import>
*/
class ImportRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Import::class);
}
public function incrementProcessedBatches(Import $import): int
{
$this->getEntityManager()->getConnection()->executeStatement(
'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id',
['id' => $import->getId()]
);
return (int) $this->getEntityManager()->getConnection()->fetchOne(
'SELECT processed_batches FROM import WHERE id = :id',
['id' => $import->getId()]
);
}
public function incrementFailedFilms(Import $import): void
{
$this->getEntityManager()->getConnection()->executeStatement(
'UPDATE import SET failed_films = failed_films + 1 WHERE id = :id',
['id' => $import->getId()]
);
}
}
```
- [ ] **Step 3: Generate and run migration**
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Import.php src/Repository/ImportRepository.php migrations/
git commit -m "feat: add Import entity with batch tracking"
```
---
## Task 4: Create Notification Entity
**Files:**
- Create: `src/Entity/Notification.php`
- Create: `src/Repository/NotificationRepository.php`
- [ ] **Step 1: Create the Notification entity**
Create `src/Entity/Notification.php`:
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NotificationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NotificationRepository::class)]
class Notification
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\Column(length: 255)]
private ?string $message = null;
#[ORM\Column]
private bool $read = false;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function setUser(?User $user): static
{
$this->user = $user;
return $this;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message): static
{
$this->message = $message;
return $this;
}
public function isRead(): bool
{
return $this->read;
}
public function setRead(bool $read): static
{
$this->read = $read;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}
```
- [ ] **Step 2: Create the repository**
Create `src/Repository/NotificationRepository.php`:
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Notification;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Notification>
*/
class NotificationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Notification::class);
}
/**
* @return Notification[]
*/
public function findRecentForUser(User $user, int $limit = 20): array
{
return $this->createQueryBuilder('n')
->andWhere('n.user = :user')
->setParameter('user', $user)
->orderBy('n.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
public function countUnreadForUser(User $user): int
{
return $this->count(['user' => $user, 'read' => false]);
}
public function markAllReadForUser(User $user): void
{
$this->createQueryBuilder('n')
->update()
->set('n.read', ':true')
->where('n.user = :user')
->andWhere('n.read = :false')
->setParameter('true', true)
->setParameter('false', false)
->setParameter('user', $user)
->getQuery()
->execute();
}
}
```
- [ ] **Step 3: Generate and run migration**
```bash
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/
git commit -m "feat: add Notification entity"
```
---
## Task 5: Extract FilmImporter Service
**Files:**
- Create: `src/Service/FilmImporter.php`
- Modify: `src/Command/SyncFilmsCommands.php`
- [ ] **Step 1: Create FilmImporter service**
Create `src/Service/FilmImporter.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use App\Model\Ltbxd\LtbxdMovie;
use Doctrine\ORM\EntityManagerInterface;
readonly class FilmImporter
{
public function __construct(
private TMDBGateway $tmdbGateway,
private EntityManagerInterface $em,
) {}
/**
* Find an existing Movie by ltbxdRef or create a new one via TMDB.
* Returns null if the movie is not found on TMDB.
*
* @throws GatewayException
*/
public function importFromLtbxdMovie(LtbxdMovie $ltbxdMovie): ?Movie
{
$existing = $this->em->getRepository(Movie::class)->findOneBy(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()]);
if ($existing) {
return $existing;
}
$tmdbMovie = $this->tmdbGateway->searchMovie($ltbxdMovie->getName());
if (!$tmdbMovie) {
return null;
}
$movie = new Movie()
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
->setTitle($ltbxdMovie->getName())
->setTmdbId($tmdbMovie->getId());
$this->em->persist($movie);
return $movie;
}
}
```
- [ ] **Step 2: Refactor SyncFilmsCommands to use FilmImporter**
Replace the content of `src/Command/SyncFilmsCommands.php`:
```php
<?php
namespace App\Command;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-films')]
readonly class SyncFilmsCommands
{
public function __construct(
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
try {
$ltbxdMovies = $this->ltbxdGateway->parseFile();
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
$i = 0;
foreach ($ltbxdMovies as $ltbxdMovie) {
try {
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
if ($movie) {
$output->writeln('* Found '.$ltbxdMovie->getName());
}
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
return Command::FAILURE;
}
++$i;
if (0 === $i % 50) {
$this->em->flush();
}
}
$this->em->flush();
$output->writeln('Films synced');
return Command::SUCCESS;
}
}
```
- [ ] **Step 3: Verify the command still works**
```bash
php bin/console app:sync-films --help
```
Expected: command is recognized and shows its help.
- [ ] **Step 4: Commit**
```bash
git add src/Service/FilmImporter.php src/Command/SyncFilmsCommands.php
git commit -m "refactor: extract FilmImporter service from SyncFilmsCommands"
```
---
## Task 6: Extract ActorSyncer Service
**Files:**
- Create: `src/Service/ActorSyncer.php`
- Modify: `src/Command/SyncActorsCommand.php`
- [ ] **Step 1: Create ActorSyncer service**
Create `src/Service/ActorSyncer.php`:
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Actor;
use App\Entity\Movie;
use App\Entity\MovieRole;
use App\Exception\GatewayException;
use App\Gateway\TMDBGateway;
use Doctrine\ORM\EntityManagerInterface;
readonly class ActorSyncer
{
public function __construct(
private TMDBGateway $tmdbGateway,
private EntityManagerInterface $em,
) {}
/**
* Fetch credits from TMDB for the given movie and create missing Actor/MovieRole entries.
*
* @throws GatewayException
*/
public function syncActorsForMovie(Movie $movie): void
{
$creditsContext = $this->tmdbGateway->getMovieCredits($movie->getTmdbId());
foreach ($creditsContext->cast as $actorModel) {
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
if (!$actor instanceof Actor) {
$actor = new Actor()
->setPopularity($actorModel->popularity)
->setName($actorModel->name)
->setTmdbId($actorModel->id);
$this->em->persist($actor);
}
$existingRole = $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $movie]);
if (0 === $existingRole) {
$role = new MovieRole()
->setMovie($movie)
->setActor($actor)
->setCharacter($actorModel->character);
$this->em->persist($role);
}
}
}
}
```
Note: the original `SyncActorsCommand` had a bug where it only created roles when `count > 0` (i.e., when they already exist). The extracted service fixes this by creating roles when `count === 0`.
- [ ] **Step 2: Refactor SyncActorsCommand to use ActorSyncer**
Replace the content of `src/Command/SyncActorsCommand.php`:
```php
<?php
namespace App\Command;
use App\Entity\Movie;
use App\Exception\GatewayException;
use App\Service\ActorSyncer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand('app:sync-actors')]
readonly class SyncActorsCommand
{
public function __construct(
private ActorSyncer $actorSyncer,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
try {
$output->writeln('Syncing cast for '.$film->getTitle());
$this->actorSyncer->syncActorsForMovie($film);
} catch (GatewayException $e) {
$output->writeln('/!\ '.$e->getMessage());
continue;
}
$this->em->flush();
}
return Command::SUCCESS;
}
}
```
- [ ] **Step 3: Verify the command still works**
```bash
php bin/console app:sync-actors --help
```
Expected: command is recognized and shows its help.
- [ ] **Step 4: Commit**
```bash
git add src/Service/ActorSyncer.php src/Command/SyncActorsCommand.php
git commit -m "refactor: extract ActorSyncer service from SyncActorsCommand"
```
---
## Task 7: Create Messenger Messages
**Files:**
- Create: `src/Message/ProcessImportMessage.php`
- Create: `src/Message/ImportFilmsBatchMessage.php`
- Modify: `config/packages/messenger.yaml`
- [ ] **Step 1: Create ProcessImportMessage**
Create `src/Message/ProcessImportMessage.php`:
```php
<?php
declare(strict_types=1);
namespace App\Message;
readonly class ProcessImportMessage
{
public function __construct(
public int $importId,
) {}
}
```
- [ ] **Step 2: Create ImportFilmsBatchMessage**
Create `src/Message/ImportFilmsBatchMessage.php`:
```php
<?php
declare(strict_types=1);
namespace App\Message;
readonly class ImportFilmsBatchMessage
{
public function __construct(
public int $importId,
public int $offset,
public int $limit,
) {}
}
```
- [ ] **Step 3: Add message routing to messenger.yaml**
In `config/packages/messenger.yaml`, add the following under the `routing:` section:
```yaml
routing:
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
Symfony\Component\Notifier\Message\ChatMessage: async
Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\ProcessImportMessage: async
App\Message\ImportFilmsBatchMessage: async
```
- [ ] **Step 4: Commit**
```bash
git add src/Message/ config/packages/messenger.yaml
git commit -m "feat: add Messenger messages for import processing"
```
---
## Task 8: Create ProcessImportMessageHandler
**Files:**
- Create: `src/MessageHandler/ProcessImportMessageHandler.php`
- [ ] **Step 1: Create the handler**
Create `src/MessageHandler/ProcessImportMessageHandler.php`:
```php
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\Import;
use App\Entity\Notification;
use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage;
use App\Message\ProcessImportMessage;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
readonly class ProcessImportMessageHandler
{
private const int BATCH_SIZE = 50;
public function __construct(
private EntityManagerInterface $em,
private FilesystemOperator $defaultStorage,
private LtbxdGateway $ltbxdGateway,
private MessageBusInterface $bus,
private LoggerInterface $logger,
) {}
public function __invoke(ProcessImportMessage $message): void
{
$import = $this->em->getRepository(Import::class)->find($message->importId);
if (!$import) {
$this->logger->error('Import not found', ['importId' => $message->importId]);
return;
}
try {
$csvContent = $this->defaultStorage->read($import->getFilePath());
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
file_put_contents($tmpFile, $csvContent);
try {
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
} finally {
unlink($tmpFile);
}
$totalFilms = count($ltbxdMovies);
$totalBatches = (int) ceil($totalFilms / self::BATCH_SIZE);
$import->setTotalFilms($totalFilms);
$import->setTotalBatches($totalBatches);
$import->setStatus(Import::STATUS_PROCESSING);
$this->em->flush();
for ($i = 0; $i < $totalBatches; $i++) {
$this->bus->dispatch(new ImportFilmsBatchMessage(
importId: $import->getId(),
offset: $i * self::BATCH_SIZE,
limit: self::BATCH_SIZE,
));
}
} catch (\Throwable $e) {
$this->logger->error('Import processing failed', [
'importId' => $import->getId(),
'error' => $e->getMessage(),
]);
$import->setStatus(Import::STATUS_FAILED);
$this->em->flush();
$notification = new Notification();
$notification->setUser($import->getUser());
$notification->setMessage('L\'import a échoué.');
$this->em->persist($notification);
$this->em->flush();
}
}
}
```
- [ ] **Step 2: Add `parseFileFromPath` method to LtbxdGateway**
The existing `parseFile()` uses a hardcoded path from config. We need a variant that accepts a path argument. In `src/Gateway/LtbxdGateway.php`, add this method:
```php
/**
* @return LtbxdMovie[]
* @throws GatewayException
*/
public function parseFileFromPath(string $path): array
{
if (!file_exists($path)) {
throw new GatewayException(sprintf('Could not find file %s', $path));
}
$fileContent = file_get_contents($path);
try {
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
} catch (ExceptionInterface $e) {
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
}
}
```
Then refactor the existing `parseFile()` to delegate:
```php
public function parseFile(): array
{
return $this->parseFileFromPath($this->fileDir);
}
```
- [ ] **Step 3: Commit**
```bash
git add src/MessageHandler/ProcessImportMessageHandler.php src/Gateway/LtbxdGateway.php
git commit -m "feat: add ProcessImportMessageHandler"
```
---
## Task 9: Create ImportFilmsBatchMessageHandler
**Files:**
- Create: `src/MessageHandler/ImportFilmsBatchMessageHandler.php`
- [ ] **Step 1: Create the handler**
Create `src/MessageHandler/ImportFilmsBatchMessageHandler.php`:
```php
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\Import;
use App\Entity\Notification;
use App\Entity\UserMovie;
use App\Exception\GatewayException;
use App\Gateway\LtbxdGateway;
use App\Message\ImportFilmsBatchMessage;
use App\Repository\ImportRepository;
use App\Service\ActorSyncer;
use App\Service\FilmImporter;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
readonly class ImportFilmsBatchMessageHandler
{
public function __construct(
private EntityManagerInterface $em,
private FilesystemOperator $defaultStorage,
private LtbxdGateway $ltbxdGateway,
private FilmImporter $filmImporter,
private ActorSyncer $actorSyncer,
private ImportRepository $importRepository,
private LoggerInterface $logger,
) {}
public function __invoke(ImportFilmsBatchMessage $message): void
{
$import = $this->em->getRepository(Import::class)->find($message->importId);
if (!$import) {
$this->logger->error('Import not found', ['importId' => $message->importId]);
return;
}
$csvContent = $this->defaultStorage->read($import->getFilePath());
$tmpFile = tempnam(sys_get_temp_dir(), 'import_');
file_put_contents($tmpFile, $csvContent);
try {
$ltbxdMovies = $this->ltbxdGateway->parseFileFromPath($tmpFile);
} finally {
unlink($tmpFile);
}
$batch = array_slice($ltbxdMovies, $message->offset, $message->limit);
$user = $import->getUser();
foreach ($batch as $ltbxdMovie) {
try {
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
if (!$movie) {
$this->importRepository->incrementFailedFilms($import);
continue;
}
$this->actorSyncer->syncActorsForMovie($movie);
$existingLink = $this->em->getRepository(UserMovie::class)->findOneBy([
'user' => $user,
'movie' => $movie,
]);
if (!$existingLink) {
$userMovie = new UserMovie();
$userMovie->setUser($user);
$userMovie->setMovie($movie);
$this->em->persist($userMovie);
}
$this->em->flush();
} catch (\Throwable $e) {
$this->logger->warning('Failed to import film', [
'film' => $ltbxdMovie->getName(),
'importId' => $import->getId(),
'error' => $e->getMessage(),
]);
$this->importRepository->incrementFailedFilms($import);
}
}
$processedBatches = $this->importRepository->incrementProcessedBatches($import);
if ($processedBatches >= $import->getTotalBatches()) {
// Refresh the entity to get updated failedFilms from DB
$this->em->refresh($import);
$import->setStatus(Import::STATUS_COMPLETED);
$import->setCompletedAt(new \DateTimeImmutable());
$this->em->flush();
$imported = $import->getTotalFilms() - $import->getFailedFilms();
$notification = new Notification();
$notification->setUser($user);
$notification->setMessage(sprintf(
'Import terminé : %d/%d films importés.',
$imported,
$import->getTotalFilms()
));
$this->em->persist($notification);
$this->em->flush();
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/MessageHandler/ImportFilmsBatchMessageHandler.php
git commit -m "feat: add ImportFilmsBatchMessageHandler"
```
---
## Task 10: Create Import API Controller
**Files:**
- Create: `src/Controller/ImportController.php`
- [ ] **Step 1: Create the controller**
Create `src/Controller/ImportController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Import;
use App\Entity\User;
use App\Message\ProcessImportMessage;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ImportController extends AbstractController
{
#[Route('/api/imports', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function create(
Request $request,
FilesystemOperator $defaultStorage,
EntityManagerInterface $em,
MessageBusInterface $bus,
): JsonResponse {
$file = $request->files->get('file');
if (!$file) {
return $this->json(['error' => 'No file provided.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ('csv' !== $file->getClientOriginalExtension()) {
return $this->json(['error' => 'Only CSV files are accepted.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($file->getSize() > 5 * 1024 * 1024) {
return $this->json(['error' => 'File too large (max 5 MB).'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @var User $user */
$user = $this->getUser();
$import = new Import();
$import->setUser($user);
$em->persist($import);
$em->flush();
$filePath = sprintf('imports/%d/%d.csv', $user->getId(), $import->getId());
$defaultStorage->write($filePath, file_get_contents($file->getPathname()));
$import->setFilePath($filePath);
$em->flush();
$bus->dispatch(new ProcessImportMessage($import->getId()));
return $this->json([
'id' => $import->getId(),
'status' => $import->getStatus(),
], Response::HTTP_CREATED);
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Controller/ImportController.php
git commit -m "feat: add POST /api/imports endpoint"
```
---
## Task 11: Create Notification API Controller
**Files:**
- Create: `src/Controller/NotificationController.php`
- [ ] **Step 1: Create the controller**
Create `src/Controller/NotificationController.php`:
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\User;
use App\Repository\NotificationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class NotificationController extends AbstractController
{
#[Route('/api/notifications', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function index(NotificationRepository $notificationRepository): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$notifications = $notificationRepository->findRecentForUser($user);
$unreadCount = $notificationRepository->countUnreadForUser($user);
return $this->json([
'unreadCount' => $unreadCount,
'notifications' => array_map(fn ($n) => [
'id' => $n->getId(),
'message' => $n->getMessage(),
'read' => $n->isRead(),
'createdAt' => $n->getCreatedAt()->format('c'),
], $notifications),
]);
}
#[Route('/api/notifications/read', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function markRead(NotificationRepository $notificationRepository): JsonResponse
{
/** @var User $user */
$user = $this->getUser();
$notificationRepository->markAllReadForUser($user);
return $this->json(null, Response::HTTP_NO_CONTENT);
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/Controller/NotificationController.php
git commit -m "feat: add notification API endpoints"
```
---
## Task 12: Create Navbar Template
**Files:**
- Create: `templates/_navbar.html.twig`
- Modify: `templates/base.html.twig`
- [ ] **Step 1: Create the navbar partial**
Create `templates/_navbar.html.twig`:
```twig
{% if app.user %}
<nav class="navbar" data-controller="notifications">
<div class="navbar-left">
<a href="{{ path('app_homepage') }}" class="navbar-brand">Actorle</a>
</div>
<div class="navbar-right">
{# Notifications #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<span class="badge" data-notifications-target="badge" hidden></span>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<div class="dropdown-header">Notifications</div>
<div data-notifications-target="list" class="notifications-list">
<p class="dropdown-empty">Aucune notification</p>
</div>
</div>
</div>
{# User menu #}
<div class="navbar-item" data-controller="dropdown">
<button class="navbar-icon" data-action="click->dropdown#toggle" data-dropdown-target="trigger">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</button>
<div class="dropdown-menu" data-dropdown-target="menu" hidden>
<button class="dropdown-item" data-action="click->import-modal#open">Importer ses films</button>
<a href="{{ path('app_logout') }}" class="dropdown-item">Se déconnecter</a>
</div>
</div>
</div>
</nav>
{# Import Modal #}
<div class="modal-overlay" data-controller="import-modal" data-import-modal-target="overlay" hidden>
<div class="modal">
<div class="modal-header">
<h2>Importer ses films</h2>
<button class="modal-close" data-action="click->import-modal#close">&times;</button>
</div>
<div class="modal-body">
<p>Importez votre fichier <code>watched.csv</code> exporté depuis Letterboxd.</p>
<input type="file" accept=".csv" data-import-modal-target="fileInput">
<div data-import-modal-target="feedback" class="modal-feedback" hidden></div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" data-action="click->import-modal#submit" data-import-modal-target="submitBtn">
Importer
</button>
</div>
</div>
</div>
{% endif %}
```
- [ ] **Step 2: Include navbar in base.html.twig**
Modify `templates/base.html.twig` to include the navbar just inside `<body>`, before the body block:
```html
<body>
{% include '_navbar.html.twig' %}
{% block body %}{% endblock %}
</body>
```
- [ ] **Step 3: Commit**
```bash
git add templates/_navbar.html.twig templates/base.html.twig
git commit -m "feat: add navbar with user dropdown and import modal"
```
---
## Task 13: Create Stimulus Dropdown Controller
**Files:**
- Create: `assets/controllers/dropdown_controller.js`
- [ ] **Step 1: Create the controller**
Create `assets/controllers/dropdown_controller.js`:
```javascript
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['menu', 'trigger'];
connect() {
this._closeOnClickOutside = this._closeOnClickOutside.bind(this);
document.addEventListener('click', this._closeOnClickOutside);
}
disconnect() {
document.removeEventListener('click', this._closeOnClickOutside);
}
toggle() {
this.menuTarget.hidden = !this.menuTarget.hidden;
}
_closeOnClickOutside(event) {
if (!this.element.contains(event.target)) {
this.menuTarget.hidden = true;
}
}
}
```
- [ ] **Step 2: Commit**
```bash
git add assets/controllers/dropdown_controller.js
git commit -m "feat: add Stimulus dropdown controller"
```
---
## Task 14: Create Stimulus Notifications Controller
**Files:**
- Create: `assets/controllers/notifications_controller.js`
- [ ] **Step 1: Create the controller**
Create `assets/controllers/notifications_controller.js`:
```javascript
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['badge', 'list'];
connect() {
this._poll();
this._interval = setInterval(() => this._poll(), 30000);
}
disconnect() {
clearInterval(this._interval);
}
async _poll() {
try {
const response = await fetch('/api/notifications');
if (!response.ok) return;
const data = await response.json();
this._updateBadge(data.unreadCount);
this._updateTitle(data.unreadCount);
this._updateList(data.notifications);
} catch (e) {
// silently ignore polling errors
}
}
_updateBadge(count) {
if (count > 0) {
this.badgeTarget.textContent = count;
this.badgeTarget.hidden = false;
} else {
this.badgeTarget.hidden = true;
}
}
_updateTitle(count) {
const base = 'Actorle';
document.title = count > 0 ? `(${count}) ${base}` : base;
}
_updateList(notifications) {
if (notifications.length === 0) {
this.listTarget.innerHTML = '<p class="dropdown-empty">Aucune notification</p>';
return;
}
this.listTarget.innerHTML = notifications.map(n => `
<div class="notification-item ${n.read ? '' : 'notification-unread'}">
<p>${this._escapeHtml(n.message)}</p>
<time>${this._formatDate(n.createdAt)}</time>
</div>
`).join('');
}
async markRead() {
await fetch('/api/notifications/read', { method: 'POST' });
this._poll();
}
_escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
_formatDate(isoString) {
const date = new Date(isoString);
return date.toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
}
```
- [ ] **Step 2: Wire the markRead action to the notifications dropdown**
In `templates/_navbar.html.twig`, update the notifications dropdown trigger to also call markRead when the dropdown opens. Replace the notification button line:
```twig
<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
```
- [ ] **Step 3: Commit**
```bash
git add assets/controllers/notifications_controller.js templates/_navbar.html.twig
git commit -m "feat: add Stimulus notifications controller with polling"
```
---
## Task 15: Create Stimulus Import Modal Controller
**Files:**
- Create: `assets/controllers/import_modal_controller.js`
- [ ] **Step 1: Create the controller**
Create `assets/controllers/import_modal_controller.js`:
```javascript
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['overlay', 'fileInput', 'feedback', 'submitBtn'];
open() {
this.overlayTarget.hidden = false;
}
close() {
this.overlayTarget.hidden = true;
this.fileInputTarget.value = '';
this.feedbackTarget.hidden = true;
}
async submit() {
const file = this.fileInputTarget.files[0];
if (!file) {
this._showFeedback('Veuillez sélectionner un fichier.', true);
return;
}
if (!file.name.endsWith('.csv')) {
this._showFeedback('Seuls les fichiers CSV sont acceptés.', true);
return;
}
this.submitBtnTarget.disabled = true;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/imports', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const data = await response.json();
this._showFeedback(data.error || 'Une erreur est survenue.', true);
return;
}
this._showFeedback('Import lancé !', false);
setTimeout(() => this.close(), 1500);
} catch (e) {
this._showFeedback('Une erreur est survenue.', true);
} finally {
this.submitBtnTarget.disabled = false;
}
}
_showFeedback(message, isError) {
this.feedbackTarget.textContent = message;
this.feedbackTarget.className = isError ? 'modal-feedback error' : 'modal-feedback success';
this.feedbackTarget.hidden = false;
}
}
```
- [ ] **Step 2: Commit**
```bash
git add assets/controllers/import_modal_controller.js
git commit -m "feat: add Stimulus import modal controller"
```
---
## Task 16: Add CSS Styles
**Files:**
- Modify: `assets/styles/app.css`
- [ ] **Step 1: Add navbar, dropdown, modal, and notification styles**
Append the following to `assets/styles/app.css`:
```css
/* Navbar */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 24px;
background: white;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
font-weight: 700;
font-size: 18px;
color: #1f2937;
text-decoration: none;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.navbar-item {
position: relative;
}
.navbar-icon {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
color: #4b5563;
position: relative;
display: flex;
align-items: center;
}
.navbar-icon:hover {
background: #f3f4f6;
}
/* Badge */
.badge {
position: absolute;
top: 2px;
right: 2px;
background: #dc2626;
color: white;
font-size: 11px;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* Dropdown */
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
background: white;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
z-index: 200;
padding: 4px 0;
margin-top: 4px;
}
.dropdown-header {
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.dropdown-item {
display: block;
width: 100%;
padding: 8px 16px;
text-align: left;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #1f2937;
text-decoration: none;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-empty {
padding: 12px 16px;
color: #9ca3af;
font-size: 13px;
margin: 0;
}
/* Notifications */
.notifications-list {
max-height: 300px;
overflow-y: auto;
}
.notification-item {
padding: 8px 16px;
border-bottom: 1px solid #f3f4f6;
}
.notification-item:last-child {
border-bottom: none;
}
.notification-item p {
margin: 0 0 2px;
font-size: 13px;
color: #1f2937;
}
.notification-item time {
font-size: 11px;
color: #9ca3af;
}
.notification-unread {
background: #eff6ff;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 300;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 18px;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
line-height: 1;
}
.modal-body {
padding: 24px;
}
.modal-body p {
margin: 0 0 16px;
color: #4b5563;
font-size: 14px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
text-align: right;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.btn-primary {
background: #2563eb;
color: white;
}
.btn-primary:hover {
background: #1d4ed8;
}
.btn-primary:disabled {
background: #93c5fd;
cursor: not-allowed;
}
.modal-feedback {
margin-top: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.modal-feedback.error {
background: #fef2f2;
color: #dc2626;
}
.modal-feedback.success {
background: #f0fdf4;
color: #16a34a;
}
```
- [ ] **Step 2: Commit**
```bash
git add assets/styles/app.css
git commit -m "feat: add navbar, dropdown, modal and notification styles"
```
---
## Task 17: Update Security Config for API Routes
**Files:**
- Modify: `config/packages/security.yaml`
- [ ] **Step 1: Check current access_control configuration**
Read `config/packages/security.yaml` and verify the access control section. The `/api/` routes need to be accessible by authenticated users. The current config requires `ROLE_USER` for `/` which should cover `/api/*` too. If not, add:
```yaml
access_control:
- { path: ^/login, roles: PUBLIC_ACCESS }
- { path: ^/register, roles: PUBLIC_ACCESS }
- { path: ^/, roles: ROLE_USER }
```
This is likely already the case. Verify and adjust if needed.
- [ ] **Step 2: Commit (if changes were needed)**
```bash
git add config/packages/security.yaml
git commit -m "chore: ensure API routes require authentication"
```
---
## Task 18: End-to-End Smoke Test
- [ ] **Step 1: Start the Vite dev server**
```bash
npm run dev
```
- [ ] **Step 2: Start the Symfony dev server**
```bash
symfony serve
```
- [ ] **Step 3: Start the Messenger worker**
```bash
php bin/console messenger:consume async -vv
```
- [ ] **Step 4: Manual testing checklist**
1. Log in to the app
2. Verify the navbar appears with user icon and bell icon
3. Click user icon → dropdown shows "Importer ses films" and "Se déconnecter"
4. Click "Importer ses films" → modal opens
5. Upload `public/files/ltbxd/watched.csv`
6. Verify "Import lancé !" message appears
7. Watch the Messenger worker output — batches should be processed
8. After processing completes, the bell should show a badge with "1"
9. Click the bell → notification "Import terminé : X/Y films importés"
10. Page title should show "(1) Actorle" while notification is unread
11. After clicking bell (marks as read), title reverts to "Actorle"
12. Click "Se déconnecter" → logged out
- [ ] **Step 5: Final commit (if any fixes were needed)**
```bash
git add -A
git commit -m "fix: smoke test adjustments"
```