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

51 KiB

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.phpPOST /api/imports
  • src/Controller/NotificationController.phpGET /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

composer require league/flysystem-bundle league/flysystem-aws-s3-v3
  • Step 2: Generate Symfony Secrets for S3 credentials
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:

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
php bin/console debug:container default.storage

Expected: shows the Flysystem storage service.

  • Step 5: Commit
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

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

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
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Verify the table
php bin/console doctrine:schema:validate

Expected: no errors about the user_movie table.

  • Step 5: Commit
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

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

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
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Commit
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

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

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
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate --no-interaction
  • Step 4: Commit
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

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

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
php bin/console app:sync-films --help

Expected: command is recognized and shows its help.

  • Step 4: Commit
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

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

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
php bin/console app:sync-actors --help

Expected: command is recognized and shows its help.

  • Step 4: Commit
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

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

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:

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

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:

/**
 * @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:

public function parseFile(): array
{
    return $this->parseFileFromPath($this->fileDir);
}
  • Step 3: Commit
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

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

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

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
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:

{% 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:

    <body>
        {% include '_navbar.html.twig' %}
        {% block body %}{% endblock %}
    </body>
  • Step 3: Commit
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:

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
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:

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:

<button class="navbar-icon" data-action="click->dropdown#toggle click->notifications#markRead" data-dropdown-target="trigger">
  • Step 3: Commit
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:

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
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:

/* 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
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:

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)
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
npm run dev
  • Step 2: Start the Symfony dev server
symfony serve
  • Step 3: Start the Messenger worker
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)
git add -A
git commit -m "fix: smoke test adjustments"