Compare commits
20 Commits
9cb5c6e2a5
...
5fc6b4a53b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc6b4a53b | ||
|
|
6edc122ff6 | ||
|
|
b0024bbcf5 | ||
|
|
300699fa82 | ||
|
|
c9880baddb | ||
|
|
1ea07a2438 | ||
|
|
a348de01b0 | ||
|
|
4f8eb5f3dc | ||
|
|
2cfbe191cf | ||
|
|
4955c5bde9 | ||
|
|
98be393e3c | ||
|
|
2d768e8b52 | ||
|
|
dedc41e237 | ||
|
|
bbbfb895af | ||
|
|
1bf8afd88e | ||
|
|
7be4de6967 | ||
|
|
5f7ddcd3cc | ||
|
|
5d16d28c59 | ||
|
|
def97304a9 | ||
|
|
720e8e0cf9 |
24
assets/controllers/dropdown_controller.js
Normal file
24
assets/controllers/dropdown_controller.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
assets/controllers/import_modal_controller.js
Normal file
59
assets/controllers/import_modal_controller.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
assets/controllers/notifications_controller.js
Normal file
77
assets/controllers/notifications_controller.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,3 +125,238 @@ body {
|
|||||||
.auth-link a {
|
.auth-link a {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"doctrine/doctrine-bundle": "^3.2",
|
"doctrine/doctrine-bundle": "^3.2",
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
|
"league/flysystem-aws-s3-v3": "*",
|
||||||
|
"league/flysystem-bundle": "*",
|
||||||
"pentatrion/vite-bundle": "^8.2",
|
"pentatrion/vite-bundle": "^8.2",
|
||||||
"phpdocumentor/reflection-docblock": "^6.0",
|
"phpdocumentor/reflection-docblock": "^6.0",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
|
|||||||
1062
composer.lock
generated
1062
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -15,4 +15,5 @@ return [
|
|||||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
Symfony\UX\React\ReactBundle::class => ['all' => true],
|
Symfony\UX\React\ReactBundle::class => ['all' => true],
|
||||||
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
Pentatrion\ViteBundle\PentatrionViteBundle::class => ['all' => true],
|
||||||
|
League\FlysystemBundle\FlysystemBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
19
config/packages/flysystem.yaml
Normal file
19
config/packages/flysystem.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
@@ -25,5 +25,5 @@ framework:
|
|||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
Symfony\Component\Notifier\Message\ChatMessage: async
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
Symfony\Component\Notifier\Message\SmsMessage: async
|
||||||
|
|
||||||
# Route your messages to the transports
|
App\Message\ProcessImportMessage: async
|
||||||
# 'App\Message\YourMessage': async
|
App\Message\ImportFilmsBatchMessage: async
|
||||||
|
|||||||
@@ -1487,6 +1487,22 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* preload_attributes?: list<scalar|null|Param>,
|
* preload_attributes?: list<scalar|null|Param>,
|
||||||
* }>,
|
* }>,
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type FlysystemConfig = array{
|
||||||
|
* storages?: array<string, array{ // Default: []
|
||||||
|
* adapter: scalar|null|Param,
|
||||||
|
* options?: list<mixed>,
|
||||||
|
* visibility?: scalar|null|Param, // Default: null
|
||||||
|
* directory_visibility?: scalar|null|Param, // Default: null
|
||||||
|
* retain_visibility?: bool|null|Param, // Default: null
|
||||||
|
* case_sensitive?: bool|Param, // Default: true
|
||||||
|
* disable_asserts?: bool|Param, // Default: false
|
||||||
|
* public_url?: list<scalar|null|Param>,
|
||||||
|
* path_normalizer?: scalar|null|Param, // Default: null
|
||||||
|
* public_url_generator?: scalar|null|Param, // Default: null
|
||||||
|
* temporary_url_generator?: scalar|null|Param, // Default: null
|
||||||
|
* read_only?: bool|Param, // Default: false
|
||||||
|
* }>,
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1502,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1520,6 +1537,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* maker?: MakerConfig,
|
* maker?: MakerConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1536,6 +1554,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1553,6 +1572,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
* react?: ReactConfig,
|
* react?: ReactConfig,
|
||||||
* pentatrion_vite?: PentatrionViteConfig,
|
* pentatrion_vite?: PentatrionViteConfig,
|
||||||
|
* flysystem?: FlysystemConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ services:
|
|||||||
database:
|
database:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
messenger:
|
||||||
|
image: git.lclr.dev/thibaud-lclr/ltbxd-actorle/app:latest
|
||||||
|
command: ["php", "bin/console", "messenger:consume", "async", "--time-limit=3600", "--memory-limit=256M"]
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
database:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
database:
|
database:
|
||||||
build:
|
build:
|
||||||
context: docker/database
|
context: docker/database
|
||||||
|
|||||||
2063
docs/superpowers/plans/2026-03-29-user-import-notifications.md
Normal file
2063
docs/superpowers/plans/2026-03-29-user-import-notifications.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,148 @@
|
|||||||
|
# User Film Import, Navbar & Notifications
|
||||||
|
|
||||||
|
**Date:** 2026-03-29
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a navbar for authenticated users with a user dropdown (import films, logout) and a notifications dropdown (with unread count badge and page title update). Users can import their Letterboxd CSV to sync films and actors via async processing, with files stored on a remote SeaweedFS instance.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### New Entities
|
||||||
|
|
||||||
|
**`UserMovie`** — join table User <-> Movie
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `movie` (ManyToOne -> Movie)
|
||||||
|
- Unique constraint on `(user, movie)`
|
||||||
|
|
||||||
|
**`Import`** — tracks a CSV import job
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `filePath` (string) — path on SeaweedFS
|
||||||
|
- `status` (string, enum: `pending`, `processing`, `completed`, `failed`)
|
||||||
|
- `totalBatches` (int, default 0)
|
||||||
|
- `processedBatches` (int, default 0)
|
||||||
|
- `totalFilms` (int, default 0)
|
||||||
|
- `failedFilms` (int, default 0)
|
||||||
|
- `createdAt` (datetime)
|
||||||
|
- `completedAt` (datetime, nullable)
|
||||||
|
|
||||||
|
**`Notification`** — user notifications
|
||||||
|
- `id` (int, PK)
|
||||||
|
- `user` (ManyToOne -> User)
|
||||||
|
- `message` (string)
|
||||||
|
- `read` (bool, default false)
|
||||||
|
- `createdAt` (datetime)
|
||||||
|
|
||||||
|
### Modified Entities
|
||||||
|
|
||||||
|
**`User`** — add OneToMany relations to `UserMovie`, `Import`, `Notification`.
|
||||||
|
|
||||||
|
## File Storage (SeaweedFS)
|
||||||
|
|
||||||
|
- **Library:** `league/flysystem-aws-s3-v3` with Flysystem S3 adapter
|
||||||
|
- **Endpoint:** `s3.lclr.dev`
|
||||||
|
- **Bucket:** `ltbxd-actorle`
|
||||||
|
- **Credentials:** Symfony Secrets (`S3_ACCESS_KEY`, `S3_SECRET_KEY`)
|
||||||
|
- **File path pattern:** `imports/{userId}/{importId}.csv`
|
||||||
|
- No local/Docker SeaweedFS — always the remote instance, including in dev.
|
||||||
|
|
||||||
|
## Async Processing (Messenger)
|
||||||
|
|
||||||
|
### Messages
|
||||||
|
|
||||||
|
**`ProcessImportMessage(importId)`**
|
||||||
|
- Dispatched by the upload controller.
|
||||||
|
- Single entry point for import processing.
|
||||||
|
|
||||||
|
**`ImportFilmsBatchMessage(importId, offset, limit)`**
|
||||||
|
- Dispatched by `ProcessImportMessageHandler`.
|
||||||
|
- One per batch of 50 films.
|
||||||
|
|
||||||
|
### Handler: `ProcessImportMessageHandler`
|
||||||
|
|
||||||
|
1. Fetch `Import` entity
|
||||||
|
2. Download CSV from SeaweedFS via Flysystem
|
||||||
|
3. Parse the file: save to a temp file, then use `LtbxdGateway->parseFile()` (which expects a local path), then delete the temp file
|
||||||
|
4. Calculate `totalFilms`, `totalBatches` (batches of 50), update the Import
|
||||||
|
5. Dispatch N `ImportFilmsBatchMessage(importId, offset, limit)` messages
|
||||||
|
6. Set Import status to `processing`
|
||||||
|
|
||||||
|
### Handler: `ImportFilmsBatchMessageHandler`
|
||||||
|
|
||||||
|
1. Fetch Import, download CSV from SeaweedFS, read slice [offset, offset+limit]
|
||||||
|
2. For each film in the slice:
|
||||||
|
- Look up by `ltbxdRef` in DB; if missing, call `TMDBGateway->searchMovie()` and create Movie
|
||||||
|
- Fetch actors via TMDB, create missing Actor/MovieRole entries
|
||||||
|
- Create `UserMovie` link if it doesn't exist
|
||||||
|
3. Atomically increment `processedBatches` (`UPDATE ... SET processed_batches = processed_batches + 1`) to avoid race conditions with multiple workers
|
||||||
|
4. If `processedBatches == totalBatches`: set Import to `completed`, set `completedAt`, create Notification ("Import terminé : X/Y films importés")
|
||||||
|
5. On per-film error: log and continue, increment `failedFilms`
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- `ProcessImportMessageHandler` failure (SeaweedFS down, invalid CSV): set Import to `failed`, create error Notification.
|
||||||
|
- `ImportFilmsBatchMessageHandler` per-film failure: log, skip film, increment `failedFilms`, continue.
|
||||||
|
- Messenger retry: default config (3 retries with backoff), then failure transport.
|
||||||
|
|
||||||
|
## Extracted Services
|
||||||
|
|
||||||
|
The logic currently embedded in `SyncFilmsCommand` and `SyncActorsCommand` is extracted into reusable services:
|
||||||
|
|
||||||
|
- **`FilmImporter`** — given a parsed CSV row, finds or creates a Movie entity via TMDB lookup.
|
||||||
|
- **`ActorSyncer`** — given a Movie, fetches cast from TMDB and creates missing Actor/MovieRole entries.
|
||||||
|
|
||||||
|
The existing commands are refactored to use these services.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### `POST /api/imports` (authenticated)
|
||||||
|
- **Input:** CSV file as multipart form data
|
||||||
|
- **Validation:** `.csv` extension, max 5 MB
|
||||||
|
- **Action:** Upload to SeaweedFS, create Import entity (status `pending`), dispatch `ProcessImportMessage`
|
||||||
|
- **Response:** `201 { id, status: "pending" }`
|
||||||
|
|
||||||
|
### `GET /api/notifications` (authenticated)
|
||||||
|
- **Response:** `200 { unreadCount: N, notifications: [{ id, message, read, createdAt }] }`
|
||||||
|
- Sorted by `createdAt` desc, limited to 20 most recent
|
||||||
|
|
||||||
|
### `POST /api/notifications/read` (authenticated)
|
||||||
|
- **Action:** Mark all notifications for the authenticated user as read
|
||||||
|
- **Response:** `204`
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Navbar (Twig + Stimulus)
|
||||||
|
|
||||||
|
- Added in the main layout (`base.html.twig` or partial), visible only when authenticated.
|
||||||
|
- Right side: notification icon (bell) + user icon.
|
||||||
|
|
||||||
|
### User Dropdown (`dropdown_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click user icon -> toggle dropdown menu
|
||||||
|
- Entries: "Importer ses films", "Se deconnecter"
|
||||||
|
- Click outside -> close
|
||||||
|
|
||||||
|
### Notifications Dropdown (`notifications_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click bell icon -> dropdown listing recent notifications
|
||||||
|
- Polling every 30s on `GET /api/notifications` returning notifications + unread count
|
||||||
|
- On dropdown open: call `POST /api/notifications/read` to mark as read
|
||||||
|
- Badge (red, unread count) updates on each poll
|
||||||
|
- `document.title` updates: `(N) Actorle` if unread > 0, `Actorle` otherwise
|
||||||
|
|
||||||
|
### Import Modal (`import_modal_controller` Stimulus)
|
||||||
|
|
||||||
|
- Click "Importer ses films" -> show modal (HTML in DOM, toggle `hidden`)
|
||||||
|
- File input (accept `.csv`)
|
||||||
|
- "Importer" button -> `POST /api/imports` via fetch (multipart)
|
||||||
|
- On success: "Import lance !" message, close modal
|
||||||
|
- Client-side validation: `.csv` extension only
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Modifying game logic based on user's imported films (future: game config page)
|
||||||
|
- Mercure/WebSocket for real-time notifications (polling is sufficient)
|
||||||
|
- Docker SeaweedFS for local dev
|
||||||
38
migrations/Version20260329000001.php
Normal file
38
migrations/Version20260329000001.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260329000001 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE user_movie (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, movie_id INT NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A6B68B33A76ED395 ON user_movie (user_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A6B68B338F93B6FC ON user_movie (movie_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX user_movie_unique ON user_movie (user_id, movie_id)');
|
||||||
|
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B33A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('ALTER TABLE user_movie ADD CONSTRAINT FK_A6B68B338F93B6FC FOREIGN KEY (movie_id) REFERENCES movie (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B33A76ED395');
|
||||||
|
$this->addSql('ALTER TABLE user_movie DROP CONSTRAINT FK_A6B68B338F93B6FC');
|
||||||
|
$this->addSql('DROP TABLE user_movie');
|
||||||
|
}
|
||||||
|
}
|
||||||
36
migrations/Version20260329000002.php
Normal file
36
migrations/Version20260329000002.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260329000002 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE import (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, file_path VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, total_batches INT NOT NULL, processed_batches INT NOT NULL, total_films INT NOT NULL, failed_films INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, completed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_9D4ECE1DA76ED395 ON import (user_id)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN import.completed_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE import ADD CONSTRAINT FK_9D4ECE1DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE import DROP CONSTRAINT FK_9D4ECE1DA76ED395');
|
||||||
|
$this->addSql('DROP TABLE import');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
migrations/Version20260329000003.php
Normal file
35
migrations/Version20260329000003.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260329000003 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE notification (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, user_id INT NOT NULL, message VARCHAR(255) NOT NULL, is_read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_BF5476CAA76ED395 ON notification (user_id)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN notification.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395');
|
||||||
|
$this->addSql('DROP TABLE notification');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Entity\Actor;
|
|
||||||
use App\Entity\Movie;
|
use App\Entity\Movie;
|
||||||
use App\Entity\MovieRole;
|
|
||||||
use App\Exception\GatewayException;
|
use App\Exception\GatewayException;
|
||||||
use App\Gateway\TMDBGateway;
|
use App\Service\ActorSyncer;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -16,7 +14,7 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
readonly class SyncActorsCommand
|
readonly class SyncActorsCommand
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private TMDBGateway $TMDBGateway,
|
private ActorSyncer $actorSyncer,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -24,39 +22,13 @@ readonly class SyncActorsCommand
|
|||||||
{
|
{
|
||||||
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
|
foreach ($this->em->getRepository(Movie::class)->findAll() as $film) {
|
||||||
try {
|
try {
|
||||||
$creditsContext = $this->TMDBGateway->getMovieCredits($film->getTmdbId());
|
$output->writeln('Syncing cast for '.$film->getTitle());
|
||||||
|
$this->actorSyncer->syncActorsForMovie($film);
|
||||||
} catch (GatewayException $e) {
|
} catch (GatewayException $e) {
|
||||||
$output->writeln('/!\ '.$e->getMessage());
|
$output->writeln('/!\ '.$e->getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($creditsContext->cast)) {
|
|
||||||
$output->writeln('Syncing cast for '.$film->getTitle());
|
|
||||||
}
|
|
||||||
foreach ($creditsContext->cast as $actorModel) {
|
|
||||||
// Get existing or create new
|
|
||||||
$actor = $this->em->getRepository(Actor::class)->findOneBy(['tmdbId' => $actorModel->id]);
|
|
||||||
if (!$actor instanceof Actor) {
|
|
||||||
$output->writeln('* New actor found: '.$actorModel->name);
|
|
||||||
|
|
||||||
$actor = new Actor()
|
|
||||||
->setPopularity($actorModel->popularity)
|
|
||||||
->setName($actorModel->name)
|
|
||||||
->setTmdbId($actorModel->id)
|
|
||||||
;
|
|
||||||
|
|
||||||
$this->em->persist($actor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create the role
|
|
||||||
if (0 < $this->em->getRepository(MovieRole::class)->count(['actor' => $actor, 'movie' => $film])) {
|
|
||||||
$actor->addMovieRole(new MovieRole()
|
|
||||||
->setMovie($film)
|
|
||||||
->setCharacter($actorModel->character)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Command;
|
namespace App\Command;
|
||||||
|
|
||||||
use App\Entity\Movie;
|
|
||||||
use App\Exception\GatewayException;
|
use App\Exception\GatewayException;
|
||||||
use App\Gateway\LtbxdGateway;
|
use App\Gateway\LtbxdGateway;
|
||||||
use App\Gateway\TMDBGateway;
|
use App\Service\FilmImporter;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -16,7 +15,7 @@ readonly class SyncFilmsCommands
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private LtbxdGateway $ltbxdGateway,
|
private LtbxdGateway $ltbxdGateway,
|
||||||
private TMDBGateway $TMDBGateway,
|
private FilmImporter $filmImporter,
|
||||||
private EntityManagerInterface $em,
|
private EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,31 +31,17 @@ readonly class SyncFilmsCommands
|
|||||||
|
|
||||||
$i = 0;
|
$i = 0;
|
||||||
foreach ($ltbxdMovies as $ltbxdMovie) {
|
foreach ($ltbxdMovies as $ltbxdMovie) {
|
||||||
// If the movie already exists, skip
|
|
||||||
if (0 < $this->em->getRepository(Movie::class)->count(['ltbxdRef' => $ltbxdMovie->getLtbxdRef()])) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search movie on TMDB
|
|
||||||
try {
|
try {
|
||||||
$film = $this->TMDBGateway->searchMovie($ltbxdMovie->getName());
|
$movie = $this->filmImporter->importFromLtbxdMovie($ltbxdMovie);
|
||||||
|
if ($movie) {
|
||||||
|
$output->writeln('* Found '.$ltbxdMovie->getName());
|
||||||
|
}
|
||||||
} catch (GatewayException $e) {
|
} catch (GatewayException $e) {
|
||||||
$output->writeln('/!\ '.$e->getMessage());
|
$output->writeln('/!\ '.$e->getMessage());
|
||||||
|
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($film) {
|
|
||||||
$output->writeln('* Found '.$ltbxdMovie->getName());
|
|
||||||
|
|
||||||
$filmEntity = new Movie()
|
|
||||||
->setLtbxdRef($ltbxdMovie->getLtbxdRef())
|
|
||||||
->setTitle($ltbxdMovie->getName())
|
|
||||||
->setTmdbId($film->getId())
|
|
||||||
;
|
|
||||||
$this->em->persist($filmEntity);
|
|
||||||
}
|
|
||||||
|
|
||||||
++$i;
|
++$i;
|
||||||
if (0 === $i % 50) {
|
if (0 === $i % 50) {
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|||||||
67
src/Controller/ImportController.php
Normal file
67
src/Controller/ImportController.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?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);
|
||||||
|
$import->setFilePath('pending');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/Controller/NotificationController.php
Normal file
49
src/Controller/NotificationController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?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): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$notificationRepository->markAllReadForUser($user);
|
||||||
|
|
||||||
|
return new Response('', Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
153
src/Entity/Import.php
Normal file
153
src/Entity/Import.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/Entity/Notification.php
Normal file
78
src/Entity/Notification.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?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(name: 'is_read')]
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Entity/UserMovie.php
Normal file
55
src/Entity/UserMovie.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,13 @@ readonly class LtbxdGateway
|
|||||||
* @return LtbxdMovie[]
|
* @return LtbxdMovie[]
|
||||||
* @throws GatewayException
|
* @throws GatewayException
|
||||||
*/
|
*/
|
||||||
public function parseFile(): array
|
public function parseFileFromPath(string $path): array
|
||||||
{
|
{
|
||||||
if (!file_exists($this->fileDir)) {
|
if (!file_exists($path)) {
|
||||||
throw new GatewayException(sprintf('Could not find file %s', $this->fileDir));
|
throw new GatewayException(sprintf('Could not find file %s', $path));
|
||||||
}
|
}
|
||||||
|
|
||||||
$fileContent = file_get_contents($this->fileDir);
|
$fileContent = file_get_contents($path);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
|
return $this->serializer->deserialize($fileContent, LtbxdMovie::class.'[]', 'csv');
|
||||||
@@ -34,4 +34,13 @@ readonly class LtbxdGateway
|
|||||||
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
|
throw new GatewayException('Error while deserializing Letterboxd data', previous: $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return LtbxdMovie[]
|
||||||
|
* @throws GatewayException
|
||||||
|
*/
|
||||||
|
public function parseFile(): array
|
||||||
|
{
|
||||||
|
return $this->parseFileFromPath($this->fileDir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/Message/ImportFilmsBatchMessage.php
Normal file
14
src/Message/ImportFilmsBatchMessage.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
readonly class ImportFilmsBatchMessage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $importId,
|
||||||
|
public int $offset,
|
||||||
|
public int $limit,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
12
src/Message/ProcessImportMessage.php
Normal file
12
src/Message/ProcessImportMessage.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Message;
|
||||||
|
|
||||||
|
readonly class ProcessImportMessage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $importId,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
110
src/MessageHandler/ImportFilmsBatchMessageHandler.php
Normal file
110
src/MessageHandler/ImportFilmsBatchMessageHandler.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/MessageHandler/ProcessImportMessageHandler.php
Normal file
83
src/MessageHandler/ProcessImportMessageHandler.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Repository/ImportRepository.php
Normal file
36
src/Repository/ImportRepository.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
return (int) $this->getEntityManager()->getConnection()->fetchOne(
|
||||||
|
'UPDATE import SET processed_batches = processed_batches + 1 WHERE id = :id RETURNING processed_batches',
|
||||||
|
['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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Repository/NotificationRepository.php
Normal file
54
src/Repository/NotificationRepository.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/Repository/UserMovieRepository.php
Normal file
20
src/Repository/UserMovieRepository.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Service/ActorSyncer.php
Normal file
52
src/Service/ActorSyncer.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Service/FilmImporter.php
Normal file
47
src/Service/FilmImporter.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
symfony.lock
13
symfony.lock
@@ -35,6 +35,19 @@
|
|||||||
"migrations/.gitignore"
|
"migrations/.gitignore"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"league/flysystem-bundle": {
|
||||||
|
"version": "3.6",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/flysystem.yaml",
|
||||||
|
"var/storage/.gitignore"
|
||||||
|
]
|
||||||
|
},
|
||||||
"pentatrion/vite-bundle": {
|
"pentatrion/vite-bundle": {
|
||||||
"version": "8.2",
|
"version": "8.2",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
61
templates/_navbar.html.twig
Normal file
61
templates/_navbar.html.twig
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% if app.user %}
|
||||||
|
<div data-controller="import-modal">
|
||||||
|
<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 click->notifications#markRead" 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-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>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{% include '_navbar.html.twig' %}
|
||||||
{% block body %}{% endblock %}
|
{% block body %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user