pp-planer/app/Http/Controllers/SlideController.php
Thorsten Bus d915f8cfc2 feat: Wave 2 - Service list, Song CRUD, Slide upload, Arrangements, Song matching, Translation
T8: Service List Page
- ServiceController with index, finalize, reopen actions
- Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded)
- German UI with finalize/reopen toggle buttons
- Status aggregation via SQL subqueries for efficiency
- Tests: 3 passing (46 assertions)

T9: Song CRUD Backend
- SongController with full REST API (index, store, show, update, destroy)
- SongService for default groups/arrangements creation
- SongRequest validation (title required, ccli_id unique)
- Search by title and CCLI ID
- last_used_in_service accessor via service_songs join
- Tests: 20 passing (85 assertions)

T10: Slide Upload Component
- SlideController with store, destroy, updateExpireDate
- SlideUploader.vue with vue3-dropzone drag-and-drop
- SlideGrid.vue with thumbnail grid and inline expire date editing
- Multi-format support: images (sync), PPT (async job), ZIP (extract)
- Type validation: information (global), moderation/sermon (service-specific)
- Tests: 15 passing (37 assertions)

T11: Arrangement Configurator
- ArrangementController with store, clone, update, destroy
- ArrangementConfigurator.vue with vue-draggable-plus
- Drag-and-drop arrangement editor with colored group pills
- Clone from default or existing arrangement
- Color picker for group customization
- Prevent deletion of last arrangement
- Tests: 4 passing (17 assertions)

T12: Song Matching Service
- SongMatchingService with autoMatch, manualAssign, requestCreation, unassign
- ServiceSongController API endpoints for song assignment
- Auto-match by CCLI ID during CTS sync
- Manual assignment with searchable song select
- Email request for missing songs (MissingSongRequest mailable)
- Tests: 14 passing (33 assertions)

T13: Translation Service
- TranslationService with fetchFromUrl, importTranslation, removeTranslation
- TranslationController API endpoints
- URL scraping (best-effort HTTP fetch with strip_tags)
- Line-count distribution algorithm (match original slide line counts)
- Mark song as translated, remove translation
- Tests: 18 passing (18 assertions)

All tests passing: 103/103 (488 assertions)
Build: ✓ Vite production build successful
German UI: All user-facing text in German with 'Du' form
2026-03-01 19:55:37 +01:00

215 lines
7.3 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Slide;
use App\Services\FileConversionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
class SlideController extends Controller
{
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'];
private const POWERPOINT_EXTENSIONS = ['ppt', 'pptx'];
public function store(Request $request, FileConversionService $conversionService): JsonResponse
{
$validated = $request->validate([
'file' => ['required', 'file', 'max:51200'],
'type' => ['required', Rule::in(['information', 'moderation', 'sermon'])],
'service_id' => ['nullable', 'exists:services,id'],
'expire_date' => ['nullable', 'date'],
]);
// moderation and sermon slides require a service_id
if (in_array($validated['type'], ['moderation', 'sermon']) && empty($validated['service_id'])) {
return response()->json([
'message' => 'Moderations- und Predigtfolien benötigen einen Gottesdienst.',
'errors' => ['service_id' => ['Gottesdienst ist erforderlich.']],
], 422);
}
/** @var UploadedFile $file */
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension());
// Validate file extension
$allowedExtensions = [...self::IMAGE_EXTENSIONS, ...self::POWERPOINT_EXTENSIONS, 'zip'];
if (! in_array($extension, $allowedExtensions, true)) {
return response()->json([
'message' => 'Dateityp nicht erlaubt.',
'errors' => ['file' => ['Nur PNG, JPG, PPT, PPTX und ZIP-Dateien sind erlaubt.']],
], 422);
}
$uploaderName = $request->user()?->name ?? 'Unbekannt';
$serviceId = $validated['service_id'] ?? null;
$type = $validated['type'];
$expireDate = $validated['expire_date'] ?? null;
// Handle PowerPoint files — dispatch async job
if (in_array($extension, self::POWERPOINT_EXTENSIONS, true)) {
return $this->handlePowerPoint($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
}
// Handle ZIP files — extract and process
if ($extension === 'zip') {
return $this->handleZip($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
}
// Handle images — convert synchronously
return $this->handleImage($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
}
public function destroy(Slide $slide): JsonResponse
{
$slide->delete();
return response()->json([
'success' => true,
'message' => 'Folie wurde gelöscht.',
]);
}
public function updateExpireDate(Request $request, Slide $slide): JsonResponse
{
if ($slide->type !== 'information') {
return response()->json([
'message' => 'Ablaufdatum kann nur für Informationsfolien gesetzt werden.',
'errors' => ['type' => ['Nur Informationsfolien haben ein Ablaufdatum.']],
], 422);
}
$validated = $request->validate([
'expire_date' => ['nullable', 'date'],
]);
$slide->update(['expire_date' => $validated['expire_date']]);
return response()->json([
'success' => true,
'slide' => $slide->fresh(),
]);
}
private function handleImage(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
): JsonResponse {
try {
$result = $conversionService->convertImage($file);
$slide = Slide::create([
'type' => $type,
'service_id' => $serviceId,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
]);
return response()->json([
'success' => true,
'slide' => $slide,
]);
} catch (InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
private function handlePowerPoint(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
): JsonResponse {
// Store file persistently so the job can access it
$storedPath = $file->store('temp/ppt', 'local');
try {
$jobId = $conversionService->convertPowerPoint(
storage_path('app/' . $storedPath)
);
return response()->json([
'success' => true,
'job_id' => $jobId,
'message' => 'PowerPoint wird verarbeitet...',
'meta' => [
'type' => $type,
'service_id' => $serviceId,
'uploader_name' => $uploaderName,
'expire_date' => $expireDate,
'original_filename' => $file->getClientOriginalName(),
],
]);
} catch (InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
private function handleZip(
UploadedFile $file,
FileConversionService $conversionService,
string $type,
?int $serviceId,
string $uploaderName,
?string $expireDate,
): JsonResponse {
try {
$results = $conversionService->processZip($file);
$slides = [];
foreach ($results as $result) {
// Skip PPT job results (they are handled asynchronously)
if (isset($result['job_id'])) {
continue;
}
$slides[] = Slide::create([
'type' => $type,
'service_id' => $serviceId,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $result['filename'],
'thumbnail_filename' => $result['thumbnail'],
'expire_date' => $expireDate,
'uploader_name' => $uploaderName,
'uploaded_at' => now(),
]);
}
return response()->json([
'success' => true,
'slides' => $slides,
'count' => count($slides),
]);
} catch (InvalidArgumentException $e) {
Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
}
}
}