feat(sync): sync all CTS agenda items (not just songs)

This commit is contained in:
Thorsten Bus 2026-03-29 11:54:50 +02:00
parent 0b671956d6
commit 88661c6bef
2 changed files with 495 additions and 34 deletions

View file

@ -39,6 +39,7 @@ public function sync(): array
'songs_count' => $eventsSummary['songs_count'],
'matched_songs_count' => $eventsSummary['matched_songs_count'],
'unmatched_songs_count' => $eventsSummary['unmatched_songs_count'],
'agenda_items_count' => $eventsSummary['agenda_items_count'],
'catalog_songs_count' => $songsCount,
];
@ -51,6 +52,7 @@ public function sync(): array
'songs_count' => 0,
'matched_songs_count' => 0,
'unmatched_songs_count' => 0,
'agenda_items_count' => 0,
'catalog_songs_count' => 0,
];
@ -69,6 +71,7 @@ public function syncEvents(): array
'songs_count' => 0,
'matched_songs_count' => 0,
'unmatched_songs_count' => 0,
'agenda_items_count' => 0,
];
foreach ($events as $event) {
@ -87,6 +90,7 @@ public function syncEvents(): array
$summary['songs_count'] += $songSummary['songs_count'];
$summary['matched_songs_count'] += $songSummary['matched_songs_count'];
$summary['unmatched_songs_count'] += $songSummary['unmatched_songs_count'];
$summary['agenda_items_count'] += $songSummary['agenda_items_count'] ?? 0;
} catch (\Throwable $e) {
Log::warning('Sync-Fehler für Event', [
'event_id' => $event->getId() ?? 'unknown',
@ -321,64 +325,129 @@ private function upsertService(mixed $event, array $serviceRoles): object
->firstOrFail();
}
/** @deprecated Use syncServiceAgendaItems() instead. */
private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
{
return $this->syncServiceAgendaItems($serviceId, $eventId);
}
private function syncServiceAgendaItems(int $serviceId, int $eventId): array
{
$agenda = $this->syncAgenda($eventId);
$agendaSongs = method_exists($agenda, 'getSongs') ? $agenda->getSongs() : [];
$agendaItems = method_exists($agenda, 'getItems') ? $agenda->getItems() : [];
$summary = [
'songs_count' => 0,
'matched_songs_count' => 0,
'unmatched_songs_count' => 0,
'agenda_items_count' => 0,
];
$songMatchingService = app(SongMatchingService::class);
$processedSortOrders = [];
foreach ($agendaSongs as $index => $song) {
$ctsSongId = (string) ($song->getId() ?? '');
if ($ctsSongId === '') {
foreach ($agendaItems as $index => $item) {
if (method_exists($item, 'getIsBeforeEvent') && $item->getIsBeforeEvent()) {
continue;
}
$ccliId = $this->normalizeCcli($song->getCcli() ?? null);
$sortOrder = $index + 1;
$processedSortOrders[] = $sortOrder;
DB::table('service_songs')->updateOrInsert(
$responsible = method_exists($item, 'getResponsible') ? $item->getResponsible() : null;
DB::table('service_agenda_items')->updateOrInsert(
[
'service_id' => $serviceId,
'order' => $index + 1,
'sort_order' => $sortOrder,
],
[
'cts_song_name' => (string) ($song->getName() ?? ''),
'cts_ccli_id' => $ccliId,
'cts_song_id' => $ctsSongId,
'cts_agenda_item_id' => method_exists($item, 'getId') ? (string) ($item->getId() ?? '') : null,
'position' => (string) (method_exists($item, 'getPosition') ? ($item->getPosition() ?? '') : ''),
'title' => (string) (method_exists($item, 'getTitle') ? ($item->getTitle() ?? '') : ''),
'type' => (string) (method_exists($item, 'getType') ? ($item->getType() ?? '') : ''),
'note' => method_exists($item, 'getNote') ? $item->getNote() : null,
'duration' => method_exists($item, 'getDuration') ? $item->getDuration() : null,
'start' => method_exists($item, 'getStart') ? $item->getStart() : null,
'is_before_event' => false,
'responsible' => $responsible !== null ? json_encode($responsible, JSON_THROW_ON_ERROR) : null,
'updated_at' => Carbon::now(),
'created_at' => Carbon::now(),
]
);
$serviceSong = \App\Models\ServiceSong::where('service_id', $serviceId)
->where('order', $index + 1)
->first();
$song = method_exists($item, 'getSong') ? $item->getSong() : null;
$matched = false;
if ($song !== null) {
$ctsSongId = (string) ($song->getId() ?? '');
if ($ctsSongId !== '') {
$ccliId = $this->normalizeCcli($song->getCcli() ?? null);
if (
$serviceSong !== null
&& ! $serviceSong->song_id
&& ($serviceSong->cts_ccli_id || $serviceSong->cts_song_id)
) {
$matched = $songMatchingService->autoMatch($serviceSong);
} elseif ($serviceSong !== null && $serviceSong->song_id) {
$matched = true;
DB::table('service_songs')->updateOrInsert(
[
'service_id' => $serviceId,
'order' => $sortOrder,
],
[
'cts_song_name' => (string) ($song->getName() ?? ''),
'cts_ccli_id' => $ccliId,
'cts_song_id' => $ctsSongId,
'updated_at' => Carbon::now(),
'created_at' => Carbon::now(),
]
);
$serviceSong = \App\Models\ServiceSong::where('service_id', $serviceId)
->where('order', $sortOrder)
->first();
if ($serviceSong) {
DB::table('service_agenda_items')
->where('service_id', $serviceId)
->where('sort_order', $sortOrder)
->update(['service_song_id' => $serviceSong->id]);
}
$matched = false;
if (
$serviceSong !== null
&& ! $serviceSong->song_id
&& ($serviceSong->cts_ccli_id || $serviceSong->cts_song_id)
) {
$matched = $songMatchingService->autoMatch($serviceSong);
} elseif ($serviceSong !== null && $serviceSong->song_id) {
$matched = true;
}
$summary['songs_count']++;
if ($matched) {
$summary['matched_songs_count']++;
} else {
$summary['unmatched_songs_count']++;
}
}
}
$summary['songs_count']++;
$summary['agenda_items_count']++;
}
if ($matched) {
$summary['matched_songs_count']++;
} else {
$summary['unmatched_songs_count']++;
}
// Clean up orphaned agenda items: first null-ify slide FKs, then delete
$orphanedIds = DB::table('service_agenda_items')
->where('service_id', $serviceId)
->whereNotIn('sort_order', $processedSortOrders)
->pluck('id');
if ($orphanedIds->isNotEmpty()) {
DB::table('slides')
->whereIn('service_agenda_item_id', $orphanedIds)
->update(['service_agenda_item_id' => null]);
DB::table('service_agenda_items')
->where('service_id', $serviceId)
->whereNotIn('sort_order', $processedSortOrders)
->delete();
}
return $summary;

View file

@ -36,8 +36,9 @@
],
songFetcher: fn () => [new FakeSong(id: 1, title: 'Way Maker', ccli: '7115744')],
agendaFetcher: fn () => new FakeAgenda([
new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744'),
new FakeSong(id: 5002, title: 'Unbekannt', ccli: '9999999'),
new FakeAgendaItem(id: 1, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 2, position: '2', title: 'Way Maker', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
new FakeAgendaItem(id: 3, position: '3', title: 'Unbekannt', type: 'Song', song: new FakeSong(id: 5002, title: 'Unbekannt', ccli: '9999999')),
]),
eventServiceFetcher: fn (int $eventId) => [
new FakeEventService('Predigt', new FakePerson('Max', 'Mustermann')),
@ -55,12 +56,12 @@
expect($service->preacher_name)->toBe('Max Mustermann');
expect($service->beamer_tech_name)->toBe('Lisa Technik');
$matchedSong = DB::table('service_songs')->where('order', 1)->first();
$matchedSong = DB::table('service_songs')->where('order', 2)->first();
expect($matchedSong)->not->toBeNull();
expect((int) $matchedSong->song_id)->toBe($localSongId);
expect($matchedSong->matched_at)->not->toBeNull();
$unmatchedSong = DB::table('service_songs')->where('order', 2)->first();
$unmatchedSong = DB::table('service_songs')->where('order', 3)->first();
expect($unmatchedSong)->not->toBeNull();
expect($unmatchedSong->song_id)->toBeNull();
expect($unmatchedSong->cts_ccli_id)->toBe('9999999');
@ -72,6 +73,283 @@
expect((int) $syncLog->songs_count)->toBe(2);
});
test('sync speichert alle agenda items (songs und nicht-songs) in service_agenda_items', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 200, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 10, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 11, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
new FakeAgendaItem(id: 12, position: '3', title: 'Predigt', type: 'Default', note: 'Zum Thema Liebe'),
new FakeAgendaItem(id: 13, position: '4', title: 'Abschluss', type: 'Song', song: new FakeSong(id: 5002, title: 'Amazing Grace', ccli: '1234567')),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '200')->first();
$agendaItems = DB::table('service_agenda_items')
->where('service_id', $service->id)
->orderBy('sort_order')
->get();
expect($agendaItems)->toHaveCount(4);
expect($agendaItems[0]->title)->toBe('Begrüssung');
expect($agendaItems[0]->type)->toBe('Default');
expect((int) $agendaItems[0]->sort_order)->toBe(1);
expect($agendaItems[1]->title)->toBe('Lobpreis');
expect($agendaItems[1]->type)->toBe('Song');
expect((int) $agendaItems[1]->sort_order)->toBe(2);
expect($agendaItems[2]->title)->toBe('Predigt');
expect($agendaItems[2]->note)->toBe('Zum Thema Liebe');
expect($agendaItems[3]->title)->toBe('Abschluss');
});
test('song items erstellen service_song UND service_agenda_item mit korrekter service_song_id', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 300, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 20, position: '1', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '300')->first();
$serviceSong = DB::table('service_songs')->where('service_id', $service->id)->first();
expect($serviceSong)->not->toBeNull();
expect($serviceSong->cts_song_name)->toBe('Way Maker');
$agendaItem = DB::table('service_agenda_items')->where('service_id', $service->id)->first();
expect($agendaItem)->not->toBeNull();
expect($agendaItem->title)->toBe('Lobpreis');
expect((int) $agendaItem->service_song_id)->toBe((int) $serviceSong->id);
});
test('nicht-song items erstellen keine service_song eintraege', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 400, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 30, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 31, position: '2', title: 'Predigt', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '400')->first();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(2);
$serviceSongs = DB::table('service_songs')->where('service_id', $service->id)->get();
expect($serviceSongs)->toHaveCount(0);
});
test('is_before_event items werden uebersprungen', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 500, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 40, position: '0', title: 'Soundcheck', type: 'Default', isBeforeEvent: true),
new FakeAgendaItem(id: 41, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 42, position: '2', title: 'Probe', type: 'Default', isBeforeEvent: true),
new FakeAgendaItem(id: 43, position: '3', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '500')->first();
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(2);
$titles = $agendaItems->pluck('title')->toArray();
expect($titles)->not->toContain('Soundcheck');
expect($titles)->not->toContain('Probe');
expect($titles)->toContain('Begrüssung');
expect($titles)->toContain('Lobpreis');
});
test('verwaiste agenda items werden bei re-sync entfernt', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
// First sync with 3 items
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 600, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 50, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 51, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
new FakeAgendaItem(id: 52, position: '3', title: 'Predigt', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '600')->first();
expect(DB::table('service_agenda_items')->where('service_id', $service->id)->count())->toBe(3);
// Re-sync with only 1 item
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 600, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 50, position: '1', title: 'Begrüssung', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$agendaItems = DB::table('service_agenda_items')->where('service_id', $service->id)->get();
expect($agendaItems)->toHaveCount(1);
expect($agendaItems[0]->title)->toBe('Begrüssung');
});
test('slides werden erhalten (FK genullt) wenn verwaiste agenda items entfernt werden', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
// First sync with 2 items
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 700, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 60, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 61, position: '2', title: 'Predigt', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '700')->first();
$agendaItem = DB::table('service_agenda_items')
->where('service_id', $service->id)
->where('sort_order', 2)
->first();
// Create a slide linked to the second agenda item
$slideId = DB::table('slides')->insertGetId([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => 'predigt.jpg',
'stored_filename' => 'predigt_stored.jpg',
'thumbnail_filename' => 'predigt_thumb.jpg',
'uploaded_at' => now(),
'sort_order' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// Re-sync with only 1 item (second item removed)
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 700, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 60, position: '1', title: 'Begrüssung', type: 'Default'),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
// Slide still exists but FK is null
$slide = DB::table('slides')->where('id', $slideId)->first();
expect($slide)->not->toBeNull();
expect($slide->service_agenda_item_id)->toBeNull();
// Orphaned agenda item is gone
expect(DB::table('service_agenda_items')->where('service_id', $service->id)->count())->toBe(1);
});
test('sync summary enthaelt agenda_items_count', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
$service = new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 800, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(id: 70, position: '1', title: 'Begrüssung', type: 'Default'),
new FakeAgendaItem(id: 71, position: '2', title: 'Lobpreis', type: 'Song', song: new FakeSong(id: 5001, title: 'Way Maker', ccli: '7115744')),
]),
eventServiceFetcher: fn (int $eventId) => [],
);
$summary = $service->sync();
expect($summary)->toHaveKey('agenda_items_count');
expect($summary['agenda_items_count'])->toBe(2);
expect($summary['songs_count'])->toBe(1);
expect($summary['services_count'])->toBe(1);
});
test('responsible feld wird als json gespeichert', function () {
Carbon::setTestNow('2026-03-01 09:00:00');
app()->instance(ChurchToolsService::class, new ChurchToolsService(
eventFetcher: fn () => [
new FakeEvent(id: 900, title: 'Service', startDate: '2026-03-08T10:00:00+00:00'),
],
songFetcher: fn () => [],
agendaFetcher: fn () => new FakeAgenda([
new FakeAgendaItem(
id: 80,
position: '1',
title: 'Begrüssung',
type: 'Default',
responsible: ['name' => 'Max Mustermann'],
),
]),
eventServiceFetcher: fn (int $eventId) => [],
));
Artisan::call('cts:sync');
$service = DB::table('services')->where('cts_event_id', '900')->first();
$agendaItem = DB::table('service_agenda_items')->where('service_id', $service->id)->first();
$responsible = json_decode($agendaItem->responsible, true);
expect($responsible)->toBe(['name' => 'Max Mustermann']);
});
function ensureSyncTables(): void
{
if (! Schema::hasTable('services')) {
@ -93,6 +371,7 @@ function ensureSyncTables(): void
$table->id();
$table->string('title');
$table->string('ccli_id')->nullable()->unique();
$table->string('cts_song_id')->nullable()->index();
$table->timestamps();
});
}
@ -104,6 +383,7 @@ function ensureSyncTables(): void
$table->foreignId('song_id')->nullable()->constrained('songs')->nullOnDelete();
$table->string('cts_song_name');
$table->string('cts_ccli_id')->nullable();
$table->string('cts_song_id')->nullable();
$table->unsignedInteger('order')->default(0);
$table->timestamp('matched_at')->nullable();
$table->timestamps();
@ -111,6 +391,44 @@ function ensureSyncTables(): void
});
}
if (! Schema::hasTable('service_agenda_items')) {
Schema::create('service_agenda_items', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
$table->string('cts_agenda_item_id')->nullable()->index();
$table->string('position');
$table->string('title');
$table->string('type');
$table->text('note')->nullable();
$table->string('duration')->nullable();
$table->string('start')->nullable();
$table->boolean('is_before_event')->default(false);
$table->json('responsible')->nullable();
$table->foreignId('service_song_id')->nullable()->constrained('service_songs')->nullOnDelete();
$table->unsignedInteger('sort_order');
$table->timestamps();
$table->unique(['service_id', 'sort_order']);
});
}
if (! Schema::hasTable('slides')) {
Schema::create('slides', function (Blueprint $table) {
$table->id();
$table->enum('type', ['information', 'moderation', 'sermon']);
$table->foreignId('service_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('service_agenda_item_id')->nullable()->constrained('service_agenda_items')->nullOnDelete();
$table->string('original_filename');
$table->string('stored_filename');
$table->string('thumbnail_filename');
$table->date('expire_date')->nullable();
$table->string('uploader_name')->nullable();
$table->timestamp('uploaded_at');
$table->unsignedInteger('sort_order')->default(0);
$table->softDeletes();
$table->timestamps();
});
}
if (! Schema::hasTable('cts_sync_log')) {
Schema::create('cts_sync_log', function (Blueprint $table) {
$table->id();
@ -198,11 +516,85 @@ public function getLastName(): string
final class FakeAgenda
{
public function __construct(private readonly array $songs) {}
public function __construct(private readonly array $items) {}
public function getItems(): array
{
return $this->items;
}
public function getSongs(): array
{
return $this->songs;
return array_values(array_filter(
array_map(fn ($item) => $item->getSong(), $this->items),
fn ($song) => $song !== null,
));
}
}
final class FakeAgendaItem
{
public function __construct(
private readonly int $id,
private readonly string $position,
private readonly string $title,
private readonly string $type,
private readonly ?FakeSong $song = null,
private readonly ?string $note = null,
private readonly ?string $duration = null,
private readonly ?string $start = null,
private readonly bool $isBeforeEvent = false,
private readonly ?array $responsible = null,
) {}
public function getId(): string
{
return (string) $this->id;
}
public function getPosition(): string
{
return $this->position;
}
public function getTitle(): string
{
return $this->title;
}
public function getType(): string
{
return $this->type;
}
public function getSong(): ?FakeSong
{
return $this->song;
}
public function getNote(): ?string
{
return $this->note;
}
public function getDuration(): ?string
{
return $this->duration;
}
public function getStart(): ?string
{
return $this->start;
}
public function getIsBeforeEvent(): bool
{
return $this->isBeforeEvent;
}
public function getResponsible(): ?array
{
return $this->responsible;
}
}