feat(service): moderator/preacher name resolution

This commit is contained in:
Thorsten Bus 2026-05-31 00:20:33 +02:00
parent b31f21959f
commit d2193bb3b2
4 changed files with 314 additions and 0 deletions

View file

@ -0,0 +1,16 @@
Task T7 moderator evidence
Implemented App\Services\NameTagResolver::moderatorFor(Service $service).
Covered behavior:
- Non-empty services.moderator_name wins and is trimmed.
- Without override, first visible agenda item (is_before_event=false) ordered by sort_order then id is used.
- Multiple responsible names are joined with comma-space: "Anna Müller, Tom Klein".
- No override and no visible agenda item returns null.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,28 @@
Task T7 preacher evidence
Implemented App\Services\NameTagResolver::preacherFor(Service $service).
Covered behavior:
- Non-empty services.preacher_name_override wins and is trimmed.
- Without override, services.preacher_name (CTS Predigt role) is returned.
- Without both fields, first visible non-song sermon agenda item is resolved by agenda_sermon_matching patterns via AgendaMatcherService.
- Multiple responsible entries are supported with comma-space joining; empty/missing names return null.
- No override, no CTS preacher name, and no sermon responsible returns null.
Responsible JSON findings:
- ServiceAgendaItem.responsible is cast to array.
- Existing sync test stores associative object shape: {"name":"Max Mustermann"}.
- New tests cover the expected multiple-person list shape: [{"name":"Anna Müller"},{"name":"Tom Klein"}].
- Resolver also supports string entries and firstName/lastName or first_name/last_name fallbacks.
Sermon detection used:
- Prefer configured Setting key agenda_sermon_matching, split by comma, matched with AgendaMatcherService::matchesAny().
- Only visible non-song agenda items are considered (is_before_event=false, service_song_id IS NULL).
- If no setting exists, title/type substring fallback accepts predigt or sermon.
Verification:
- RED before implementation: ddev exec php artisan test tests/Feature/NameTagResolverTest.php failed because App\Services\NameTagResolver did not exist.
- GREEN targeted: ddev exec php artisan test tests/Feature/NameTagResolverTest.php -> 7 passed.
- GREEN full suite: ddev exec php artisan test -> 532 passed (2659 assertions).
- Pint: ddev exec ./vendor/bin/pint app/Services/NameTagResolver.php tests/Feature/NameTagResolverTest.php.
- LSP diagnostics clean for app/Services/NameTagResolver.php and tests/Feature/NameTagResolverTest.php.

View file

@ -0,0 +1,130 @@
<?php
namespace App\Services;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class NameTagResolver
{
public function __construct(
private readonly AgendaMatcherService $agendaMatcherService,
) {}
public function moderatorFor(Service $service): ?string
{
$override = $this->filledString($service->moderator_name);
if ($override !== null) {
return $override;
}
$firstAgendaItem = $service->agendaItems()
->where('is_before_event', false)
->orderBy('sort_order')
->orderBy('id')
->first();
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
}
public function preacherFor(Service $service): ?string
{
$override = $this->filledString($service->preacher_name_override);
if ($override !== null) {
return $override;
}
$preacherName = $this->filledString($service->preacher_name);
if ($preacherName !== null) {
return $preacherName;
}
$sermonItem = $service->agendaItems()
->where('is_before_event', false)
->whereNull('service_song_id')
->orderBy('sort_order')
->orderBy('id')
->get()
->first(fn (ServiceAgendaItem $item) => $this->isSermonItem($item));
return $sermonItem ? $this->namesFromResponsible($sermonItem->responsible) : null;
}
private function filledString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed === '' ? null : $trimmed;
}
private function namesFromResponsible(mixed $responsible): ?string
{
if (! is_array($responsible) || $responsible === []) {
return null;
}
$people = Arr::isAssoc($responsible) ? [$responsible] : $responsible;
$names = collect($people)
->map(fn (mixed $person) => $this->nameFromResponsiblePerson($person))
->filter()
->values()
->all();
return $names === [] ? null : implode(', ', $names);
}
private function nameFromResponsiblePerson(mixed $person): ?string
{
if (is_string($person)) {
return $this->filledString($person);
}
if (! is_array($person)) {
return null;
}
$name = $this->filledString($person['name'] ?? null);
if ($name !== null) {
return $name;
}
$firstName = $this->filledString($person['firstName'] ?? $person['first_name'] ?? null) ?? '';
$lastName = $this->filledString($person['lastName'] ?? $person['last_name'] ?? null) ?? '';
$fullName = trim($firstName.' '.$lastName);
return $fullName === '' ? null : $fullName;
}
private function isSermonItem(ServiceAgendaItem $item): bool
{
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));
if ($configuredPatterns !== []) {
return $this->agendaMatcherService->matchesAny($item->title, $configuredPatterns);
}
$title = Str::lower($item->title);
$type = Str::lower($item->type ?? '');
return str_contains($title, 'predigt')
|| str_contains($title, 'sermon')
|| str_contains($type, 'predigt')
|| str_contains($type, 'sermon');
}
/** @return array<int, string> */
private function patternsFromSetting(?string $patterns): array
{
if ($patterns === null || trim($patterns) === '') {
return [];
}
return array_values(array_filter(
array_map(fn (string $pattern) => trim($pattern), explode(',', $patterns)),
fn (string $pattern) => $pattern !== '',
));
}
}

View file

@ -0,0 +1,140 @@
<?php
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Services\NameTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('moderator override wins', function () {
$service = Service::factory()->create([
'moderator_name' => 'Override Mod',
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Override Mod');
});
test('moderator falls back to first visible agenda item responsibles', function () {
$service = Service::factory()->create([
'moderator_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Vorprogramm',
'is_before_event' => true,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'is_before_event' => false,
'responsible' => [
['name' => 'Anna Müller'],
['name' => 'Tom Klein'],
],
'sort_order' => 2,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Später',
'is_before_event' => false,
'responsible' => [['name' => 'Späte Person']],
'sort_order' => 3,
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBe('Anna Müller, Tom Klein');
});
test('moderator returns null without override or visible agenda item', function () {
$service = Service::factory()->create([
'moderator_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'is_before_event' => true,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
$name = app(NameTagResolver::class)->moderatorFor($service);
expect($name)->toBeNull();
});
test('preacher override wins', function () {
$service = Service::factory()->create([
'preacher_name_override' => 'Gast Sprecher',
'preacher_name' => 'Pfr. Lang',
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Gast Sprecher');
});
test('preacher falls back to cts preacher name', function () {
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => 'Pfr. Lang',
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Pfr. Lang');
});
test('preacher falls back to sermon agenda item responsibles', function () {
Setting::set('agenda_sermon_matching', 'Predigt*,Sermon*');
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Lied vor der Predigt',
'service_song_id' => null,
'responsible' => [['name' => 'Ignored Person']],
'sort_order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'responsible' => [['name' => 'Diakon Bauer']],
'sort_order' => 2,
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBe('Diakon Bauer');
});
test('preacher returns null without override cts name or sermon responsibles', function () {
Setting::set('agenda_sermon_matching', 'Predigt*');
$service = Service::factory()->create([
'preacher_name_override' => null,
'preacher_name' => null,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Begrüßung',
'service_song_id' => null,
'responsible' => [['name' => 'Moderation']],
'sort_order' => 1,
]);
$name = app(NameTagResolver::class)->preacherFor($service);
expect($name)->toBeNull();
});