pp-planer/app/Http/Controllers/ServiceController.php
Thorsten Bus 0e3c647cfc feat: probundle export with media, image upscaling, upload dimension warnings
- Fix probundle exports missing images (double slides/ prefix in storage path)
- Replace manual ZipArchive with PresentationBundle + ProBundleWriter from parser plugin
- Add per-agenda-item download route and buttons for songs and slide items
- Remove text layer from image-only slides in .pro generation
- Fix image conversion: upscale small images, black bars on 2 sides max (contain)
- Add upload warnings for non-16:9 and sub-1920x1080 images (German, non-blocking)
- Update SlideFactory and all tests to use slides/ prefix in stored_filename
- Add 11 new tests (agenda download, image conversion, upload warnings)
2026-03-30 10:29:37 +02:00

416 lines
17 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use App\Models\Song;
use App\Services\AgendaMatcherService;
use App\Services\ProBundleExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ServiceController extends Controller
{
public function index(): Response
{
$archived = request()->boolean('archived');
$query = Service::query();
if ($archived) {
$query->whereDate('date', '<', Carbon::today())
->orderByDesc('date');
} else {
$query->whereDate('date', '>=', Carbon::today())
->orderBy('date');
}
$sermonPatterns = Setting::get('agenda_sermon_matching');
$matcher = $sermonPatterns ? app(AgendaMatcherService::class) : null;
$services = $query
->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')
->whereNull('slides.deleted_at')
->where(function ($query) {
$query
->whereNull('slides.service_id')
->orWhereColumn('slides.service_id', 'services.id');
})
->where(function ($query) {
$query->whereNull('slides.expire_date')
->orWhereColumn('slides.expire_date', '>=', 'services.date');
})
->when(
! $archived,
fn ($q) => $q
->whereColumn(DB::raw('DATE(slides.uploaded_at)'), '<=', 'services.date')
),
'agenda_slides_count' => ServiceAgendaItem::query()
->selectRaw('COUNT(*)')
->whereColumn('service_agenda_items.service_id', 'services.id')
->whereNull('service_agenda_items.service_song_id')
->whereExists(
Slide::query()
->selectRaw('1')
->whereColumn('slides.service_agenda_item_id', 'service_agenda_items.id')
->whereNull('slides.deleted_at')
),
])
->with(['agendaItems' => fn ($q) => $q->whereNull('service_song_id')])
->with(['agendaItems.slides' => fn ($q) => $q->whereNull('deleted_at')])
->get()
->map(function (Service $service) use ($matcher, $sermonPatterns) {
// Determine sermon slides status
$hasSermonSlides = (bool) $service->has_sermon_slides;
if ($matcher && $sermonPatterns) {
$sermonItems = $service->agendaItems->filter(
fn (ServiceAgendaItem $item) => $matcher->matchesAny(
$item->title,
array_map('trim', explode(',', $sermonPatterns))
)
);
if ($sermonItems->isNotEmpty()) {
$hasSermonSlides = $sermonItems->contains(
fn (ServiceAgendaItem $item) => $item->slides->isNotEmpty()
);
}
}
return [
'id' => $service->id,
'cts_event_id' => $service->cts_event_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' => $hasSermonSlides,
'info_slides_count' => (int) $service->info_slides_count,
'agenda_slides_count' => (int) $service->agenda_slides_count,
'has_agenda' => (bool) $service->has_agenda,
];
})
->values();
return Inertia::render('Services/Index', [
'services' => $services,
'archived' => $archived,
]);
}
public function edit(Service $service): Response
{
$service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'),
'serviceSongs.song',
'serviceSongs.song.groups',
'serviceSongs.song.arrangements.arrangementGroups.group',
'serviceSongs.arrangement',
'slides',
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
'agendaItems.slides',
'agendaItems.serviceSong.song.groups.slides',
'agendaItems.serviceSong.song.arrangements.arrangementGroups.group',
'agendaItems.serviceSong.arrangement.arrangementGroups.group',
]);
$songsCatalog = Song::query()
->orderBy('title')
->get(['id', 'title', 'ccli_id', 'has_translation'])
->map(fn (Song $song) => [
'id' => $song->id,
'title' => $song->title,
'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation,
])
->values();
$informationSlides = Slide::query()
->where('type', 'information')
->whereNull('deleted_at')
->where(function ($query) use ($service) {
$query
->whereNull('service_id')
->orWhere('service_id', $service->id);
})
->when(
$service->date,
fn ($query) => $query
->where(function ($q) use ($service) {
$q->whereNull('expire_date')
->orWhereDate('expire_date', '>=', $service->date);
})
->when(
$service->date->isFuture() || $service->date->isToday(),
fn ($q) => $q->whereDate('uploaded_at', '<=', $service->date)
)
)
->orderByDesc('uploaded_at')
->get();
$moderationSlides = $service->slides
->where('type', 'moderation')
->sortByDesc('uploaded_at')
->values();
$sermonSlides = $service->slides
->where('type', 'sermon')
->sortByDesc('uploaded_at')
->values();
$prevService = Service::where('date', '<', $service->date->toDateString())
->orderByDesc('date')
->first(['id', 'title', 'date']);
$nextService = Service::where('date', '>', $service->date->toDateString())
->orderBy('date')
->first(['id', 'title', 'date']);
// Load agenda settings
$agendaSettings = [
'start_title' => Setting::get('agenda_start_title'),
'end_title' => Setting::get('agenda_end_title'),
'announcement_position' => Setting::get('agenda_announcement_position'),
'sermon_matching' => Setting::get('agenda_sermon_matching'),
];
// Filter agenda items (exclude before-event, apply boundary filtering)
$matcher = app(AgendaMatcherService::class);
$agendaItemsArray = $service->agendaItems
->filter(fn ($i) => ! $i->is_before_event)
->values()
->all();
$filteredItems = $matcher->filterBetween(
$agendaItemsArray,
$agendaSettings['start_title'],
$agendaSettings['end_title']
);
// Add computed flags to each agenda item
$announcementPatterns = $agendaSettings['announcement_position'];
$sermonPatterns = $agendaSettings['sermon_matching'];
$agendaItemsMapped = array_map(function ($item) use ($matcher, $announcementPatterns, $sermonPatterns) {
$arr = $item->toArray();
$arr['is_announcement_position'] = $announcementPatterns
? $matcher->matchesAny($item->title, array_map('trim', explode(',', $announcementPatterns)))
: false;
$arr['is_sermon'] = $sermonPatterns
? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns)))
: false;
return $arr;
}, $filteredItems);
return Inertia::render('Services/Edit', [
'service' => [
'id' => $service->id,
'title' => $service->title,
'date' => $service->date?->toDateString(),
'preacher_name' => $service->preacher_name,
'beamer_tech_name' => $service->beamer_tech_name,
'finalized_at' => $service->finalized_at?->toJSON(),
'last_synced_at' => $service->last_synced_at?->toJSON(),
'has_agenda' => $service->has_agenda,
],
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
'id' => $ss->id,
'order' => $ss->order,
'cts_song_name' => $ss->cts_song_name,
'cts_ccli_id' => $ss->cts_ccli_id,
'use_translation' => $ss->use_translation,
'song_id' => $ss->song_id,
'song_arrangement_id' => $ss->song_arrangement_id,
'matched_at' => $ss->matched_at?->toJSON(),
'request_sent_at' => $ss->request_sent_at?->toJSON(),
'song' => $ss->song ? [
'id' => $ss->song->id,
'title' => $ss->song->title,
'ccli_id' => $ss->song->ccli_id,
'has_translation' => $ss->song->has_translation,
'groups' => $ss->song->groups
->sortBy('order')
->values()
->map(fn ($group) => [
'id' => $group->id,
'name' => $group->name,
'color' => $group->color,
'order' => $group->order,
]),
'arrangements' => $ss->song->arrangements
->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
->values()
->map(fn ($arrangement) => [
'id' => $arrangement->id,
'name' => $arrangement->name,
'is_default' => $arrangement->is_default,
'groups' => $arrangement->arrangementGroups
->sortBy('order')
->values()
->map(fn ($arrangementGroup) => [
'id' => $arrangementGroup->group?->id,
'name' => $arrangementGroup->group?->name,
'color' => $arrangementGroup->group?->color,
])
->filter(fn ($group) => $group['id'] !== null)
->values(),
]),
] : null,
'arrangement' => $ss->arrangement ? [
'id' => $ss->arrangement->id,
'name' => $ss->arrangement->name,
] : null,
]),
'informationSlides' => $informationSlides,
'moderationSlides' => $moderationSlides,
'sermonSlides' => $sermonSlides,
'songsCatalog' => $songsCatalog,
'agendaItems' => $agendaItemsMapped,
'agendaSettings' => $agendaSettings,
'prevService' => $prevService ? [
'id' => $prevService->id,
'title' => $prevService->title,
'date' => $prevService->date?->toDateString(),
] : null,
'nextService' => $nextService ? [
'id' => $nextService->id,
'title' => $nextService->title,
'date' => $nextService->date?->toDateString(),
] : null,
]);
}
public function finalize(Service $service): JsonResponse
{
$status = $service->finalizationStatus();
$confirmed = request()->boolean('confirmed');
if (! $status['ready'] && ! $confirmed) {
return response()->json([
'needs_confirmation' => true,
'warnings' => $status['warnings'],
]);
}
$service->update([
'finalized_at' => now(),
]);
return response()->json([
'needs_confirmation' => false,
'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.');
}
public function destroy(Service $service): RedirectResponse
{
$service->serviceSongs()->delete();
$service->slides()->where('type', '!=', 'information')->delete();
$service->delete();
return redirect()
->route('services.index')
->with('success', 'Service wurde gelöscht und wird beim nächsten Sync neu erstellt.');
}
public function download(Service $service): JsonResponse|BinaryFileResponse
{
if (! $service->finalized_at) {
abort(403, 'Nur abgeschlossene Services können heruntergeladen werden.');
}
try {
$playlistService = new \App\Services\PlaylistExportService;
$result = $playlistService->generatePlaylist($service);
$response = response()->download($result['path'], $result['filename']);
if ($result['skipped'] > 0) {
$response->headers->set('X-Skipped-Songs', (string) $result['skipped']);
}
return $response->deleteFileAfterSend(false);
} catch (\RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse
{
$request->merge(['blockType' => $blockType]);
$request->validate([
'blockType' => 'required|in:information,moderation,sermon',
]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
return response()
->download(
$bundlePath,
"{$service->id}_{$blockType}.probundle",
['Content-Type' => 'application/zip']
)
->deleteFileAfterSend(true);
}
public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaItem): JsonResponse|BinaryFileResponse
{
if ((int) $agendaItem->service_id !== (int) $service->id) {
abort(404);
}
try {
$bundlePath = app(ProBundleExportService::class)->generateAgendaItemBundle($agendaItem);
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $agendaItem->title ?: 'element');
return response()
->download(
$bundlePath,
$safeTitle.'.probundle',
['Content-Type' => 'application/zip']
)
->deleteFileAfterSend(true);
}
}