diff --git a/.sisyphus/notepads/edit-page-restructure/learnings.md b/.sisyphus/notepads/edit-page-restructure/learnings.md index 188cfb7..2dae83c 100644 --- a/.sisyphus/notepads/edit-page-restructure/learnings.md +++ b/.sisyphus/notepads/edit-page-restructure/learnings.md @@ -86,3 +86,107 @@ ### Code Patterns - Public method documentation via docstrings (necessary for API clarity) - Test organization via section headers (necessary for readability) - No regex used — just `fnmatch()` for simplicity and correctness + +## 2026-03-29 — Settings Controller Agenda Keys + +### Added 4 New Configuration Keys +- `agenda_start_title` — start boundary title pattern for agenda display +- `agenda_end_title` — end boundary title pattern +- `agenda_announcement_position` — comma-separated patterns for announcement position +- `agenda_sermon_matching` — comma-separated patterns for sermon recognition + +### SettingsController Updates +- Modified `MACRO_KEYS` constant array in `SettingsController.php` to include the 4 new keys +- Validation automatically accepts new keys via `'in:'.implode(',', self::MACRO_KEYS)` rule +- `index()` already iterates through all MACRO_KEYS, so no additional logic needed +- Settings fetched via `Setting::get($key)` and passed to Inertia + +### Test Implementation +- Created `tests/Feature/SettingsControllerAgendaKeysTest.php` with 6 Pest tests +- Test patterns used: + - `$response->assertInertia()` with `->has('settings.key')` for prop assertions + - `$this->patchJson(route('settings.update'), [...])` for PATCH requests + - `Setting::get($key)` to verify database persistence + - `$response->assertUnprocessable()` for invalid key validation +- All tests pass; code passes Pint formatting + +## 2026-03-29 — DiscoverAgendaTypes command + +### Implementation +- Created `DiscoverAgendaTypes.php` artisan command (cts:discover-agenda-types) +- Fetches next upcoming service from DB (date >= today, order by date ASC) +- Calls `ChurchToolsService->syncAgenda($ctsEventId)` to fetch agenda +- Displays formatted table: Position | Titel | Typ | Hat Song | Vor Event | Dauer +- Prints distinct type values found in agenda items + +### Key Methods Used +- `$agenda->getItems()` returns array of EventAgendaItem +- `EventAgendaItem` getters: `getPosition()`, `getTitle()`, `getType()`, `getSong()`, `getIsBeforeEvent()`, `getDuration()` +- `Str::limit($string, 40, '...')` truncates titles to 40 chars + +### Test Coverage (5 tests) +- No service found → outputs "Kein bevorstehender Service gefunden." +- Displays table and unique types from agenda items +- Handles null agenda → outputs "Keine Agenda für diesen Service gefunden." +- Handles empty items array → outputs "Keine Agenda-Items gefunden." +- Truncates long titles (40 chars) and still displays unique types + +### Code Patterns +- Command signature: `protected $signature = 'cts:discover-agenda-types';` +- Dependency injection via `handle(ChurchToolsService $churchToolsService): int` +- Test mocking: `$this->app->bind(ChurchToolsService::class, function () use (...) { ... })` +- Table output: `$this->table($headers, $rows)` with array of arrays +- Info output: `$this->info()` for text, `$this->line()` for list items + +### Status +- ✓ Command created: `app/Console/Commands/DiscoverAgendaTypes.php` +- ✓ Tests created: `tests/Feature/DiscoverAgendaTypesTest.php` (5 tests, all passing) +- ✓ Pint formatting: clean (`pint --test` passes) +- ✓ Ready for: `git commit -m "chore(debug): add CTS agenda type discovery command"` + +## 2026-03-29 — Service::finalizationStatus() refactor + +### Implementation +- Updated `Service::finalizationStatus()` to check new `service_agenda_items` model +- Added backward compatibility: if `agendaItems()->count() === 0`, fall back to legacy `finalizationStatusLegacy()` +- New checks (agenda-based): + 1. Unmatched songs: Find agenda items with `service_song_id` where `serviceSong.song_id IS NULL` + - Warning format: `"{songName} wurde noch nicht zugeordnet"` + 2. Songs without arrangement: Find agenda items where `serviceSong.song_arrangement_id IS NULL` + - Warning format: `"{songName} hat kein Arrangement ausgewählt"` + 3. Sermon slides: + - If `Setting::get('agenda_sermon_matching')` configured: check if any matching agenda item has slides + - Else: fall back to legacy check `$this->slides()->where('type', 'sermon')->exists()` + - Warning: `"Keine Predigt-Folien hochgeladen"` + +### Code Changes +- Modified: `app/Models/Service.php` + - Added import: `use App\Services\AgendaMatcherService;` + - Updated `finalizationStatus()` method (95 lines) + - Added private `finalizationStatusLegacy()` method (28 lines) for backward compat + - Used `app(AgendaMatcherService::class)` to instantiate service for pattern matching + - Preserved `agendaItems()` HasMany relationship (added in earlier Task) + +- Modified: `tests/Feature/FinalizationTest.php` + - Added imports: `ServiceAgendaItem`, `Setting` + - Renamed first test to `finalize ohne voraussetzungen gibt warnungen zurueck (legacy)` for clarity + - Added 6 new agenda-based tests: + 1. `agenda finalization warnt bei unzugeordneten songs` — unmatched song warning + 2. `agenda finalization warnt bei songs ohne arrangement` — no arrangement warning + 3. `agenda finalization bereit wenn alle songs zugeordnet und arrangement` — all pass + 4. `agenda finalization warnt wenn keine predigtfolien und sermon setting konfiguriert` — sermon with setting + 5. `agenda finalization ok wenn predigtfolien bei sermon item vorhanden` — sermon slides exist + 6. `agenda finalization warnt wenn keine predigtfolien und kein sermon setting` — no setting fallback + +### Test Results +- All 18 tests pass (12 existing + 6 new) +- Backward compatibility verified: legacy tests with `service_songs` still work +- Pint formatting: clean + +### Code Patterns Used +- Guard clause in `finalizationStatus()` for backward compat check +- `$agendaItems->filter()` with closures for conditional filtering +- Service instantiation via `app(AgendaMatcherService::class)` with closure captures +- Null-safe navigation: `$item->serviceSong?->song_id` +- Setting retrieval: `Setting::get('agenda_sermon_matching')` +- Slide filtering: `whereNull('deleted_at')` for soft deletes diff --git a/app/Models/Service.php b/app/Models/Service.php index 5dcb8fb..a6eb2a4 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\AgendaMatcherService; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -42,6 +43,11 @@ public function slides(): HasMany return $this->hasMany(Slide::class); } + public function agendaItems(): HasMany + { + return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order'); + } + /** * Check finalization prerequisites and return warnings. * @@ -51,6 +57,87 @@ public function finalizationStatus(): array { $warnings = []; + // Backward compatibility: if no agenda items exist, use old checks + if ($this->agendaItems()->count() === 0) { + return $this->finalizationStatusLegacy(); + } + + // New agenda-based checks + $agendaItems = $this->agendaItems()->with('serviceSong.song')->get(); + + // Check 1: Unmatched songs (song-type agenda items where song_id IS NULL) + $unmatchedSongs = $agendaItems->filter(function (ServiceAgendaItem $item) { + if ($item->service_song_id === null) { + return false; + } + $serviceSong = $item->serviceSong; + + return $serviceSong && $serviceSong->song_id === null; + }); + + foreach ($unmatchedSongs as $item) { + $songName = $item->serviceSong?->cts_song_name ?? 'Unbekannter Song'; + $warnings[] = "{$songName} wurde noch nicht zugeordnet"; + } + + // Check 2: Matched songs without arrangement + $matchedSongsWithoutArrangement = $agendaItems->filter(function (ServiceAgendaItem $item) { + if ($item->service_song_id === null) { + return false; + } + $serviceSong = $item->serviceSong; + + return $serviceSong && $serviceSong->song_id !== null && $serviceSong->song_arrangement_id === null; + }); + + foreach ($matchedSongsWithoutArrangement as $item) { + $songName = $item->serviceSong?->cts_song_name ?? 'Unbekannter Song'; + $warnings[] = "{$songName} hat kein Arrangement ausgewählt"; + } + + // Check 3: Sermon slides + $sermonPatterns = Setting::get('agenda_sermon_matching'); + + if ($sermonPatterns) { + // Settings configured: check if any agenda item flagged as sermon has slides + $agendaMatcher = app(AgendaMatcherService::class); + $sermonItems = $agendaItems->filter(function (ServiceAgendaItem $item) use ($agendaMatcher, $sermonPatterns) { + return $agendaMatcher->matches($item->title, $sermonPatterns); + }); + + $sermonHasSlides = false; + foreach ($sermonItems as $item) { + if ($item->slides()->whereNull('deleted_at')->exists()) { + $sermonHasSlides = true; + break; + } + } + + if (! $sermonHasSlides) { + $warnings[] = 'Keine Predigt-Folien hochgeladen'; + } + } else { + // No settings configured: fall back to checking slides table + if (! $this->slides()->where('type', 'sermon')->exists()) { + $warnings[] = 'Keine Predigt-Folien hochgeladen'; + } + } + + return [ + 'ready' => empty($warnings), + 'warnings' => $warnings, + ]; + } + + /** + * Legacy finalization status for services without agenda items. + * + * @return array{ready: bool, warnings: string[]} + */ + private function finalizationStatusLegacy(): array + { + $warnings = []; + $totalSongs = $this->serviceSongs()->count(); $mappedSongs = $this->serviceSongs()->whereNotNull('song_id')->count(); $arrangedSongs = $this->serviceSongs()->whereNotNull('song_arrangement_id')->count(); diff --git a/tests/Feature/FinalizationTest.php b/tests/Feature/FinalizationTest.php index 7dfa792..d514e07 100644 --- a/tests/Feature/FinalizationTest.php +++ b/tests/Feature/FinalizationTest.php @@ -1,7 +1,9 @@ user = User::factory()->create(); }); -test('finalize ohne voraussetzungen gibt warnungen zurueck', function () { +test('finalize ohne voraussetzungen gibt warnungen zurueck (legacy)', function () { $service = Service::factory()->create(['finalized_at' => null]); - // Song without match or arrangement + // Song without match or arrangement (legacy service_songs) ServiceSong::create([ 'service_id' => $service->id, 'song_id' => null, @@ -251,3 +253,228 @@ ->and($status['warnings'])->toHaveCount(1) ->and($status['warnings'][0])->toContain('Predigtfolien'); }); + +// Agenda-based finalization tests +test('agenda finalization warnt bei unzugeordneten songs', function () { + $service = Service::factory()->create(['finalized_at' => null]); + + // Create an unmatched song via agenda item + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => null, + 'song_arrangement_id' => null, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Unzugeordneter Song', + 'cts_ccli_id' => '12345', + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + ]); + + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeFalse() + ->and($status['warnings'])->toContain('Unzugeordneter Song wurde noch nicht zugeordnet'); +}); + +test('agenda finalization warnt bei songs ohne arrangement', function () { + $service = Service::factory()->create(['finalized_at' => null]); + $song = Song::factory()->create(); + + // Song is matched but has no arrangement + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => null, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Song ohne Arrangement', + 'cts_ccli_id' => '12345', + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + ]); + + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeFalse() + ->and($status['warnings'])->toContain('Song ohne Arrangement hat kein Arrangement ausgewählt'); +}); + +test('agenda finalization bereit wenn alle songs zugeordnet und arrangement', function () { + $service = Service::factory()->create(['finalized_at' => null]); + $song = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create(['song_id' => $song->id]); + + // Song is matched with arrangement + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => $arrangement->id, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Zugeordneter Song', + 'cts_ccli_id' => '12345', + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + ]); + + // Add sermon slides + Slide::factory()->create([ + 'service_id' => $service->id, + 'type' => 'sermon', + ]); + + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeTrue() + ->and($status['warnings'])->toHaveCount(0); +}); + +test('agenda finalization warnt wenn keine predigtfolien und sermon setting konfiguriert', function () { + $service = Service::factory()->create(['finalized_at' => null]); + + // Configure sermon pattern + Setting::set('agenda_sermon_matching', 'Predigt*'); + + $song = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create(['song_id' => $song->id]); + + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => $arrangement->id, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Zugeordneter Song', + 'cts_ccli_id' => '12345', + ]); + + // Create agenda item for sermon (matches pattern) + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Predigt', + 'type' => 'Default', + 'sort_order' => 1, + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '2', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + ]); + + // No sermon slides + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeFalse() + ->and($status['warnings'])->toContain('Keine Predigt-Folien hochgeladen'); +}); + +test('agenda finalization ok wenn predigtfolien bei sermon item vorhanden', function () { + $service = Service::factory()->create(['finalized_at' => null]); + + // Configure sermon pattern + Setting::set('agenda_sermon_matching', 'Predigt*'); + + $song = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create(['song_id' => $song->id]); + + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => $arrangement->id, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Zugeordneter Song', + 'cts_ccli_id' => '12345', + ]); + + // Create sermon agenda item + $sermonItem = ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Predigt', + 'type' => 'Default', + 'sort_order' => 1, + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '2', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 2, + ]); + + // Add sermon slides to the sermon agenda item + Slide::factory()->create([ + 'service_id' => $service->id, + 'service_agenda_item_id' => $sermonItem->id, + ]); + + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeTrue() + ->and($status['warnings'])->toHaveCount(0); +}); + +test('agenda finalization warnt wenn keine predigtfolien und kein sermon setting', function () { + $service = Service::factory()->create(['finalized_at' => null]); + + // No sermon pattern setting configured + Setting::set('agenda_sermon_matching', ''); + + $song = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create(['song_id' => $song->id]); + + $serviceSong = ServiceSong::create([ + 'service_id' => $service->id, + 'song_id' => $song->id, + 'song_arrangement_id' => $arrangement->id, + 'use_translation' => false, + 'order' => 1, + 'cts_song_name' => 'Zugeordneter Song', + 'cts_ccli_id' => '12345', + ]); + + ServiceAgendaItem::create([ + 'service_id' => $service->id, + 'position' => '1', + 'title' => 'Song 1', + 'type' => 'Song', + 'service_song_id' => $serviceSong->id, + 'sort_order' => 1, + ]); + + // No sermon slides - fallback should warn + $status = $service->finalizationStatus(); + + expect($status['ready'])->toBeFalse() + ->and($status['warnings'])->toContain('Keine Predigt-Folien hochgeladen'); +});