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
215 lines
7.3 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|