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