feat(songs): add CTS song ID matching, info slide date filter, arrangement ordering, translation defaults

- Add cts_song_id column to songs and service_songs for CCLI-free matching fallback
- Filter information slides by uploaded_at <= service date (not shown before upload)
- New arrangements use song's default group ordering instead of cloning
- Auto-set use_translation=true when matched song has translation
- Update syncSongs/syncServiceAgendaSongs to store and use cts_song_id
- Add tests for CTS song ID fallback, upload date filtering, and translation defaults
This commit is contained in:
Thorsten Bus 2026-03-02 14:10:40 +01:00
parent f561c0ada9
commit a36841f920
10 changed files with 148 additions and 39 deletions

View file

@ -23,19 +23,18 @@ public function store(Request $request, Song $song): RedirectResponse
'is_default' => false, 'is_default' => false,
]); ]);
$source = $song->arrangements() $groups = $song->groups()->orderBy('order')->get();
->where('is_default', true) $rows = $groups->map(fn ($group, $index) => [
->with('arrangementGroups') 'song_arrangement_id' => $arrangement->id,
->first(); 'song_group_id' => $group->id,
'order' => $index + 1,
'created_at' => now(),
'updated_at' => now(),
])->all();
if ($source === null) { if ($rows !== []) {
$source = $song->arrangements() $arrangement->arrangementGroups()->insert($rows);
->with('arrangementGroups')
->orderBy('id')
->first();
} }
$this->cloneGroups($source, $arrangement);
}); });
return back()->with('success', 'Arrangement wurde hinzugefügt.'); return back()->with('success', 'Arrangement wurde hinzugefügt.');

View file

@ -111,6 +111,7 @@ public function edit(Service $service): Response
$q->whereNull('expire_date') $q->whereNull('expire_date')
->orWhereDate('expire_date', '>=', $service->date); ->orWhereDate('expire_date', '>=', $service->date);
}) })
->whereDate('uploaded_at', '<=', $service->date)
) )
->orderByDesc('uploaded_at') ->orderByDesc('uploaded_at')
->get(); ->get();

View file

@ -18,6 +18,7 @@ class ServiceSong extends Model
'order', 'order',
'cts_song_name', 'cts_song_name',
'cts_ccli_id', 'cts_ccli_id',
'cts_song_id',
'matched_at', 'matched_at',
'request_sent_at', 'request_sent_at',
]; ];

View file

@ -15,6 +15,7 @@ class Song extends Model
protected $fillable = [ protected $fillable = [
'ccli_id', 'ccli_id',
'cts_song_id',
'title', 'title',
'author', 'author',
'copyright_text', 'copyright_text',

View file

@ -108,13 +108,13 @@ public function syncSongs(): int
foreach ($songs as $song) { foreach ($songs as $song) {
$ccliId = $this->normalizeCcli($song->getCcli() ?? null); $ccliId = $this->normalizeCcli($song->getCcli() ?? null);
if ($ccliId === null) { $ctsSongId = (string) ($song->getId() ?? '');
continue;
}
if ($ccliId !== null) {
DB::table('songs')->updateOrInsert( DB::table('songs')->updateOrInsert(
['ccli_id' => $ccliId], ['ccli_id' => $ccliId],
[ [
'cts_song_id' => $ctsSongId !== '' ? $ctsSongId : null,
'title' => (string) ($song->getName() ?? ''), 'title' => (string) ($song->getName() ?? ''),
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now(),
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
@ -124,6 +124,22 @@ public function syncSongs(): int
$count++; $count++;
} }
if ($ctsSongId !== '' && $ccliId === null) {
$existing = DB::table('songs')->where('cts_song_id', $ctsSongId)->first();
if (! $existing) {
DB::table('songs')->insert([
'cts_song_id' => $ctsSongId,
'title' => (string) ($song->getName() ?? ''),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
}
$count++;
}
}
return $count; return $count;
} }
@ -335,6 +351,7 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
[ [
'cts_song_name' => (string) ($song->getName() ?? ''), 'cts_song_name' => (string) ($song->getName() ?? ''),
'cts_ccli_id' => $ccliId, 'cts_ccli_id' => $ccliId,
'cts_song_id' => $ctsSongId,
'updated_at' => Carbon::now(), 'updated_at' => Carbon::now(),
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
] ]
@ -346,7 +363,11 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
$matched = false; $matched = false;
if ($serviceSong !== null && $serviceSong->cts_ccli_id && ! $serviceSong->song_id) { if (
$serviceSong !== null
&& ! $serviceSong->song_id
&& ($serviceSong->cts_ccli_id || $serviceSong->cts_song_id)
) {
$matched = $songMatchingService->autoMatch($serviceSong); $matched = $songMatchingService->autoMatch($serviceSong);
} elseif ($serviceSong !== null && $serviceSong->song_id) { } elseif ($serviceSong !== null && $serviceSong->song_id) {
$matched = true; $matched = true;

View file

@ -11,23 +11,21 @@
class SongMatchingService class SongMatchingService
{ {
/**
* Auto-match a service song to a song in the DB by CCLI ID.
*
* Returns true if a match was found and assigned, false otherwise.
* Skips if already matched or no CCLI ID present.
*/
public function autoMatch(ServiceSong $serviceSong): bool public function autoMatch(ServiceSong $serviceSong): bool
{ {
if ($serviceSong->song_id !== null) { if ($serviceSong->song_id !== null) {
return false; return false;
} }
if ($serviceSong->cts_ccli_id === null || $serviceSong->cts_ccli_id === '') { $song = null;
return false;
if ($serviceSong->cts_ccli_id !== null && $serviceSong->cts_ccli_id !== '') {
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
} }
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first(); if ($song === null && $serviceSong->cts_song_id) {
$song = Song::where('cts_song_id', $serviceSong->cts_song_id)->first();
}
if ($song === null) { if ($song === null) {
return false; return false;
@ -36,6 +34,7 @@ public function autoMatch(ServiceSong $serviceSong): bool
$serviceSong->update([ $serviceSong->update([
'song_id' => $song->id, 'song_id' => $song->id,
'matched_at' => Carbon::now(), 'matched_at' => Carbon::now(),
'use_translation' => $song->has_translation,
]); ]);
return true; return true;
@ -50,6 +49,7 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
$serviceSong->update([ $serviceSong->update([
'song_id' => $song->id, 'song_id' => $song->id,
'matched_at' => Carbon::now(), 'matched_at' => Carbon::now(),
'use_translation' => $song->has_translation,
]); ]);
} }

View file

@ -0,0 +1,29 @@
<?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('songs', function (Blueprint $table) {
$table->string('cts_song_id')->nullable()->index()->after('ccli_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');
});
Schema::table('songs', function (Blueprint $table) {
$table->dropColumn('cts_song_id');
});
}
};

View file

@ -15,7 +15,7 @@ class ArrangementControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_create_arrangement_clones_groups_from_default_arrangement(): void public function test_create_arrangement_uses_default_song_group_ordering(): void
{ {
[$song, $normal] = $this->createSongWithDefaultArrangement(); [$song, $normal] = $this->createSongWithDefaultArrangement();
$user = User::factory()->create(); $user = User::factory()->create();
@ -34,10 +34,10 @@ public function test_create_arrangement_clones_groups_from_default_arrangement()
$this->assertNotNull($newArrangement); $this->assertNotNull($newArrangement);
$normalGroups = SongArrangementGroup::query() $defaultGroupOrder = SongGroup::query()
->where('song_arrangement_id', $normal->id) ->where('song_id', $song->id)
->orderBy('order') ->orderBy('order')
->pluck('song_group_id') ->pluck('id')
->all(); ->all();
$newGroups = SongArrangementGroup::query() $newGroups = SongArrangementGroup::query()
@ -46,7 +46,7 @@ public function test_create_arrangement_clones_groups_from_default_arrangement()
->pluck('song_group_id') ->pluck('song_group_id')
->all(); ->all();
$this->assertSame($normalGroups, $newGroups); $this->assertSame($defaultGroupOrder, $newGroups);
} }
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void public function test_clone_arrangement_duplicates_current_arrangement_groups(): void

View file

@ -209,6 +209,35 @@
); );
}); });
test('information slides uploaded after service date are not shown', function () {
Carbon::setTestNow('2026-03-01 10:00:00');
$service = Service::factory()->create(['date' => '2026-03-10']);
$visibleSlide = Slide::factory()->create([
'type' => 'information',
'service_id' => null,
'expire_date' => '2026-03-20',
'uploaded_at' => '2026-03-09 10:00:00',
]);
Slide::factory()->create([
'type' => 'information',
'service_id' => null,
'expire_date' => '2026-03-20',
'uploaded_at' => '2026-03-12 10:00:00',
]);
$response = $this->get(route('services.edit', $service));
$response->assertOk();
$response->assertInertia(
fn ($page) => $page
->has('informationSlides', 1)
->where('informationSlides.0.id', $visibleSlide->id)
);
});
test('information slides ordered by uploaded_at descending', function () { test('information slides ordered by uploaded_at descending', function () {
Carbon::setTestNow('2026-03-01 10:00:00'); Carbon::setTestNow('2026-03-01 10:00:00');

View file

@ -15,10 +15,11 @@
*/ */
test('autoMatch ordnet Song per CCLI-ID zu', function () { test('autoMatch ordnet Song per CCLI-ID zu', function () {
$song = Song::factory()->create(['ccli_id' => '7115744']); $song = Song::factory()->create(['ccli_id' => '7115744', 'has_translation' => true]);
$serviceSong = ServiceSong::factory()->create([ $serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => '7115744', 'cts_ccli_id' => '7115744',
'song_id' => null, 'song_id' => null,
'use_translation' => false,
'matched_at' => null, 'matched_at' => null,
]); ]);
@ -29,6 +30,31 @@
$serviceSong->refresh(); $serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id); expect($serviceSong->song_id)->toBe($song->id);
expect($serviceSong->matched_at)->not->toBeNull(); expect($serviceSong->matched_at)->not->toBeNull();
expect($serviceSong->use_translation)->toBeTrue();
});
test('autoMatch nutzt CTS-Song-ID als Fallback wenn keine CCLI passt', function () {
$song = Song::factory()->create([
'ccli_id' => '7115744',
'cts_song_id' => 'cts-123',
'has_translation' => true,
]);
$serviceSong = ServiceSong::factory()->create([
'cts_ccli_id' => '0000000',
'cts_song_id' => 'cts-123',
'song_id' => null,
'use_translation' => false,
'matched_at' => null,
]);
$service = app(SongMatchingService::class);
$result = $service->autoMatch($serviceSong);
expect($result)->toBeTrue();
$serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id);
expect($serviceSong->use_translation)->toBeTrue();
}); });
test('autoMatch gibt false zurück wenn kein CCLI-ID vorhanden', function () { test('autoMatch gibt false zurück wenn kein CCLI-ID vorhanden', function () {
@ -79,9 +105,10 @@
}); });
test('manualAssign ordnet Song manuell zu', function () { test('manualAssign ordnet Song manuell zu', function () {
$song = Song::factory()->create(); $song = Song::factory()->create(['has_translation' => true]);
$serviceSong = ServiceSong::factory()->create([ $serviceSong = ServiceSong::factory()->create([
'song_id' => null, 'song_id' => null,
'use_translation' => false,
'matched_at' => null, 'matched_at' => null,
]); ]);
@ -91,6 +118,7 @@
$serviceSong->refresh(); $serviceSong->refresh();
expect($serviceSong->song_id)->toBe($song->id); expect($serviceSong->song_id)->toBe($song->id);
expect($serviceSong->matched_at)->not->toBeNull(); expect($serviceSong->matched_at)->not->toBeNull();
expect($serviceSong->use_translation)->toBeTrue();
}); });
test('manualAssign überschreibt bestehende Zuordnung', function () { test('manualAssign überschreibt bestehende Zuordnung', function () {