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
This commit is contained in:
Thorsten Bus 2026-03-01 19:55:37 +01:00
parent 57d54ec06b
commit d915f8cfc2
27 changed files with 3951 additions and 25 deletions

View file

@ -1,3 +1,16 @@
- 2026-03-01: Fuer 1920x1080 Slide-Output ohne Upscaling funktioniert in Intervention Image v3 die Kombination aus schwarzer Canvas (`create()->fill('000000')`), `scaleDown(width: 1920, height: 1080)` und zentriertem `place(...)` stabil. - 2026-03-01: Fuer 1920x1080 Slide-Output ohne Upscaling funktioniert in Intervention Image v3 die Kombination aus schwarzer Canvas (`create()->fill('000000')`), `scaleDown(width: 1920, height: 1080)` und zentriertem `place(...)` stabil.
- 2026-03-01: Bei Fake-Storage in Tests muessen Zielordner vor direktem Intervention-`save()` explizit erstellt werden (`makeDirectory`/`mkdir`), sonst wirft Intervention `NotWritableException`. - 2026-03-01: Bei Fake-Storage in Tests muessen Zielordner vor direktem Intervention-`save()` explizit erstellt werden (`makeDirectory`/`mkdir`), sonst wirft Intervention `NotWritableException`.
- 2026-03-01: Fuer Testverifikation von Letterbox/Pillarbox sind farbige PNG-Testbilder sinnvoller als `UploadedFile::fake()->image(...)`, weil Fake-Bilder sonst komplett schwarz sein koennen. - 2026-03-01: Fuer Testverifikation von Letterbox/Pillarbox sind farbige PNG-Testbilder sinnvoller als `UploadedFile::fake()->image(...)`, weil Fake-Bilder sonst komplett schwarz sein koennen.
- 2026-03-01: CTS-Sync laeuft stabil mit `EventRequest::where("from", heute)` + `EventAgendaRequest::fromEvent(...)->get()`, wenn Services per `cts_event_id` und Agenda-Songs per (`service_id`,`order`) upserted werden; CCLI-Matching bleibt strikt auf `songs.ccli_id` und setzt nur dann `song_id`/`matched_at`.
- 2026-03-01: SongController CRUD nutzt `auth:sanctum` Middleware; `actingAs()` in Tests funktioniert damit problemlos (Sanctum unterstuetzt Session-Auth in Tests).
- 2026-03-01: SQLite gibt `date`-Spalten als `YYYY-MM-DD 00:00:00` zurueck statt `YYYY-MM-DD` — Accessor muss `substr($date, 0, 10)` nutzen fuer saubere Date-Only Werte.
- 2026-03-01: `Attribute::get()` in Laravel 12 fuer berechnete Accessors statt altem `get{Name}Attribute()` Pattern. Snake_case `last_used_in_service` mapped automatisch auf `lastUsedInService()` Methode.
- 2026-03-01: Default-Gruppen (Strophe 1=#3B82F6, Refrain=#10B981, Bridge=#F59E0B) und Default-Arrangement 'Normal' werden automatisch bei Song-Erstellung via SongService erzeugt.
- 2026-03-01: `Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at')` stellt sicher, dass Soft-Deleted Songs die Unique-Constraint nicht blockieren.
- 2026-03-01: `bootstrap/app.php` braucht explizit `api: __DIR__.'/../routes/api.php'` in `withRouting()` — ist nicht automatisch registriert in Laravel 12.
- 2026-03-01: Service-Listenstatus laesst sich performant in einem Query aggregieren via `withCount(...)` fuer Song-Metriken plus `addSelect`-Subqueries fuer `has_sermon_slides` und datumsabhaengige `info_slides_count` (inkl. globaler `information`-Slides mit `service_id = null`).
- 2026-03-01: TranslationService line-count distribution: iterate groups (by order) → slides (by order), for each slide count lines in `text_content`, then slice that many lines from the translated text array. `array_slice` + offset tracking works cleanly.
- 2026-03-01: URL scraping is best-effort only: `Http::timeout(10)->get($url)` + `strip_tags()` + `trim()`. Return null on any failure — no exceptions bubble up. PHP 8.1+ allows `catch (\Exception)` without variable capture.
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.

View file

@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers;
use App\Models\Song;
use App\Models\SongArrangement;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ArrangementController extends Controller
{
public function store(Request $request, Song $song): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
]);
DB::transaction(function () use ($song, $data): void {
$arrangement = $song->arrangements()->create([
'name' => $data['name'],
'is_default' => false,
]);
$source = $song->arrangements()
->where('is_default', true)
->with('arrangementGroups')
->first();
if ($source === null) {
$source = $song->arrangements()
->with('arrangementGroups')
->orderBy('id')
->first();
}
$this->cloneGroups($source, $arrangement);
});
return back()->with('success', 'Arrangement wurde hinzugefügt.');
}
public function clone(Request $request, SongArrangement $arrangement): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
]);
DB::transaction(function () use ($arrangement, $data): void {
$arrangement->loadMissing('arrangementGroups');
$clone = $arrangement->song->arrangements()->create([
'name' => $data['name'],
'is_default' => false,
]);
$this->cloneGroups($arrangement, $clone);
});
return back()->with('success', 'Arrangement wurde geklont.');
}
public function update(Request $request, SongArrangement $arrangement): RedirectResponse
{
$data = $request->validate([
'groups' => ['array'],
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
'groups.*.order' => ['required', 'integer', 'min:1'],
'group_colors' => ['sometimes', 'array'],
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
$uniqueGroupIds = $groupIds->unique()->values();
$validGroupIds = $arrangement->song->groups()
->whereIn('id', $uniqueGroupIds)
->pluck('id');
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
throw ValidationException::withMessages([
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
]);
}
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
$arrangement->arrangementGroups()->delete();
$rows = $groupIds
->values()
->map(fn (int $songGroupId, int $index) => [
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $songGroupId,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$arrangement->arrangementGroups()->insert($rows);
}
if (!empty($data['group_colors'])) {
foreach ($data['group_colors'] as $groupId => $color) {
$arrangement->song->groups()
->whereKey((int) $groupId)
->update(['color' => $color]);
}
}
});
return back()->with('success', 'Arrangement wurde gespeichert.');
}
public function destroy(SongArrangement $arrangement): RedirectResponse
{
$song = $arrangement->song;
if ($song->arrangements()->count() <= 1) {
return back()->with('error', 'Das letzte Arrangement kann nicht gelöscht werden.');
}
DB::transaction(function () use ($arrangement, $song): void {
$deletedWasDefault = $arrangement->is_default;
$arrangement->delete();
if ($deletedWasDefault) {
$song->arrangements()
->orderBy('id')
->limit(1)
->update(['is_default' => true]);
}
});
return back()->with('success', 'Arrangement wurde gelöscht.');
}
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
{
if ($source === null) {
return;
}
$groups = $source->arrangementGroups
->sortBy('order')
->values();
$rows = $groups
->map(fn ($arrangementGroup) => [
'song_arrangement_id' => $target->id,
'song_group_id' => $arrangementGroup->song_group_id,
'order' => $arrangementGroup->order,
'created_at' => now(),
'updated_at' => now(),
])
->all();
if ($rows !== []) {
$target->arrangementGroups()->insert($rows);
}
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\Slide;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
use Inertia\Inertia;
use Inertia\Response;
class ServiceController extends Controller
{
public function index(): Response
{
$services = Service::query()
->whereDate('date', '>=', Carbon::today())
->orderBy('date')
->withCount([
'serviceSongs as songs_total_count',
'serviceSongs as songs_mapped_count' => fn ($query) => $query->whereNotNull('song_id'),
'serviceSongs as songs_arranged_count' => fn ($query) => $query->whereNotNull('song_arrangement_id'),
])
->addSelect([
'has_sermon_slides' => Slide::query()
->selectRaw('CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END')
->whereColumn('slides.service_id', 'services.id')
->where('slides.type', 'sermon'),
'info_slides_count' => Slide::query()
->selectRaw('COUNT(*)')
->where('slides.type', 'information')
->where(function ($query) {
$query
->whereNull('slides.service_id')
->orWhereColumn('slides.service_id', 'services.id');
})
->whereNotNull('slides.expire_date')
->whereColumn('slides.expire_date', '>=', 'services.date'),
])
->get()
->map(fn (Service $service) => [
'id' => $service->id,
'title' => $service->title,
'date' => $service->date?->toDateString(),
'preacher_name' => $service->preacher_name,
'beamer_tech_name' => $service->beamer_tech_name,
'last_synced_at' => $service->last_synced_at?->toJSON(),
'updated_at' => $service->updated_at?->toJSON(),
'finalized_at' => $service->finalized_at?->toJSON(),
'songs_total_count' => (int) $service->songs_total_count,
'songs_mapped_count' => (int) $service->songs_mapped_count,
'songs_arranged_count' => (int) $service->songs_arranged_count,
'has_sermon_slides' => (bool) $service->has_sermon_slides,
'info_slides_count' => (int) $service->info_slides_count,
])
->values();
return Inertia::render('Services/Index', [
'services' => $services,
]);
}
public function finalize(Service $service): RedirectResponse
{
$service->update([
'finalized_at' => now(),
]);
return redirect()
->route('services.index')
->with('success', 'Service wurde abgeschlossen.');
}
public function reopen(Service $service): RedirectResponse
{
$service->update([
'finalized_at' => null,
]);
return redirect()
->route('services.index')
->with('success', 'Service wurde wieder geoeffnet.');
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\SongMatchingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceSongController extends Controller
{
public function __construct(
private readonly SongMatchingService $songMatchingService,
) {
}
/**
* Manuell einen Song aus der DB einem ServiceSong zuordnen.
*/
public function assignSong(int $serviceSongId, Request $request): JsonResponse
{
$validated = $request->validate([
'song_id' => ['required', 'integer', 'exists:songs,id'],
]);
$serviceSong = ServiceSong::findOrFail($serviceSongId);
$song = Song::findOrFail($validated['song_id']);
$this->songMatchingService->manualAssign($serviceSong, $song);
return response()->json([
'message' => 'Song erfolgreich zugeordnet',
'service_song' => $serviceSong->fresh(),
]);
}
/**
* E-Mail-Anfrage für fehlenden Song senden.
*/
public function requestSong(int $serviceSongId): JsonResponse
{
$serviceSong = ServiceSong::findOrFail($serviceSongId);
$this->songMatchingService->requestCreation($serviceSong);
return response()->json([
'message' => 'Anfrage wurde gesendet',
'service_song' => $serviceSong->fresh(),
]);
}
/**
* Song-Zuordnung entfernen.
*/
public function unassign(int $serviceSongId): JsonResponse
{
$serviceSong = ServiceSong::findOrFail($serviceSongId);
$this->songMatchingService->unassign($serviceSong);
return response()->json([
'message' => 'Zuordnung entfernt',
'service_song' => $serviceSong->fresh(),
]);
}
}

View file

@ -0,0 +1,214 @@
<?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);
}
}
}

View file

@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\SongRequest;
use App\Models\Song;
use App\Services\SongService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SongController extends Controller
{
public function __construct(
private readonly SongService $songService,
) {
}
/**
* Alle Songs auflisten (paginiert, durchsuchbar).
*/
public function index(Request $request): JsonResponse
{
$query = Song::query();
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('ccli_id', 'like', "%{$search}%");
});
}
$songs = $query->orderBy('title')
->paginate($request->input('per_page', 20));
return response()->json([
'data' => $songs->map(fn (Song $song) => [
'id' => $song->id,
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'author' => $song->author,
'has_translation' => $song->has_translation,
'last_used_at' => $song->last_used_at?->toDateString(),
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(),
]),
'meta' => [
'current_page' => $songs->currentPage(),
'last_page' => $songs->lastPage(),
'per_page' => $songs->perPage(),
'total' => $songs->total(),
],
]);
}
/**
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
*/
public function store(SongRequest $request): JsonResponse
{
$song = DB::transaction(function () use ($request) {
$song = Song::create($request->validated());
$this->songService->createDefaultGroups($song);
$this->songService->createDefaultArrangement($song);
return $song;
});
return response()->json([
'message' => 'Song erfolgreich erstellt',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
], 201);
}
/**
* Song mit Gruppen, Slides und Arrangements anzeigen.
*/
public function show(int $id): JsonResponse
{
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
}
return response()->json([
'data' => $this->formatSongDetail($song),
]);
}
/**
* Song-Metadaten aktualisieren.
*/
public function update(SongRequest $request, int $id): JsonResponse
{
$song = Song::find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
}
$song->update($request->validated());
return response()->json([
'message' => 'Song erfolgreich aktualisiert',
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
]);
}
/**
* Song soft-löschen.
*/
public function destroy(int $id): JsonResponse
{
$song = Song::find($id);
if (! $song) {
return response()->json(['message' => 'Song nicht gefunden'], 404);
}
$song->delete();
return response()->json([
'message' => 'Song erfolgreich gelöscht',
]);
}
/**
* Song-Detail formatieren.
*/
private function formatSongDetail(Song $song): array
{
return [
'id' => $song->id,
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'author' => $song->author,
'copyright_text' => $song->copyright_text,
'copyright_year' => $song->copyright_year,
'publisher' => $song->publisher,
'has_translation' => $song->has_translation,
'last_used_at' => $song->last_used_at?->toDateString(),
'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
'id' => $slide->id,
'order' => $slide->order,
'text_content' => $slide->text_content,
'text_content_translated' => $slide->text_content_translated,
'notes' => $slide->notes,
])->toArray(),
])->toArray(),
'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id,
'name' => $arr->name,
'is_default' => $arr->is_default,
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
'id' => $ag->id,
'song_group_id' => $ag->song_group_id,
'order' => $ag->order,
])->toArray(),
])->toArray(),
];
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers;
use App\Models\Song;
use App\Services\TranslationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TranslationController extends Controller
{
public function __construct(
private readonly TranslationService $translationService,
) {
}
/**
* URL abrufen und Text zum Prüfen zurückgeben.
*
* Der Text wird NICHT automatisch gespeichert der Benutzer
* prüft ihn zuerst und importiert dann explizit.
*/
public function fetchUrl(Request $request): JsonResponse
{
$request->validate([
'url' => ['required', 'url'],
]);
$text = $this->translationService->fetchFromUrl($request->input('url'));
if ($text === null) {
return response()->json([
'message' => 'Konnte Text nicht abrufen',
], 422);
}
return response()->json([
'text' => $text,
]);
}
/**
* Übersetzungstext für einen Song importieren.
*
* Verteilt den Text zeilenweise auf die Slides des Songs.
*/
public function import(int $songId, Request $request): JsonResponse
{
$song = Song::find($songId);
if (! $song) {
return response()->json([
'message' => 'Song nicht gefunden',
], 404);
}
$request->validate([
'text' => ['required', 'string'],
]);
$this->translationService->importTranslation($song, $request->input('text'));
return response()->json([
'message' => 'Übersetzung erfolgreich importiert',
]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*/
public function destroy(int $songId): JsonResponse
{
$song = Song::find($songId);
if (! $song) {
return response()->json([
'message' => 'Song nicht gefunden',
], 404);
}
$this->translationService->removeTranslation($song);
return response()->json([
'message' => 'Übersetzung entfernt',
]);
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SongRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$songId = $this->route('song');
return [
'title' => ['required', 'string', 'max:255'],
'ccli_id' => [
'nullable',
'string',
'max:50',
Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at'),
],
'author' => ['nullable', 'string', 'max:255'],
'copyright_text' => ['nullable', 'string', 'max:1000'],
'copyright_year' => ['nullable', 'string', 'max:10'],
'publisher' => ['nullable', 'string', 'max:255'],
'has_translation' => ['nullable', 'boolean'],
];
}
}

View file

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Casts\Attribute;
class Song extends Model class Song extends Model
{ {
@ -45,4 +46,24 @@ public function serviceSongs(): HasMany
{ {
return $this->hasMany(ServiceSong::class); return $this->hasMany(ServiceSong::class);
} }
/**
* Letztes Service-Datum, in dem dieser Song verwendet wurde.
* Berechnet über die service_songs -> services Verknüpfung.
*/
public function lastUsedInService(): Attribute
{
return Attribute::get(function () {
$latestDate = $this->serviceSongs()
->join('services', 'services.id', '=', 'service_songs.service_id')
->max('services.date');
if ($latestDate === null) {
return null;
}
// SQLite gibt date-Spalten als 'YYYY-MM-DD 00:00:00' zurück
return substr((string) $latestDate, 0, 10);
});
}
} }

View file

@ -214,6 +214,8 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
'unmatched_songs_count' => 0, 'unmatched_songs_count' => 0,
]; ];
$songMatchingService = app(SongMatchingService::class);
foreach ($agendaSongs as $index => $song) { foreach ($agendaSongs as $index => $song) {
$ctsSongId = (string) ($song->getId() ?? ''); $ctsSongId = (string) ($song->getId() ?? '');
if ($ctsSongId === '') { if ($ctsSongId === '') {
@ -221,9 +223,6 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
} }
$ccliId = $this->normalizeCcli($song->getCcli() ?? null); $ccliId = $this->normalizeCcli($song->getCcli() ?? null);
$matchedSong = $ccliId === null
? null
: DB::table('songs')->where('ccli_id', $ccliId)->first();
DB::table('service_songs')->updateOrInsert( DB::table('service_songs')->updateOrInsert(
[ [
@ -233,16 +232,26 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
[ [
'cts_song_name' => (string) ($song->getName() ?? ''), 'cts_song_name' => (string) ($song->getName() ?? ''),
'cts_ccli_id' => $ccliId, 'cts_ccli_id' => $ccliId,
'song_id' => $matchedSong?->id,
'matched_at' => $matchedSong !== null ? Carbon::now() : null,
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now(),
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
] ]
); );
$serviceSong = \App\Models\ServiceSong::where('service_id', $serviceId)
->where('order', $index + 1)
->first();
$matched = false;
if ($serviceSong !== null && $serviceSong->cts_ccli_id && ! $serviceSong->song_id) {
$matched = $songMatchingService->autoMatch($serviceSong);
} elseif ($serviceSong !== null && $serviceSong->song_id) {
$matched = true;
}
$summary['songs_count']++; $summary['songs_count']++;
if ($matchedSong !== null) { if ($matched) {
$summary['matched_songs_count']++; $summary['matched_songs_count']++;
} else { } else {
$summary['unmatched_songs_count']++; $summary['unmatched_songs_count']++;

View file

@ -0,0 +1,86 @@
<?php
namespace App\Services;
use App\Mail\MissingSongRequest;
use App\Models\ServiceSong;
use App\Models\Song;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Mail;
class SongMatchingService
{
/**
* Auto-match a service song to a song in the DB by CCLI ID.
*
* Returns true if a match was found and assigned, false otherwise.
* Skips if already matched or no CCLI ID present.
*/
public function autoMatch(ServiceSong $serviceSong): bool
{
if ($serviceSong->song_id !== null) {
return false;
}
if ($serviceSong->cts_ccli_id === null || $serviceSong->cts_ccli_id === '') {
return false;
}
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
if ($song === null) {
return false;
}
$serviceSong->update([
'song_id' => $song->id,
'matched_at' => Carbon::now(),
]);
return true;
}
/**
* Manually assign a song to a service song.
* Overwrites any existing assignment.
*/
public function manualAssign(ServiceSong $serviceSong, Song $song): void
{
$serviceSong->update([
'song_id' => $song->id,
'matched_at' => Carbon::now(),
]);
}
/**
* Send a missing song request email and record the timestamp.
*/
public function requestCreation(ServiceSong $serviceSong): void
{
$service = $serviceSong->service;
$recipientEmail = (string) Config::get('services.song_request.email', 'songs@example.com');
Mail::to($recipientEmail)->send(new MissingSongRequest(
songName: $serviceSong->cts_song_name,
ccliId: $serviceSong->cts_ccli_id,
service: $service,
));
$serviceSong->update([
'request_sent_at' => Carbon::now(),
]);
}
/**
* Remove song assignment from a service song.
*/
public function unassign(ServiceSong $serviceSong): void
{
$serviceSong->update([
'song_id' => null,
'matched_at' => null,
]);
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use Illuminate\Support\Facades\DB;
class SongService
{
/**
* Default-Gruppen für ein neues Lied erstellen.
*
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
*/
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
{
$defaults = [
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
];
foreach ($defaults as $groupData) {
$song->groups()->create($groupData);
}
return $song->groups()->orderBy('order')->get();
}
/**
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
*/
public function createDefaultArrangement(Song $song): SongArrangement
{
$arrangement = $song->arrangements()->create([
'name' => 'Normal',
'is_default' => true,
]);
$groups = $song->groups()->orderBy('order')->get();
foreach ($groups as $index => $group) {
$arrangement->arrangementGroups()->create([
'song_group_id' => $group->id,
'order' => $index + 1,
]);
}
return $arrangement->load('arrangementGroups');
}
/**
* Arrangement duplizieren mit neuem Namen.
*/
public function duplicateArrangement(SongArrangement $arrangement, string $name): SongArrangement
{
return DB::transaction(function () use ($arrangement, $name) {
$clone = $arrangement->replicate(['id', 'created_at', 'updated_at']);
$clone->name = $name;
$clone->is_default = false;
$clone->save();
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
SongArrangementGroup::create([
'song_arrangement_id' => $clone->id,
'song_group_id' => $group->song_group_id,
'order' => $group->order,
]);
}
return $clone->load('arrangementGroups');
});
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Services;
use App\Models\Song;
use App\Models\SongSlide;
use Illuminate\Support\Facades\Http;
class TranslationService
{
/**
* Text von einer URL abrufen (Best-Effort).
*
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
* Bei Fehlern wird null zurückgegeben, ohne Exception.
*/
public function fetchFromUrl(string $url): ?string
{
try {
$response = Http::timeout(10)->get($url);
if ($response->successful()) {
$html = $response->body();
$text = strip_tags($html);
$text = trim($text);
return $text !== '' ? $text : null;
}
} catch (\Exception) {
// Best-effort: Fehler stillschweigend behandeln
}
return null;
}
/**
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
*
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
*
* Beispiel:
* Slide 1 hat 4 Zeilen bekommt die nächsten 4 Zeilen der Übersetzung
* Slide 2 hat 2 Zeilen bekommt die nächsten 2 Zeilen
* Slide 3 hat 4 Zeilen bekommt die nächsten 4 Zeilen
*/
public function importTranslation(Song $song, string $text): void
{
$translatedLines = explode("\n", $text);
$offset = 0;
// Alle Gruppen nach order sortiert laden, mit Slides
$groups = $song->groups()->orderBy('order')->with([
'slides' => fn ($query) => $query->orderBy('order'),
])->get();
foreach ($groups as $group) {
foreach ($group->slides as $slide) {
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
$offset += $originalLineCount;
$slide->update([
'text_content_translated' => implode("\n", $chunk),
]);
}
}
$this->markAsTranslated($song);
}
/**
* Song als "hat Übersetzung" markieren.
*/
public function markAsTranslated(Song $song): void
{
$song->update(['has_translation' => true]);
}
/**
* Übersetzung eines Songs komplett entfernen.
*
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
*/
public function removeTranslation(Song $song): void
{
// Alle Slides des Songs über die Gruppen aktualisieren
$slideIds = SongSlide::whereIn(
'song_group_id',
$song->groups()->pluck('id')
)->pluck('id');
SongSlide::whereIn('id', $slideIds)->update([
'text_content_translated' => null,
]);
$song->update(['has_translation' => false]);
}
}

View file

@ -7,6 +7,7 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )

View file

@ -0,0 +1,296 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const props = defineProps({
songId: {
type: Number,
required: true,
},
arrangements: {
type: Array,
required: true,
},
availableGroups: {
type: Array,
required: true,
},
selectedArrangementId: {
type: Number,
default: null,
},
})
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
props.arrangements.find((arrangement) => arrangement.id === Number(selectedId.value)) ?? null,
)
watch(
() => props.availableGroups,
(groups) => {
poolGroups.value = groups.map((group) => ({ ...group }))
},
{ immediate: true, deep: true },
)
watch(
selectedArrangement,
(arrangement) => {
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
},
{ immediate: true },
)
const colorMap = computed(() => {
const map = {}
poolGroups.value.forEach((group) => {
map[group.id] = group.color
})
arrangementGroups.value.forEach((group) => {
map[group.id] = group.color
})
return map
})
const groupPillStyle = (group) => ({
backgroundColor: group.color,
})
const addArrangement = () => {
const name = window.prompt('Name des neuen Arrangements')
if (!name) {
return
}
router.post(
`/songs/${props.songId}/arrangements`,
{ name },
{
preserveScroll: true,
},
)
}
const cloneArrangement = () => {
if (!selectedArrangement.value) {
return
}
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
if (!name) {
return
}
router.post(
`/arrangements/${selectedArrangement.value.id}/clone`,
{ name },
{
preserveScroll: true,
},
)
}
const removeGroupAt = (index) => {
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
const syncPoolColor = (groupId, color) => {
poolGroups.value = poolGroups.value.map((group) => {
if (group.id !== groupId) {
return group
}
return {
...group,
color,
}
})
}
const updateGroupColor = (group, color) => {
group.color = color
syncPoolColor(group.id, color)
saveArrangement()
}
const saveArrangement = () => {
if (!selectedArrangement.value) {
return
}
router.put(
`/arrangements/${selectedArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
song_group_id: group.id,
order: index + 1,
})),
group_colors: colorMap.value,
},
{
preserveScroll: true,
preserveState: true,
},
)
}
const deleteArrangement = () => {
if (!selectedArrangement.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
})
}
</script>
<template>
<div class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-64 flex-1">
<label
for="arrangement-select"
class="mb-1 block text-sm font-medium text-gray-700"
>
Arrangement
</label>
<select
id="arrangement-select"
v-model="selectedId"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arrangement in arrangements"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}
</option>
</select>
</div>
<button
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="addArrangement"
>
Hinzufügen
</button>
<button
type="button"
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Klonen
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
@click="deleteArrangement"
>
Löschen
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="space-y-2">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
Verfügbare Gruppen
</h4>
<VueDraggable
v-model="poolGroups"
:group="{ name: 'song-groups', pull: 'clone', put: false }"
:sort="false"
class="min-h-24 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3"
>
<div
v-for="group in poolGroups"
:key="group.id"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
</div>
</VueDraggable>
</div>
<div class="space-y-2">
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
Gruppenfolge
</h4>
<VueDraggable
v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: true }"
class="min-h-24 rounded-lg border border-gray-200 bg-gray-50 p-3"
@end="saveArrangement"
>
<div
v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`"
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
>
<span class="cursor-move text-gray-500"></span>
<span
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
:style="groupPillStyle(group)"
>
{{ group.name }}
</span>
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
Farbe ändern
<input
type="color"
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
:value="group.color"
@input="updateGroupColor(group, $event.target.value)"
>
</label>
<button
type="button"
class="rounded px-2 py-1 text-xs font-semibold text-red-600 hover:bg-red-50"
@click="removeGroupAt(index)"
>
Entfernen
</button>
</div>
</VueDraggable>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,340 @@
<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
const props = defineProps({
slides: {
type: Array,
default: () => [],
},
type: {
type: String,
required: true,
validator: (v) => ['information', 'moderation', 'sermon'].includes(v),
},
showExpireDate: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['deleted', 'updated'])
// Delete confirmation state
const confirmingDelete = ref(false)
const slideToDelete = ref(null)
const deleting = ref(false)
// Inline expire date editing
const editingExpireId = ref(null)
const editingExpireValue = ref('')
// Sort slides: newest first
const sortedSlides = computed(() => {
return [...props.slides].sort(
(a, b) => new Date(b.uploaded_at) - new Date(a.uploaded_at),
)
})
function thumbnailUrl(slide) {
if (!slide.thumbnail_filename) return null
return `/storage/${slide.thumbnail_filename}`
}
function fullImageUrl(slide) {
if (!slide.stored_filename) return null
return `/storage/${slide.stored_filename}`
}
function formatDate(dateStr) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function formatDateTime(dateStr) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Delete slide
function promptDelete(slide) {
slideToDelete.value = slide
confirmingDelete.value = true
}
function cancelDelete() {
confirmingDelete.value = false
slideToDelete.value = null
}
function confirmDelete() {
if (!slideToDelete.value) return
deleting.value = true
router.delete(route('slides.destroy', slideToDelete.value.id), {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
confirmingDelete.value = false
slideToDelete.value = null
deleting.value = false
emit('deleted')
},
onError: () => {
deleting.value = false
},
})
}
// Expire date editing
function startEditExpire(slide) {
editingExpireId.value = slide.id
editingExpireValue.value = slide.expire_date
? new Date(slide.expire_date).toISOString().split('T')[0]
: ''
}
function saveExpireDate(slide) {
router.patch(
route('slides.update-expire-date', slide.id),
{ expire_date: editingExpireValue.value || null },
{
preserveScroll: true,
preserveState: true,
onSuccess: () => {
editingExpireId.value = null
emit('updated')
},
},
)
}
function cancelEditExpire() {
editingExpireId.value = null
editingExpireValue.value = ''
}
function isExpireSoon(expireDate) {
if (!expireDate) return false
const diff = new Date(expireDate) - new Date()
return diff > 0 && diff < 7 * 24 * 60 * 60 * 1000 // 7 days
}
function isExpired(expireDate) {
if (!expireDate) return false
return new Date(expireDate) < new Date()
}
</script>
<template>
<div class="slide-grid">
<!-- Empty state -->
<div
v-if="sortedSlides.length === 0"
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50/50 py-10"
>
<svg class="h-10 w-10 text-gray-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<p class="text-sm text-gray-400">Noch keine Folien vorhanden</p>
</div>
<!-- Grid of thumbnails -->
<div
v-else
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
>
<div
v-for="slide in sortedSlides"
:key="slide.id"
class="slide-card group relative overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-all duration-300 hover:shadow-md hover:border-gray-300 hover:-translate-y-0.5"
>
<!-- PPT processing indicator -->
<div
v-if="slide._processing"
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/90 backdrop-blur-sm"
>
<LoadingSpinner size="md" />
<span class="mt-2 text-xs font-medium text-gray-500">Verarbeitung läuft...</span>
</div>
<!-- Thumbnail -->
<div class="relative aspect-video overflow-hidden bg-gray-900">
<img
v-if="thumbnailUrl(slide)"
:src="thumbnailUrl(slide)"
:alt="slide.original_filename"
class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900"
>
<svg class="h-8 w-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
</div>
<!-- Delete button overlay -->
<button
@click.stop="promptDelete(slide)"
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:text-white group-hover:opacity-100"
title="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
<!-- Full image link overlay -->
<a
v-if="fullImageUrl(slide)"
:href="fullImageUrl(slide)"
target="_blank"
class="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-white/90 hover:text-gray-800 group-hover:opacity-100"
title="Vollbild öffnen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
<!-- Metadata -->
<div class="px-3 py-2.5">
<!-- Upload info (muted) -->
<div class="flex items-center gap-1.5 text-[11px] text-gray-400">
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span>
</div>
<div
v-if="slide.uploader_name"
class="mt-0.5 flex items-center gap-1.5 text-[11px] text-gray-400"
>
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
</svg>
<span class="truncate">{{ slide.uploader_name }}</span>
</div>
<!-- Expire date (prominent for information slides) -->
<div
v-if="showExpireDate"
class="mt-2"
>
<!-- Display mode -->
<div
v-if="editingExpireId !== slide.id"
class="flex items-center gap-1.5 cursor-pointer rounded-lg px-2 py-1 -mx-1 transition-colors hover:bg-gray-50"
@click="startEditExpire(slide)"
:title="'Ablaufdatum ändern'"
>
<svg
class="h-3.5 w-3.5 shrink-0"
:class="{
'text-red-500': isExpired(slide.expire_date),
'text-amber-500': isExpireSoon(slide.expire_date),
'text-emerald-500': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
'text-gray-300': !slide.expire_date,
}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span
class="text-xs font-semibold"
:class="{
'text-red-600': isExpired(slide.expire_date),
'text-amber-600': isExpireSoon(slide.expire_date),
'text-gray-700': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
'text-gray-400 font-normal': !slide.expire_date,
}"
>
{{ slide.expire_date ? formatDate(slide.expire_date) : 'Kein Ablaufdatum' }}
</span>
<!-- Tiny edit icon -->
<svg class="h-3 w-3 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>
</div>
<!-- Edit mode -->
<div
v-else
class="flex items-center gap-1.5"
>
<input
v-model="editingExpireValue"
type="date"
class="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400/40"
@keydown.enter="saveExpireDate(slide)"
@keydown.escape="cancelEditExpire"
/>
<button
@click="saveExpireDate(slide)"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-amber-500 text-white shadow-sm transition hover:bg-amber-600"
title="Speichern"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
<button
@click="cancelEditExpire"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-500 shadow-sm transition hover:bg-gray-50"
title="Abbrechen"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="confirmingDelete"
title="Folie löschen?"
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
confirm-label="Löschen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<style scoped>
.slide-card {
contain: layout;
}
.slide-card img {
image-rendering: auto;
}
</style>

View file

@ -0,0 +1,283 @@
<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import Vue3Dropzone from '@jaxtheprime/vue3-dropzone'
import '@jaxtheprime/vue3-dropzone/dist/style.css'
const props = defineProps({
type: {
type: String,
required: true,
validator: (v) => ['information', 'moderation', 'sermon'].includes(v),
},
serviceId: {
type: [Number, null],
default: null,
},
showExpireDate: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['uploaded'])
const files = ref([])
const expireDate = ref('')
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadError = ref('')
const uploadedCount = ref(0)
const totalCount = ref(0)
const acceptedTypes = [
'image/png',
'image/jpg',
'image/jpeg',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/zip',
]
const acceptedExtensions = ['.png', '.jpg', '.jpeg', '.ppt', '.pptx', '.zip']
const isUploading = computed(() => uploading.value)
const progressPercent = computed(() => Math.round(uploadProgress.value))
function processFiles() {
if (files.value.length === 0) return
uploadError.value = ''
totalCount.value = files.value.length
uploadedCount.value = 0
uploading.value = true
uploadProgress.value = 0
uploadNextFile(0)
}
function uploadNextFile(index) {
if (index >= files.value.length) {
uploading.value = false
uploadProgress.value = 100
files.value = []
emit('uploaded')
// Reset progress after brief display
setTimeout(() => {
uploadProgress.value = 0
uploadedCount.value = 0
totalCount.value = 0
}, 2000)
return
}
const file = files.value[index]
// Validate extension client-side
const ext = '.' + file.name.split('.').pop().toLowerCase()
if (!acceptedExtensions.includes(ext)) {
uploadError.value = `"${file.name}" — Dateityp nicht erlaubt. Nur PNG, JPG, PPT, PPTX und ZIP.`
uploading.value = false
return
}
const formData = new FormData()
formData.append('file', file)
formData.append('type', props.type)
if (props.serviceId) {
formData.append('service_id', props.serviceId)
}
if (props.showExpireDate && expireDate.value) {
formData.append('expire_date', expireDate.value)
}
router.post(route('slides.store'), formData, {
forceFormData: true,
preserveScroll: true,
preserveState: true,
onProgress: (event) => {
if (event.percentage) {
// Per-file progress weighted across total
const perFileWeight = 100 / totalCount.value
uploadProgress.value = (index * perFileWeight) + (event.percentage / 100 * perFileWeight)
}
},
onSuccess: () => {
uploadedCount.value = index + 1
uploadNextFile(index + 1)
},
onError: (errors) => {
uploading.value = false
uploadError.value = errors.file?.[0] || errors.message || 'Upload fehlgeschlagen.'
},
})
}
function dismissError() {
uploadError.value = ''
}
</script>
<template>
<div class="slide-uploader">
<!-- Expire date picker for information slides -->
<div
v-if="showExpireDate"
class="mb-4 flex items-center gap-3"
>
<label class="text-sm font-medium text-gray-600">
Ablaufdatum für neue Folien
</label>
<input
v-model="expireDate"
type="date"
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm transition focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
/>
</div>
<!-- Error message -->
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-1"
>
<div
v-if="uploadError"
class="mb-4 flex items-center gap-3 rounded-xl border border-red-200 bg-red-50/80 px-4 py-3 text-sm text-red-700"
>
<svg class="h-5 w-5 shrink-0 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<span class="flex-1">{{ uploadError }}</span>
<button
@click="dismissError"
class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-600"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</Transition>
<!-- Upload progress bar -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="isUploading || (uploadProgress > 0 && uploadProgress <= 100)"
class="mb-4"
>
<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">
<span class="font-medium">
Hochladen... {{ uploadedCount }}/{{ totalCount }}
</span>
<span class="tabular-nums">{{ progressPercent }}%</span>
</div>
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
:style="{ width: `${progressPercent}%` }"
/>
</div>
</div>
</Transition>
<!-- Dropzone -->
<Vue3Dropzone
v-model="files"
:multiple="true"
:accept="acceptedTypes"
:max-file-size="50"
:max-files="20"
:disabled="isUploading"
:show-select-button="false"
class="slide-dropzone"
@change="processFiles"
>
<template #placeholder-img>
<div class="flex flex-col items-center gap-3 pointer-events-none">
<!-- Big plus icon -->
<div class="relative">
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 shadow-sm ring-1 ring-amber-200/60">
<svg class="h-8 w-8 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
</div>
<!-- Animated pulse ring -->
<div class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" />
</div>
</div>
</template>
<template #title>
<span class="mt-3 block text-sm font-semibold text-gray-700">
Dateien hier ablegen
</span>
</template>
<template #description>
<span class="mt-1 block text-xs text-gray-400">
oder klicken zum Auswählen
</span>
<span class="mt-2 block text-[11px] text-gray-300">
PNG, JPG, PPT, PPTX, ZIP max 50 MB
</span>
</template>
</Vue3Dropzone>
</div>
</template>
<style scoped>
.slide-dropzone {
--v3-dropzone--primary: 245, 158, 11;
--v3-dropzone--border: 229, 231, 235;
--v3-dropzone--description: 156, 163, 175;
--v3-dropzone--overlay: 245, 158, 11;
--v3-dropzone--overlay-opacity: 0.08;
}
.slide-dropzone :deep(.v3-dropzone) {
border: 2px dashed rgb(229 231 235);
border-radius: 1rem;
background: linear-gradient(135deg, rgb(255 251 235 / 0.5), rgb(254 243 199 / 0.3));
padding: 2rem 1.5rem;
min-height: 160px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
.slide-dropzone :deep(.v3-dropzone:hover) {
border-color: rgb(251 191 36);
background: linear-gradient(135deg, rgb(255 251 235 / 0.8), rgb(254 243 199 / 0.5));
box-shadow: 0 0 0 4px rgb(251 191 36 / 0.1);
}
.slide-dropzone :deep(.v3-dropzone.v3-dropzone--active) {
border-color: rgb(245 158 11);
background: linear-gradient(135deg, rgb(255 251 235), rgb(254 243 199 / 0.7));
box-shadow: 0 0 0 4px rgb(245 158 11 / 0.15);
transform: scale(1.01);
}
.slide-dropzone :deep(.v3-dropzone--disabled) {
opacity: 0.5;
cursor: not-allowed;
}
/* Hide the default preview since we handle it in SlideGrid */
.slide-dropzone :deep(.v3-dropzone__preview) {
display: none;
}
</style>

View file

@ -83,8 +83,8 @@ function triggerSync() {
<!-- Desktop Navigation --> <!-- Desktop Navigation -->
<div class="hidden items-center gap-1 sm:flex"> <div class="hidden items-center gap-1 sm:flex">
<NavLink <NavLink
:href="route('dashboard')" :href="route('services.index')"
:active="route().current('dashboard') || route().current('services.*')" :active="route().current('services.*')"
> >
Services Services
</NavLink> </NavLink>
@ -231,8 +231,8 @@ function triggerSync() {
<!-- Mobile Navigation --> <!-- Mobile Navigation -->
<div class="space-y-1 pb-3 pt-2"> <div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink <ResponsiveNavLink
:href="route('dashboard')" :href="route('services.index')"
:active="route().current('dashboard') || route().current('services.*')" :active="route().current('services.*')"
> >
Services Services
</ResponsiveNavLink> </ResponsiveNavLink>

View file

@ -0,0 +1,233 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head, router } from '@inertiajs/vue3'
import { ref } from 'vue'
const props = defineProps({
services: {
type: Array,
default: () => [],
},
})
const toastMessage = ref('')
function formatDate(value) {
if (!value) {
return '-'
}
return new Date(value).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function formatDateTime(value) {
if (!value) {
return '-'
}
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function showComingSoon() {
toastMessage.value = 'Demnaechst verfuegbar'
setTimeout(() => {
toastMessage.value = ''
}, 2500)
}
function finalizeService(serviceId) {
router.post(route('services.finalize', serviceId), {}, { preserveScroll: true })
}
function reopenService(serviceId) {
router.post(route('services.reopen', serviceId), {}, { preserveScroll: true })
}
function mappingStatusClass(service) {
if (service.songs_total_count === 0) {
return 'text-red-600'
}
if (service.songs_mapped_count === service.songs_total_count) {
return 'text-emerald-700'
}
return 'text-orange-600'
}
function arrangementStatusClass(service) {
if (service.songs_total_count === 0) {
return 'text-red-600'
}
if (service.songs_arranged_count === service.songs_total_count) {
return 'text-emerald-700'
}
return 'text-orange-600'
}
function stateIconClass(isDone) {
return isDone ? 'text-emerald-600' : 'text-red-600'
}
</script>
<template>
<Head title="Services" />
<AuthenticatedLayout>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<h2 class="text-xl font-semibold leading-tight text-gray-800">Services</h2>
<p class="text-sm text-gray-500">Hier siehst du alle heutigen und kommenden Services.</p>
</div>
</template>
<div class="py-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="translate-y-1 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="toastMessage"
class="mb-4 rounded-lg border border-orange-300 bg-orange-50 px-4 py-3 text-sm font-medium text-orange-700"
role="status"
>
{{ toastMessage }}
</div>
</Transition>
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
<div v-if="services.length === 0" class="p-8 text-center text-sm text-gray-500">
Aktuell gibt es keine heutigen oder kommenden Services.
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Titel</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Prediger</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Beamer-Techniker</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Anzahl Songs</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Letzte Änderung</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr v-for="service in services" :key="service.id" class="align-top hover:bg-gray-50/60">
<td class="px-4 py-4">
<div class="font-medium text-gray-900">{{ service.title }}</div>
<div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div>
</td>
<td class="px-4 py-4 text-sm text-gray-700">
{{ service.preacher_name || '-' }}
</td>
<td class="px-4 py-4 text-sm text-gray-700">
{{ service.beamer_tech_name || '-' }}
</td>
<td class="px-4 py-4 text-sm text-gray-700">
{{ service.songs_total_count }}
</td>
<td class="px-4 py-4 text-sm text-gray-700">
{{ formatDateTime(service.updated_at) }}
</td>
<td class="px-4 py-4">
<div class="space-y-1.5 text-xs font-medium">
<div :class="mappingStatusClass(service)">
{{ service.songs_mapped_count }}/{{ service.songs_total_count }} Songs zugeordnet
</div>
<div :class="arrangementStatusClass(service)">
{{ service.songs_arranged_count }}/{{ service.songs_total_count }} Arrangements geprueft
</div>
<div :class="stateIconClass(service.has_sermon_slides)" class="flex items-center gap-1.5">
<span aria-hidden="true">{{ service.has_sermon_slides ? '✓' : '•' }}</span>
<span>Predigtfolien</span>
</div>
<div :class="stateIconClass(service.info_slides_count > 0)" class="flex items-center gap-1.5">
<span aria-hidden="true">{{ service.info_slides_count > 0 ? '✓' : '•' }}</span>
<span>{{ service.info_slides_count }} Infofolien</span>
</div>
<div :class="stateIconClass(Boolean(service.finalized_at))" class="flex items-center gap-1.5">
<span aria-hidden="true">{{ service.finalized_at ? '✓' : '•' }}</span>
<span>
Abgeschlossen am
{{ service.finalized_at ? formatDateTime(service.finalized_at) : '-' }}
</span>
</div>
</div>
</td>
<td class="px-4 py-4">
<div class="flex flex-col gap-2">
<template v-if="service.finalized_at">
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
@click="reopenService(service.id)"
>
Wieder öffnen
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="showComingSoon"
>
Herunterladen
</button>
</template>
<template v-else>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
@click="showComingSoon"
>
Bearbeiten
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100"
@click="finalizeService(service.id)"
>
Abschließen
</button>
</template>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

39
routes/api.php Normal file
View file

@ -0,0 +1,39 @@
<?php
use App\Http\Controllers\ServiceSongController;
use App\Http\Controllers\SongController;
use App\Http\Controllers\TranslationController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routen
|--------------------------------------------------------------------------
|
| Alle API-Routen sind authentifizierungspflichtig und verwenden
| das Prefix /api automatisch durch die Laravel API-Routing-Konvention.
|
*/
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('songs', SongController::class);
Route::post('/service-songs/{serviceSongId}/assign', [ServiceSongController::class, 'assignSong'])
->name('api.service-songs.assign');
Route::post('/service-songs/{serviceSongId}/request', [ServiceSongController::class, 'requestSong'])
->name('api.service-songs.request');
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign'])
->name('api.service-songs.unassign');
// Übersetzung
Route::post('/translation/fetch-url', [TranslationController::class, 'fetchUrl'])
->name('api.translation.fetch-url');
Route::post('/songs/{song}/translation/import', [TranslationController::class, 'import'])
->name('api.songs.translation.import');
Route::delete('/songs/{song}/translation', [TranslationController::class, 'destroy'])
->name('api.songs.translation.destroy');
});

View file

@ -1,35 +1,23 @@
<?php <?php
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\ArrangementController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\SlideController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
/*
|--------------------------------------------------------------------------
| Authentifizierung (öffentlich)
|--------------------------------------------------------------------------
*/
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
Route::get('/auth/churchtools', [AuthController::class, 'redirect'])->name('auth.churchtools'); Route::get('/auth/churchtools', [AuthController::class, 'redirect'])->name('auth.churchtools');
Route::get('/auth/churchtools/callback', [AuthController::class, 'callback'])->name('auth.churchtools.callback'); Route::get('/auth/churchtools/callback', [AuthController::class, 'callback'])->name('auth.churchtools.callback');
}); });
/*
|--------------------------------------------------------------------------
| Abmeldung
|--------------------------------------------------------------------------
*/
Route::post('/logout', [AuthController::class, 'logout']) Route::post('/logout', [AuthController::class, 'logout'])
->middleware('auth') ->middleware('auth')
->name('logout'); ->name('logout');
/*
|--------------------------------------------------------------------------
| Geschützte Routen (nur für angemeldete Benutzer)
|--------------------------------------------------------------------------
*/
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::get('/', function () { Route::get('/', function () {
return redirect()->route('dashboard'); return redirect()->route('dashboard');
@ -39,5 +27,23 @@
return Inertia::render('Dashboard'); return Inertia::render('Dashboard');
})->name('dashboard'); })->name('dashboard');
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy');
Route::post('/sync', [SyncController::class, 'sync'])->name('sync'); Route::post('/sync', [SyncController::class, 'sync'])->name('sync');
/*
|--------------------------------------------------------------------------
| Folien-Verwaltung
|--------------------------------------------------------------------------
*/
Route::post('/slides', '\\App\\Http\\Controllers\\SlideController@store')->name('slides.store');
Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy');
Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
}); });

View file

@ -0,0 +1,183 @@
<?php
namespace Tests\Feature;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Tests\TestCase;
class ArrangementControllerTest extends TestCase
{
use RefreshDatabase;
public function test_create_arrangement_clones_groups_from_default_arrangement(): void
{
[$song, $normal] = $this->createSongWithDefaultArrangement();
$user = User::factory()->create();
Auth::login($user);
$response = $this->post(route('arrangements.store', $song), [
'name' => 'Kurz',
]);
$response->assertRedirect();
$newArrangement = SongArrangement::query()
->where('song_id', $song->id)
->where('name', 'Kurz')
->first();
$this->assertNotNull($newArrangement);
$normalGroups = SongArrangementGroup::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->all();
$newGroups = SongArrangementGroup::query()
->where('song_arrangement_id', $newArrangement->id)
->orderBy('order')
->pluck('song_group_id')
->all();
$this->assertSame($normalGroups, $newGroups);
}
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
{
[$song, $normal] = $this->createSongWithDefaultArrangement();
$user = User::factory()->create();
Auth::login($user);
$response = $this->post(route('arrangements.clone', $normal), [
'name' => 'Normal Kopie',
]);
$response->assertRedirect();
$clone = SongArrangement::query()
->where('song_id', $song->id)
->where('name', 'Normal Kopie')
->first();
$this->assertNotNull($clone);
$this->assertFalse($clone->is_default);
$originalGroups = SongArrangementGroup::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->all();
$cloneGroups = SongArrangementGroup::query()
->where('song_arrangement_id', $clone->id)
->orderBy('order')
->pluck('song_group_id')
->all();
$this->assertSame($originalGroups, $cloneGroups);
}
public function test_update_arrangement_reorders_and_persists_groups(): void
{
[, $normal, $verse, $chorus, $bridge] = $this->createSongWithDefaultArrangement();
$user = User::factory()->create();
Auth::login($user);
$response = $this->put(route('arrangements.update', $normal), [
'groups' => [
['song_group_id' => $chorus->id, 'order' => 1],
['song_group_id' => $bridge->id, 'order' => 2],
['song_group_id' => $verse->id, 'order' => 3],
['song_group_id' => $chorus->id, 'order' => 4],
],
]);
$response->assertRedirect();
$updated = SongArrangementGroup::query()
->where('song_arrangement_id', $normal->id)
->orderBy('order')
->pluck('song_group_id')
->all();
$this->assertSame([
$chorus->id,
$bridge->id,
$verse->id,
$chorus->id,
], $updated);
}
public function test_cannot_delete_the_last_arrangement_of_a_song(): void
{
[$song, $normal] = $this->createSongWithDefaultArrangement();
$user = User::factory()->create();
Auth::login($user);
$this->assertSame(1, $song->arrangements()->count());
$response = $this->delete(route('arrangements.destroy', $normal));
$response->assertRedirect();
$response->assertSessionHas('error', 'Das letzte Arrangement kann nicht gelöscht werden.');
$this->assertTrue(SongArrangement::query()->whereKey($normal->id)->exists());
$this->assertSame(1, $song->arrangements()->count());
}
private function createSongWithDefaultArrangement(): array
{
$song = Song::factory()->create();
$verse = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Verse 1',
'order' => 1,
]);
$chorus = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Chorus',
'order' => 2,
]);
$bridge = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Bridge',
'order' => 3,
]);
$normal = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Normal',
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $chorus->id,
'order' => 2,
]);
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $normal->id,
'song_group_id' => $verse->id,
'order' => 3,
]);
return [$song, $normal, $verse, $chorus, $bridge];
}
}

View file

@ -0,0 +1,179 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
final class ServiceControllerTest extends TestCase
{
use RefreshDatabase;
public function test_services_index_zeigt_nur_heutige_und_kuenftige_services_mit_statusdaten(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$this->withoutVite();
$user = User::factory()->create();
Service::factory()->create([
'date' => Carbon::today()->subDay(),
'finalized_at' => null,
]);
$todayService = Service::factory()->create([
'title' => 'Gottesdienst Heute',
'date' => Carbon::today(),
'finalized_at' => null,
]);
$futureService = Service::factory()->create([
'title' => 'Gottesdienst Zukunft',
'date' => Carbon::today()->addDays(3),
'finalized_at' => null,
]);
$song = Song::factory()->create();
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
]);
ServiceSong::create([
'service_id' => $todayService->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Song 1',
'cts_ccli_id' => '100001',
]);
ServiceSong::create([
'service_id' => $todayService->id,
'song_id' => $song->id,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 2,
'cts_song_name' => 'Song 2',
'cts_ccli_id' => '100002',
]);
ServiceSong::create([
'service_id' => $todayService->id,
'song_id' => null,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 3,
'cts_song_name' => 'Song 3',
'cts_ccli_id' => '100003',
]);
ServiceSong::create([
'service_id' => $futureService->id,
'song_id' => $song->id,
'song_arrangement_id' => $arrangement->id,
'use_translation' => false,
'order' => 1,
'cts_song_name' => 'Song 4',
'cts_ccli_id' => '100004',
]);
ServiceSong::create([
'service_id' => $futureService->id,
'song_id' => null,
'song_arrangement_id' => null,
'use_translation' => false,
'order' => 2,
'cts_song_name' => 'Song 5',
'cts_ccli_id' => '100005',
]);
Slide::factory()->create([
'service_id' => $todayService->id,
'type' => 'sermon',
'expire_date' => null,
]);
Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'expire_date' => Carbon::today()->addDay(),
]);
Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'expire_date' => Carbon::today()->addDays(5),
]);
Slide::factory()->create([
'service_id' => $todayService->id,
'type' => 'information',
'expire_date' => Carbon::today()->addDays(10),
]);
Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'expire_date' => Carbon::today()->subDay(),
]);
$response = $this->actingAs($user)->get(route('services.index'));
$response->assertOk();
$response->assertInertia(
fn ($page) => $page
->component('Services/Index')
->has('services', 2)
->where('services.0.id', $todayService->id)
->where('services.0.title', 'Gottesdienst Heute')
->where('services.0.songs_total_count', 3)
->where('services.0.songs_mapped_count', 2)
->where('services.0.songs_arranged_count', 1)
->where('services.0.has_sermon_slides', true)
->where('services.0.info_slides_count', 3)
->where('services.1.id', $futureService->id)
->where('services.1.title', 'Gottesdienst Zukunft')
->where('services.1.songs_total_count', 2)
->where('services.1.songs_mapped_count', 1)
->where('services.1.songs_arranged_count', 1)
->where('services.1.has_sermon_slides', false)
->where('services.1.info_slides_count', 1)
);
}
public function test_service_kann_abgeschlossen_werden(): void
{
Carbon::setTestNow('2026-03-01 10:00:00');
$user = User::factory()->create();
$service = Service::factory()->create([
'finalized_at' => null,
]);
$response = $this->actingAs($user)->post(route('services.finalize', $service));
$response->assertRedirect(route('services.index'));
$this->assertSame(now()->toDateTimeString(), $service->fresh()->finalized_at?->toDateTimeString());
}
public function test_service_kann_wieder_geoeffnet_werden(): void
{
$user = User::factory()->create();
$service = Service::factory()->create([
'finalized_at' => now(),
]);
$response = $this->actingAs($user)->post(route('services.reopen', $service));
$response->assertRedirect(route('services.index'));
$this->assertNull($service->fresh()->finalized_at);
}
}

View file

@ -0,0 +1,300 @@
<?php
use App\Models\Service;
use App\Models\Slide;
use App\Models\User;
use App\Services\FileConversionService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('public');
Queue::fake();
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
/*
|--------------------------------------------------------------------------
| Upload (store)
|--------------------------------------------------------------------------
*/
test('upload image creates slide with 1920x1080 jpg', function () {
$service = Service::factory()->create();
$file = makePngUploadForSlide('test-slide.png', 800, 600);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
'service_id' => $service->id,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
$slide = Slide::first();
expect($slide)->not->toBeNull();
expect($slide->type)->toBe('information');
expect($slide->service_id)->toBe($service->id);
expect($slide->uploader_name)->toBe($this->user->name);
expect($slide->original_filename)->toBe('test-slide.png');
expect(Storage::disk('public')->exists($slide->stored_filename))->toBeTrue();
expect(Storage::disk('public')->exists($slide->thumbnail_filename))->toBeTrue();
});
test('upload image with expire_date stores date on slide', function () {
$service = Service::factory()->create();
$file = makePngUploadForSlide('info.png', 400, 300);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
'service_id' => $service->id,
'expire_date' => '2026-06-15',
]);
$response->assertStatus(200);
$slide = Slide::first();
expect($slide->expire_date->format('Y-m-d'))->toBe('2026-06-15');
});
test('upload moderation slide without service_id fails', function () {
$file = makePngUploadForSlide('mod.png', 400, 300);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'moderation',
]);
$response->assertStatus(422);
});
test('upload information slide without service_id is allowed', function () {
$file = makePngUploadForSlide('info.png', 400, 300);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
]);
$response->assertStatus(200);
expect(Slide::first()->service_id)->toBeNull();
});
test('upload rejects unsupported file types', function () {
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
]);
$response->assertStatus(422);
});
test('upload rejects invalid type', function () {
$file = makePngUploadForSlide('test.png', 400, 300);
$response = $this->postJson(route('slides.store'), [
'file' => $file,
'type' => 'invalid_type',
]);
$response->assertStatus(422);
});
test('upload pptx dispatches conversion job', function () {
// Mock the conversion service to return a job ID
$mockService = Mockery::mock(FileConversionService::class);
$mockService->shouldReceive('convertPowerPoint')
->once()
->andReturn('test-job-uuid-1234');
app()->instance(FileConversionService::class, $mockService);
// Create a real file with .pptx extension
$tempPath = tempnam(sys_get_temp_dir(), 'cts-pptx-');
$pptxPath = $tempPath . '.pptx';
file_put_contents($pptxPath, str_repeat('x', 1024));
$file = new UploadedFile($pptxPath, 'slides.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', null, true);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'sermon',
'service_id' => Service::factory()->create()->id,
]);
$response->assertStatus(200);
$response->assertJsonStructure(['success', 'job_id']);
$response->assertJson(['job_id' => 'test-job-uuid-1234']);
// Cleanup
@unlink($pptxPath);
@unlink($tempPath);
});
test('upload zip processes contained images', function () {
// Create a small zip with a valid image
$tempDir = sys_get_temp_dir() . '/cts-zip-test-' . uniqid();
mkdir($tempDir, 0775, true);
// Create an image inside
$imgPath = $tempDir . '/slide1.png';
$image = imagecreatetruecolor(200, 150);
$blue = imagecolorallocate($image, 0, 0, 255);
imagefill($image, 0, 0, $blue);
imagepng($image, $imgPath);
imagedestroy($image);
// Build zip
$zipPath = $tempDir . '/slides.zip';
$zip = new ZipArchive();
$zip->open($zipPath, ZipArchive::CREATE);
$zip->addFile($imgPath, 'slide1.png');
$zip->close();
$file = new UploadedFile($zipPath, 'slides.zip', 'application/zip', null, true);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'moderation',
'service_id' => Service::factory()->create()->id,
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
// Cleanup
@unlink($imgPath);
@unlink($zipPath);
@rmdir($tempDir);
});
test('unauthenticated user cannot upload slides', function () {
auth()->logout();
$file = makePngUploadForSlide('test.png', 400, 300);
$response = $this->post(route('slides.store'), [
'file' => $file,
'type' => 'information',
]);
$response->assertRedirect('/login');
});
/*
|--------------------------------------------------------------------------
| Soft Delete (destroy)
|--------------------------------------------------------------------------
*/
test('delete slide soft deletes it', function () {
$slide = Slide::factory()->create([
'service_id' => Service::factory()->create()->id,
'uploader_name' => $this->user->name,
]);
$response = $this->delete(route('slides.destroy', $slide));
$response->assertStatus(200);
$response->assertJson(['success' => true]);
expect(Slide::find($slide->id))->toBeNull();
expect(Slide::withTrashed()->find($slide->id))->not->toBeNull();
});
test('delete non-existing slide returns 404', function () {
$response = $this->delete(route('slides.destroy', 99999));
$response->assertStatus(404);
});
/*
|--------------------------------------------------------------------------
| Update Expire Date
|--------------------------------------------------------------------------
*/
test('update expire date on information slide', function () {
$slide = Slide::factory()->create([
'type' => 'information',
'expire_date' => '2026-04-01',
'service_id' => null,
]);
$response = $this->patch(route('slides.update-expire-date', $slide), [
'expire_date' => '2026-08-15',
]);
$response->assertStatus(200);
$response->assertJson(['success' => true]);
expect($slide->fresh()->expire_date->format('Y-m-d'))->toBe('2026-08-15');
});
test('update expire date rejects non-information slides', function () {
$slide = Slide::factory()->create([
'type' => 'sermon',
'service_id' => Service::factory()->create()->id,
]);
$response = $this->patch(route('slides.update-expire-date', $slide), [
'expire_date' => '2026-08-15',
]);
$response->assertStatus(422);
});
test('expire date must be a valid date', function () {
$slide = Slide::factory()->create([
'type' => 'information',
'service_id' => null,
]);
$response = $this->patchJson(route('slides.update-expire-date', $slide), [
'expire_date' => 'not-a-date',
]);
$response->assertStatus(422);
});
test('expire date can be set to null', function () {
$slide = Slide::factory()->create([
'type' => 'information',
'expire_date' => '2026-04-01',
'service_id' => null,
]);
$response = $this->patch(route('slides.update-expire-date', $slide), [
'expire_date' => null,
]);
$response->assertStatus(200);
expect($slide->fresh()->expire_date)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| Helpers
|--------------------------------------------------------------------------
*/
function makePngUploadForSlide(string $name, int $width, int $height): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'cts-slide-');
if ($path === false) {
throw new RuntimeException('Temp-Datei konnte nicht erstellt werden.');
}
$image = imagecreatetruecolor($width, $height);
if ($image === false) {
throw new RuntimeException('Bild konnte nicht erstellt werden.');
}
$red = imagecolorallocate($image, 255, 0, 0);
imagefill($image, 0, 0, $red);
imagepng($image, $path);
imagedestroy($image);
return new UploadedFile($path, $name, 'image/png', null, true);
}

View file

@ -0,0 +1,309 @@
<?php
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song CRUD API Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
});
// --- INDEX / LIST ---
test('songs index returns paginated list', function () {
Song::factory()->count(3)->create();
$response = $this->actingAs($this->user)
->getJson('/api/songs');
$response->assertOk()
->assertJsonStructure([
'data' => [['id', 'title', 'ccli_id', 'author', 'has_translation']],
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
]);
expect($response->json('meta.total'))->toBe(3);
});
test('songs index excludes soft-deleted songs', function () {
Song::factory()->count(2)->create();
Song::factory()->create(['deleted_at' => now()]);
$response = $this->actingAs($this->user)
->getJson('/api/songs');
$response->assertOk();
expect($response->json('meta.total'))->toBe(2);
});
test('songs index search by title', function () {
Song::factory()->create(['title' => 'Amazing Grace']);
Song::factory()->create(['title' => 'Holy Spirit']);
$response = $this->actingAs($this->user)
->getJson('/api/songs?search=Amazing');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.title'))->toBe('Amazing Grace');
});
test('songs index search by ccli id', function () {
Song::factory()->create(['ccli_id' => '123456', 'title' => 'Song A']);
Song::factory()->create(['ccli_id' => '789012', 'title' => 'Song B']);
$response = $this->actingAs($this->user)
->getJson('/api/songs?search=123456');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.ccli_id'))->toBe('123456');
});
test('songs index requires authentication', function () {
$response = $this->getJson('/api/songs');
$response->assertUnauthorized();
});
// --- STORE / CREATE ---
test('store creates song with default groups and arrangement', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
'title' => 'Neues Lied',
'ccli_id' => '999999',
'author' => 'Test Author',
]);
$response->assertCreated()
->assertJsonFragment(['message' => 'Song erfolgreich erstellt']);
$song = Song::where('title', 'Neues Lied')->first();
expect($song)->not->toBeNull();
// Default groups: Strophe 1, Refrain, Bridge
expect($song->groups)->toHaveCount(3);
expect($song->groups->pluck('name')->toArray())
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
// Default "Normal" arrangement
$arrangement = $song->arrangements()->where('is_default', true)->first();
expect($arrangement)->not->toBeNull();
expect($arrangement->name)->toBe('Normal');
expect($arrangement->arrangementGroups)->toHaveCount(3);
});
test('store validates required title', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
'ccli_id' => '111111',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['title']);
});
test('store validates unique ccli_id', function () {
Song::factory()->create(['ccli_id' => '555555']);
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
'title' => 'Duplicate Song',
'ccli_id' => '555555',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['ccli_id']);
});
test('store allows null ccli_id', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs', [
'title' => 'Song ohne CCLI',
]);
$response->assertCreated();
expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull();
});
// --- SHOW ---
test('show returns song with groups slides and arrangements', function () {
$song = Song::factory()->create();
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']);
SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertOk()
->assertJsonStructure([
'data' => [
'id', 'title', 'ccli_id', 'author', 'copyright_text',
'has_translation', 'last_used_in_service',
'groups' => [['id', 'name', 'color', 'order', 'slides']],
'arrangements' => [['id', 'name', 'is_default', 'arrangement_groups']],
],
]);
});
test('show returns 404 for nonexistent song', function () {
$response = $this->actingAs($this->user)
->getJson('/api/songs/99999');
$response->assertNotFound()
->assertJsonFragment(['message' => 'Song nicht gefunden']);
});
test('show returns 404 for soft-deleted song', function () {
$song = Song::factory()->create(['deleted_at' => now()]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertNotFound();
});
// --- UPDATE ---
test('update modifies song metadata', function () {
$song = Song::factory()->create(['title' => 'Old Title']);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => 'New Title',
'author' => 'New Author',
]);
$response->assertOk()
->assertJsonFragment(['message' => 'Song erfolgreich aktualisiert']);
$song->refresh();
expect($song->title)->toBe('New Title');
expect($song->author)->toBe('New Author');
});
test('update validates unique ccli_id excluding self', function () {
$songA = Song::factory()->create(['ccli_id' => '111111']);
$songB = Song::factory()->create(['ccli_id' => '222222']);
// Try setting songB's ccli_id to songA's
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$songB->id}", [
'title' => $songB->title,
'ccli_id' => '111111',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['ccli_id']);
});
test('update allows keeping own ccli_id', function () {
$song = Song::factory()->create(['ccli_id' => '333333']);
$response = $this->actingAs($this->user)
->putJson("/api/songs/{$song->id}", [
'title' => 'Updated Title',
'ccli_id' => '333333',
]);
$response->assertOk();
});
// --- DESTROY / SOFT DELETE ---
test('destroy soft-deletes a song', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->deleteJson("/api/songs/{$song->id}");
$response->assertOk()
->assertJsonFragment(['message' => 'Song erfolgreich gelöscht']);
expect(Song::find($song->id))->toBeNull();
expect(Song::withTrashed()->find($song->id))->not->toBeNull();
});
test('destroy returns 404 for nonexistent song', function () {
$response = $this->actingAs($this->user)
->deleteJson('/api/songs/99999');
$response->assertNotFound();
});
// --- LAST USED IN SERVICE ---
test('last_used_in_service returns correct date from service_songs', function () {
$song = Song::factory()->create();
$serviceOld = Service::factory()->create(['date' => '2025-06-01']);
$serviceNew = Service::factory()->create(['date' => '2026-01-15']);
ServiceSong::factory()->create([
'song_id' => $song->id,
'service_id' => $serviceOld->id,
]);
ServiceSong::factory()->create([
'song_id' => $song->id,
'service_id' => $serviceNew->id,
]);
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertOk();
expect($response->json('data.last_used_in_service'))->toBe('2026-01-15');
});
test('last_used_in_service returns null when never used', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->getJson("/api/songs/{$song->id}");
$response->assertOk();
expect($response->json('data.last_used_in_service'))->toBeNull();
});
// --- SONG SERVICE: DUPLICATE ARRANGEMENT ---
test('duplicate arrangement clones arrangement with groups', function () {
$song = Song::factory()->create();
$group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]);
$group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]);
$arrangement = SongArrangement::factory()->create([
'song_id' => $song->id,
'name' => 'Original',
'is_default' => true,
]);
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group1->id,
'order' => 1,
]);
SongArrangementGroup::factory()->create([
'song_arrangement_id' => $arrangement->id,
'song_group_id' => $group2->id,
'order' => 2,
]);
$service = app(\App\Services\SongService::class);
$clone = $service->duplicateArrangement($arrangement, 'Klone');
expect($clone->name)->toBe('Klone');
expect($clone->is_default)->toBeFalse();
expect($clone->arrangementGroups)->toHaveCount(2);
expect($clone->arrangementGroups->pluck('song_group_id')->toArray())
->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray());
});

View file

@ -0,0 +1,250 @@
<?php
use App\Mail\MissingSongRequest;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Models\User;
use App\Services\SongMatchingService;
use Illuminate\Support\Facades\Mail;
/*
|--------------------------------------------------------------------------
| SongMatchingService Unit-level feature tests
|--------------------------------------------------------------------------
*/
test('autoMatch ordnet Song per CCLI-ID zu', function () {
$song = Song::factory()->create(['ccli_id' => '7115744']);
$serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => '7115744',
'song_id' => null,
'matched_at' => null,
]);
$service = app(SongMatchingService::class);
$result = $service->autoMatch($serviceSong);
expect($result)->toBeTrue();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id);
expect($serviceSong->matched_at)->not->toBeNull();
});
test('autoMatch gibt false zurück wenn kein CCLI-ID vorhanden', function () {
$serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => null,
'song_id' => null,
'matched_at' => null,
]);
$service = app(SongMatchingService::class);
$result = $service->autoMatch($serviceSong);
expect($result)->toBeFalse();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBeNull();
});
test('autoMatch gibt false zurück wenn kein passender Song in DB', function () {
$serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => '9999999',
'song_id' => null,
'matched_at' => null,
]);
$service = app(SongMatchingService::class);
$result = $service->autoMatch($serviceSong);
expect($result)->toBeFalse();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBeNull();
});
test('autoMatch überspringt bereits zugeordnete Songs', function () {
$existingSong = Song::factory()->create(['ccli_id' => '7115744']);
$serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => '7115744',
'song_id' => $existingSong->id,
'matched_at' => now(),
]);
$service = app(SongMatchingService::class);
$result = $service->autoMatch($serviceSong);
expect($result)->toBeFalse();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($existingSong->id);
});
test('manualAssign ordnet Song manuell zu', function () {
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => null,
'matched_at' => null,
]);
$service = app(SongMatchingService::class);
$service->manualAssign($serviceSong, $song);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id);
expect($serviceSong->matched_at)->not->toBeNull();
});
test('manualAssign überschreibt bestehende Zuordnung', function () {
$oldSong = Song::factory()->create();
$newSong = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $oldSong->id,
'matched_at' => now()->subDay(),
]);
$service = app(SongMatchingService::class);
$service->manualAssign($serviceSong, $newSong);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($newSong->id);
expect($serviceSong->matched_at)->not->toBeNull();
});
test('requestCreation sendet E-Mail und setzt request_sent_at', function () {
Mail::fake();
$serviceSong = ServiceSong::factory()->create([
'cts_song_name' => 'Großer Gott',
'cts_ccli_id' => '12345678',
'request_sent_at' => null,
]);
$service = app(SongMatchingService::class);
$service->requestCreation($serviceSong);
Mail::assertSent(MissingSongRequest::class, function (MissingSongRequest $mail) {
return $mail->songName === 'Großer Gott'
&& $mail->ccliId === '12345678';
});
$serviceSong->refresh();
expect($serviceSong->request_sent_at)->not->toBeNull();
});
test('unassign entfernt Zuordnung', function () {
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'matched_at' => now(),
]);
$service = app(SongMatchingService::class);
$service->unassign($serviceSong);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBeNull();
expect($serviceSong->matched_at)->toBeNull();
});
/*
|--------------------------------------------------------------------------
| ServiceSongController API endpoint tests
|--------------------------------------------------------------------------
*/
test('POST /api/service-songs/{id}/assign ordnet Song zu', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => null,
'matched_at' => null,
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/assign", [
'song_id' => $song->id,
]);
$response->assertOk()
->assertJson(['message' => 'Song erfolgreich zugeordnet']);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id);
});
test('POST /api/service-songs/{id}/assign validiert song_id', function () {
$user = User::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => null,
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/assign", [
'song_id' => 99999,
]);
$response->assertUnprocessable();
});
test('POST /api/service-songs/{id}/request sendet Anfrage-E-Mail', function () {
Mail::fake();
$user = User::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'cts_song_name' => 'Way Maker',
'cts_ccli_id' => '7115744',
'request_sent_at' => null,
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/request");
$response->assertOk()
->assertJson(['message' => 'Anfrage wurde gesendet']);
Mail::assertSent(MissingSongRequest::class);
$serviceSong->refresh();
expect($serviceSong->request_sent_at)->not->toBeNull();
});
test('POST /api/service-songs/{id}/unassign entfernt Zuordnung', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$serviceSong = ServiceSong::factory()->create([
'song_id' => $song->id,
'matched_at' => now(),
]);
$response = $this->actingAs($user)
->postJson("/api/service-songs/{$serviceSong->id}/unassign");
$response->assertOk()
->assertJson(['message' => 'Zuordnung entfernt']);
$serviceSong->refresh();
expect($serviceSong->song_id)->toBeNull();
expect($serviceSong->matched_at)->toBeNull();
});
test('API Endpunkte erfordern Authentifizierung', function () {
$serviceSong = ServiceSong::factory()->create();
$this->postJson("/api/service-songs/{$serviceSong->id}/assign", ['song_id' => 1])
->assertUnauthorized();
$this->postJson("/api/service-songs/{$serviceSong->id}/request")
->assertUnauthorized();
$this->postJson("/api/service-songs/{$serviceSong->id}/unassign")
->assertUnauthorized();
});
test('API gibt 404 für nicht existierende ServiceSong', function () {
$user = User::factory()->create();
$song = Song::factory()->create();
$this->actingAs($user)
->postJson('/api/service-songs/99999/assign', ['song_id' => $song->id])
->assertNotFound();
});

View file

@ -0,0 +1,376 @@
<?php
use App\Models\Song;
use App\Models\SongGroup;
use App\Models\SongSlide;
use App\Models\User;
use App\Services\TranslationService;
use Illuminate\Support\Facades\Http;
/*
|--------------------------------------------------------------------------
| Translation Service Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->user = User::factory()->create();
$this->service = app(TranslationService::class);
});
// --- URL FETCH ---
test('fetchFromUrl returns text from successful HTTP response', function () {
Http::fake([
'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200),
]);
$result = $this->service->fetchFromUrl('https://example.com/lyrics');
expect($result)->not->toBeNull();
expect($result)->toContain('Zeile 1');
expect($result)->toContain('Zeile 2');
// HTML tags should be stripped
expect($result)->not->toContain('<p>');
expect($result)->not->toContain('<html>');
});
test('fetchFromUrl returns null on HTTP failure', function () {
Http::fake([
'https://example.com/broken' => Http::response('Not Found', 404),
]);
$result = $this->service->fetchFromUrl('https://example.com/broken');
expect($result)->toBeNull();
});
test('fetchFromUrl returns null on connection error', function () {
Http::fake([
'https://timeout.example.com/*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Timeout'),
]);
$result = $this->service->fetchFromUrl('https://timeout.example.com/lyrics');
expect($result)->toBeNull();
});
test('fetchFromUrl returns null for empty response body', function () {
Http::fake([
'https://example.com/empty' => Http::response('', 200),
]);
$result = $this->service->fetchFromUrl('https://example.com/empty');
expect($result)->toBeNull();
});
// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) ---
test('importTranslation distributes lines by slide line counts', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
]);
// Slide 1: 4 lines
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
]);
// Slide 2: 2 lines
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 2,
'text_content' => "Original 5\nOriginal 6",
]);
// Slide 3: 4 lines
$slide3 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 3,
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
]);
$translatedText = "Zeile 1\nZeile 2\nZeile 3\nZeile 4\nZeile 5\nZeile 6\nZeile 7\nZeile 8\nZeile 9\nZeile 10";
$this->service->importTranslation($song, $translatedText);
$slide1->refresh();
$slide2->refresh();
$slide3->refresh();
// Slide 1 gets lines 1-4
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4");
// Slide 2 gets lines 5-6
expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6");
// Slide 3 gets lines 7-10
expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10");
});
test('importTranslation distributes across multiple groups', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group1 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Strophe 1',
'order' => 1,
]);
$group2 = SongGroup::factory()->create([
'song_id' => $song->id,
'name' => 'Refrain',
'order' => 2,
]);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group1->id,
'order' => 1,
'text_content' => "Line A\nLine B",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group2->id,
'order' => 1,
'text_content' => "Line C\nLine D\nLine E",
]);
$translatedText = "Über A\nÜber B\nÜber C\nÜber D\nÜber E";
$this->service->importTranslation($song, $translatedText);
$slide1->refresh();
$slide2->refresh();
expect($slide1->text_content_translated)->toBe("Über A\nÜber B");
expect($slide2->text_content_translated)->toBe("Über C\nÜber D\nÜber E");
});
test('importTranslation handles fewer translation lines than original', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Line 1\nLine 2\nLine 3",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 2,
'text_content' => "Line 4\nLine 5",
]);
// Only 3 lines for 5 lines total
$translatedText = "Zeile 1\nZeile 2\nZeile 3";
$this->service->importTranslation($song, $translatedText);
$slide1->refresh();
$slide2->refresh();
// Slide 1 gets all 3 available lines
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3");
// Slide 2 gets empty (no lines left)
expect($slide2->text_content_translated)->toBe('');
});
test('importTranslation marks song as translated', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Line 1",
]);
$this->service->importTranslation($song, "Zeile 1");
$song->refresh();
expect($song->has_translation)->toBeTrue();
});
// --- MARK AS TRANSLATED ---
test('markAsTranslated sets has_translation to true', function () {
$song = Song::factory()->create(['has_translation' => false]);
$this->service->markAsTranslated($song);
$song->refresh();
expect($song->has_translation)->toBeTrue();
});
// --- REMOVE TRANSLATION ---
test('removeTranslation clears all translated text and sets flag to false', function () {
$song = Song::factory()->create(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$slide1 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Original",
'text_content_translated' => "Übersetzt",
]);
$slide2 = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 2,
'text_content' => "Original 2",
'text_content_translated' => "Übersetzt 2",
]);
$this->service->removeTranslation($song);
$song->refresh();
$slide1->refresh();
$slide2->refresh();
expect($song->has_translation)->toBeFalse();
expect($slide1->text_content_translated)->toBeNull();
expect($slide2->text_content_translated)->toBeNull();
});
// --- CONTROLLER ENDPOINTS ---
test('POST translation/fetch-url returns scraped text', function () {
Http::fake([
'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200),
]);
$response = $this->actingAs($this->user)
->postJson('/api/translation/fetch-url', [
'url' => 'https://lyrics.example.com/song',
]);
$response->assertOk()
->assertJsonStructure(['text']);
expect($response->json('text'))->toContain('Liedtext Zeile 1');
});
test('POST translation/fetch-url returns error on failure', function () {
Http::fake([
'https://broken.example.com/*' => Http::response('', 500),
]);
$response = $this->actingAs($this->user)
->postJson('/api/translation/fetch-url', [
'url' => 'https://broken.example.com/song',
]);
$response->assertStatus(422)
->assertJsonFragment(['message' => 'Konnte Text nicht abrufen']);
});
test('POST translation/fetch-url validates url field', function () {
$response = $this->actingAs($this->user)
->postJson('/api/translation/fetch-url', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['url']);
});
test('POST songs/{song}/translation/import distributes and saves translation', function () {
$song = Song::factory()->create(['has_translation' => false]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
$slide = SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Line 1\nLine 2",
]);
$response = $this->actingAs($this->user)
->postJson("/api/songs/{$song->id}/translation/import", [
'text' => "Zeile 1\nZeile 2",
]);
$response->assertOk()
->assertJsonFragment(['message' => 'Übersetzung erfolgreich importiert']);
$slide->refresh();
$song->refresh();
expect($slide->text_content_translated)->toBe("Zeile 1\nZeile 2");
expect($song->has_translation)->toBeTrue();
});
test('POST songs/{song}/translation/import validates text field', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->postJson("/api/songs/{$song->id}/translation/import", []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['text']);
});
test('POST songs/{song}/translation/import returns 404 for missing song', function () {
$response = $this->actingAs($this->user)
->postJson('/api/songs/99999/translation/import', [
'text' => 'Some text',
]);
$response->assertNotFound();
});
test('DELETE songs/{song}/translation removes translation', function () {
$song = Song::factory()->create(['has_translation' => true]);
$group = SongGroup::factory()->create([
'song_id' => $song->id,
'order' => 1,
]);
SongSlide::factory()->create([
'song_group_id' => $group->id,
'order' => 1,
'text_content' => "Original",
'text_content_translated' => "Übersetzt",
]);
$response = $this->actingAs($this->user)
->deleteJson("/api/songs/{$song->id}/translation");
$response->assertOk()
->assertJsonFragment(['message' => 'Übersetzung entfernt']);
$song->refresh();
expect($song->has_translation)->toBeFalse();
});
test('translation endpoints require authentication', function () {
$this->postJson('/api/translation/fetch-url', ['url' => 'https://example.com'])
->assertUnauthorized();
$this->postJson('/api/songs/1/translation/import', ['text' => 'test'])
->assertUnauthorized();
$this->deleteJson('/api/songs/1/translation')
->assertUnauthorized();
});