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:
parent
f561c0ada9
commit
a36841f920
|
|
@ -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.');
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -108,20 +108,36 @@ 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(
|
||||||
|
['ccli_id' => $ccliId],
|
||||||
|
[
|
||||||
|
'cts_song_id' => $ctsSongId !== '' ? $ctsSongId : null,
|
||||||
|
'title' => (string) ($song->getName() ?? ''),
|
||||||
|
'updated_at' => Carbon::now(),
|
||||||
|
'created_at' => Carbon::now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::table('songs')->updateOrInsert(
|
if ($ctsSongId !== '' && $ccliId === null) {
|
||||||
['ccli_id' => $ccliId],
|
$existing = DB::table('songs')->where('cts_song_id', $ctsSongId)->first();
|
||||||
[
|
|
||||||
'title' => (string) ($song->getName() ?? ''),
|
|
||||||
'updated_at' => Carbon::now(),
|
|
||||||
'created_at' => Carbon::now(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$count++;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue