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:
parent
c7f5845b80
commit
4c119b647d
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ public function definition(): array
|
|||
'id' => $this->faker->uuid(),
|
||||
'name' => $this->faker->sentence(3),
|
||||
],
|
||||
'has_agenda' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue