feat(service): moderator/preacher name resolution
This commit is contained in:
parent
b31f21959f
commit
d2193bb3b2
16
.sisyphus/evidence/task-7-moderator.txt
Normal file
16
.sisyphus/evidence/task-7-moderator.txt
Normal 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.
|
||||
28
.sisyphus/evidence/task-7-preacher.txt
Normal file
28
.sisyphus/evidence/task-7-preacher.txt
Normal 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.
|
||||
130
app/Services/NameTagResolver.php
Normal file
130
app/Services/NameTagResolver.php
Normal 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 !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
140
tests/Feature/NameTagResolverTest.php
Normal file
140
tests/Feature/NameTagResolverTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue