feat: add has_agenda flag to services and guard agenda sync

Events without an agenda in ChurchTools now gracefully set has_agenda=false
instead of throwing errors during sync. The edit/finalize buttons are
disabled in the frontend for services without an agenda.

Also fixes missing cts_song_id column on service_songs table.
This commit is contained in:
Thorsten Bus 2026-03-29 15:22:32 +02:00
parent c7f5845b80
commit 4c119b647d
10 changed files with 244 additions and 11 deletions

View file

@ -112,6 +112,7 @@ public function index(): Response
'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();
@ -234,6 +235,7 @@ public function edit(Service $service): Response
'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,

View file

@ -21,6 +21,7 @@ class Service extends Model
'finalized_at',
'last_synced_at',
'cts_data',
'has_agenda',
];
protected function casts(): array
@ -30,6 +31,7 @@ protected function casts(): array
'finalized_at' => 'datetime',
'last_synced_at' => 'datetime',
'cts_data' => 'array',
'has_agenda' => 'boolean',
];
}

View file

@ -5,6 +5,9 @@
use App\Models\ApiRequestLog;
use Closure;
use CTApi\CTConfig;
use CTApi\Exceptions\CTConfigException;
use CTApi\Exceptions\CTConnectException;
use CTApi\Exceptions\CTPermissionException;
use CTApi\Models\Events\Event\EventAgendaRequest;
use CTApi\Models\Events\Event\EventRequest;
use CTApi\Models\Events\Song\SongRequest;
@ -47,6 +50,16 @@ public function sync(): array
return $summary;
} catch (Throwable $exception) {
$errorCategory = $this->classifyError($exception);
Log::error('CTS Sync fehlgeschlagen', [
'kategorie' => $errorCategory,
'nachricht' => $exception->getMessage(),
'exception_klasse' => $exception::class,
'dauer_sekunden' => $startedAt->diffInSeconds(Carbon::now()),
'trace' => Str::limit($exception->getTraceAsString(), 2000),
]);
$summary = [
'services_count' => 0,
'songs_count' => 0,
@ -56,7 +69,7 @@ public function sync(): array
'catalog_songs_count' => 0,
];
$this->writeSyncLog('error', $summary, $exception->getMessage(), $startedAt, Carbon::now());
$this->writeSyncLog('error', $summary, '['.$errorCategory.'] '.$exception->getMessage(), $startedAt, Carbon::now());
throw $exception;
}
@ -234,13 +247,24 @@ private function logApiCall(string $method, string $endpoint, Closure $operation
return $result;
} catch (\Throwable $e) {
$duration = (int) ((microtime(true) - $start) * 1000);
$errorCategory = $this->classifyError($e);
Log::error('CTS API Fehler bei '.$method, [
'endpoint' => $endpoint,
'kategorie' => $errorCategory,
'nachricht' => $e->getMessage(),
'exception_klasse' => $e::class,
'dauer_ms' => $duration,
'kontext' => $context,
'trace' => Str::limit($e->getTraceAsString(), 2000),
]);
ApiRequestLog::create([
'method' => $method,
'endpoint' => $endpoint,
'status' => 'error',
'request_context' => $context,
'error_message' => $e->getMessage(),
'error_message' => '['.$errorCategory.'] '.$e->getMessage(),
'duration_ms' => $duration,
]);
@ -293,10 +317,24 @@ private function configureApi(): void
$apiUrl = (string) Config::get('services.churchtools.url', '');
$apiToken = (string) Config::get('services.churchtools.api_token', '');
if ($apiUrl !== '') {
CTConfig::setApiUrl(rtrim($apiUrl, '/'));
Log::debug('CTS API Konfiguration', [
'url_gesetzt' => $apiUrl !== '',
'url' => $apiUrl !== '' ? $apiUrl : '(leer)',
'token_gesetzt' => $apiToken !== '',
'token_laenge' => mb_strlen($apiToken),
]);
if ($apiUrl === '') {
Log::error('CTS API URL fehlt. Bitte CTS_API_URL in der .env Datei setzen.');
throw new \RuntimeException('CTS API URL ist nicht konfiguriert. Bitte CTS_API_URL in der .env Datei setzen.');
}
if ($apiToken === '') {
Log::error('CTS API Token fehlt. Bitte CTS_API_TOKEN in der .env Datei setzen.');
throw new \RuntimeException('CTS API Token ist nicht konfiguriert. Bitte CTS_API_TOKEN in der .env Datei setzen.');
}
CTConfig::setApiUrl(rtrim($apiUrl, '/'));
CTConfig::setApiKey($apiToken);
$this->apiConfigured = true;
@ -333,7 +371,31 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
private function syncServiceAgendaItems(int $serviceId, int $eventId): array
{
$agenda = $this->syncAgenda($eventId);
try {
$agenda = $this->syncAgenda($eventId);
} catch (CTRequestException $e) {
if (str_contains(Str::lower($e->getMessage()), 'not found')) {
DB::table('services')
->where('id', $serviceId)
->update(['has_agenda' => false]);
Log::info('Event hat keine Agenda', ['event_id' => $eventId]);
return [
'songs_count' => 0,
'matched_songs_count' => 0,
'unmatched_songs_count' => 0,
'agenda_items_count' => 0,
];
}
throw $e;
}
DB::table('services')
->where('id', $serviceId)
->update(['has_agenda' => true]);
$agendaItems = method_exists($agenda, 'getItems') ? $agenda->getItems() : [];
$summary = [
@ -531,6 +593,47 @@ private function normalizeCcli(?string $ccli): ?string
return $value === '' ? null : $value;
}
private function classifyError(Throwable $e): string
{
if ($e instanceof CTPermissionException) {
return 'authentifizierung';
}
if ($e instanceof CTConnectException) {
return 'verbindung';
}
if ($e instanceof CTConfigException) {
return 'konfiguration';
}
$message = Str::lower($e->getMessage());
if (str_contains($message, 'token') || str_contains($message, '401') || str_contains($message, 'unauthorized')) {
return 'authentifizierung';
}
if (str_contains($message, 'could not resolve') || str_contains($message, 'connection refused') || str_contains($message, 'timed out')) {
return 'verbindung';
}
if (str_contains($message, '403') || str_contains($message, 'forbidden')) {
return 'berechtigung';
}
if (str_contains($message, '404') || str_contains($message, 'not found')) {
return 'nicht_gefunden';
}
if (str_contains($message, '5')) {
if (preg_match('/\b5\d{2}\b/', $message)) {
return 'server_fehler';
}
}
return 'unbekannt';
}
private function writeSyncLog(string $status, array $summary, ?string $message, mixed $startedAt, mixed $finishedAt): void
{
DB::table('cts_sync_log')->insert([

View file

@ -25,6 +25,7 @@ public function definition(): array
'id' => $this->faker->uuid(),
'name' => $this->faker->sentence(3),
],
'has_agenda' => true,
];
}
}

View file

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumn('service_songs', 'cts_song_id')) {
Schema::table('service_songs', function (Blueprint $table) {
$table->string('cts_song_id')->nullable()->after('cts_ccli_id');
});
}
}
public function down(): void
{
Schema::table('service_songs', function (Blueprint $table) {
$table->dropColumn('cts_song_id');
});
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('services', function (Blueprint $table) {
$table->boolean('has_agenda')->default(false)->after('cts_data');
});
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropColumn('has_agenda');
});
}
};

View file

@ -320,6 +320,11 @@ function stateIconClass(isDone) {
<td class="px-4 py-4">
<div class="space-y-1.5 text-xs font-medium">
<div v-if="!service.has_agenda" class="flex items-center gap-1.5 text-gray-400">
<span aria-hidden="true"></span>
<span>Keine Agenda</span>
</div>
<div :class="mappingStatusClass(service)">
{{ service.songs_mapped_count }}/{{ service.songs_total_count }} Songs zugeordnet
</div>
@ -390,17 +395,30 @@ function stateIconClass(isDone) {
<button
data-testid="service-list-edit-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="router.get(route('services.edit', service.id))"
:class="[
'inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-xs font-semibold transition',
service.has_agenda
? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
: 'cursor-not-allowed border-gray-200 bg-gray-100 text-gray-400',
]"
:disabled="!service.has_agenda"
:title="!service.has_agenda ? 'Keine Agenda vorhanden' : ''"
@click="service.has_agenda && router.get(route('services.edit', service.id))"
>
Bearbeiten
</button>
<button
data-testid="service-list-finalize-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"
:disabled="finalizing"
@click="finalizeService(service.id)"
:class="[
'inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-xs font-semibold transition',
service.has_agenda
? 'border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100'
: 'cursor-not-allowed border-gray-200 bg-gray-100 text-gray-400',
]"
:disabled="finalizing || !service.has_agenda"
:title="!service.has_agenda ? 'Keine Agenda vorhanden' : ''"
@click="service.has_agenda && finalizeService(service.id)"
>
Abschließen
</button>

View file

@ -321,6 +321,62 @@
expect($summary['services_count'])->toBe(1);
});
test('event ohne agenda setzt has_agenda auf false und ueberspringt agenda sync', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 893, title: 'Service ohne Agenda', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn (int $eventId) => throw new CTRequestException(
"Agenda for event [{$eventId}] not found."
),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '893')->first();
expect($service)->not->toBeNull();
expect($service->title)->toBe('Service ohne Agenda');
expect((bool) $service->has_agenda)->toBeFalse();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(0);
$serviceSongs = DB::table('service_songs')->where('service_id', $service->id)->get();
expect($serviceSongs)->toHaveCount(0);
$syncLog = DB::table('cts_sync_log')->latest('id')->first();
expect($syncLog)->not->toBeNull();
expect($syncLog->status)->toBe('success');
});
test('event mit agenda setzt has_agenda auf true', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 894, title: 'Service mit Agenda', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 90, position: '1', title: 'Begrüssung', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '894')->first();
expect($service)->not->toBeNull();
expect((bool) $service->has_agenda)->toBeTrue();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(1);
});
test('responsible feld wird als json gespeichert', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
@ -362,6 +418,7 @@ function ensureSyncTables(): void
$table->string('beamer_tech_name')->nullable();
$table->timestamp('last_synced_at')->nullable();
$table->json('cts_data')->nullable();
$table->boolean('has_agenda')->default(false);
$table->timestamps();
});
}

View file

@ -148,6 +148,7 @@ public function test_services_index_zeigt_nur_heutige_und_kuenftige_services_mit
->where('services.0.has_sermon_slides', true)
->where('services.0.info_slides_count', 3)
->where('services.0.agenda_slides_count', 0)
->where('services.0.has_agenda', true)
->where('services.1.id', $futureService->id)
->where('services.1.title', 'Gottesdienst Zukunft')
->where('services.1.songs_total_count', 2)

View file

@ -150,7 +150,7 @@
// Build zip
$zipPath = $tempDir.'/slides.zip';
$zip = new ZipArchive();
$zip = new ZipArchive;
$zip->open($zipPath, ZipArchive::CREATE);
$zip->addFile($imgPath, 'slide1.png');
$zip->close();