2064 lines
51 KiB
Markdown
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">×</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"
|
|
```
|