- Update propresenter ref path from ../propresenter-work/ to ../propresenter/ - Fix ProFileImportTest assertion for CCLI-based upsert behavior - Replace Mockery alias mocks with testable subclass pattern in PlaylistExportTest, eliminating @runInSeparateProcess requirement - Use DI (app()) for PlaylistExportService in controller for testability - All 302 tests pass (was 285 pass + 17 fail)
416 lines
17 KiB
PHP
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 = app(\App\Services\PlaylistExportService::class);
|
|
$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);
|
|
}
|
|
}
|