From e796dd01eece026c936837f19e52c878eca178b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 14 Oct 2026 12:32:23 +0200 Subject: [PATCH] feat: admin view listing a travel's mailing recipients from bpn-connect --- config/services.yaml | 3 + .../Model/TravelMailingRecipient.php | 15 ++++ .../Model/TravelMailingResponse.php | 17 ++++ src/BpnConnect/TravelsClient.php | 35 ++++++++ .../Admin/TravelMailing/IndexController.php | 51 +++++++++++ src/Form/Admin/TravelMailingLookupType.php | 44 +++++++++ src/Form/Model/TravelMailingLookupDto.php | 13 +++ src/Menu/AdminMenuBuilder.php | 9 ++ .../admin/travel_mailing/index.html.twig | 71 +++++++++++++++ tests/BpnConnect/TravelsClientTest.php | 89 +++++++++++++++++++ 10 files changed, 347 insertions(+) create mode 100644 src/BpnConnect/Model/TravelMailingRecipient.php create mode 100644 src/BpnConnect/Model/TravelMailingResponse.php create mode 100644 src/BpnConnect/TravelsClient.php create mode 100644 src/Controller/Admin/TravelMailing/IndexController.php create mode 100644 src/Form/Admin/TravelMailingLookupType.php create mode 100644 src/Form/Model/TravelMailingLookupDto.php create mode 100644 templates/admin/travel_mailing/index.html.twig create mode 100644 tests/BpnConnect/TravelsClientTest.php diff --git a/config/services.yaml b/config/services.yaml index 590cccba..83ea8197 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -435,6 +435,9 @@ services: App\BpnConnect\ContingentsClient: parent: App\BpnConnect\AbstractApiClient + App\BpnConnect\TravelsClient: + parent: App\BpnConnect\AbstractApiClient + App\ClickUp\ApiClient: arguments: $apiToken: '%env(default::CLICKUP_API_TOKEN)%' diff --git a/src/BpnConnect/Model/TravelMailingRecipient.php b/src/BpnConnect/Model/TravelMailingRecipient.php new file mode 100644 index 00000000..3e3fd4f0 --- /dev/null +++ b/src/BpnConnect/Model/TravelMailingRecipient.php @@ -0,0 +1,15 @@ +request(self::MAILING_PATH, [ + 'travelCode' => $travelCode, + ]); + + $recipients = array_map( + static fn (array $item) => new TravelMailingRecipient( + email: $item['email'], + firstName: $item['first_name'] ?? '', + lastName: $item['last_name'] ?? '', + ), + $payload['data'] ?? [], + ); + + return new TravelMailingResponse($payload['meta']['travel_code'] ?? $travelCode, $recipients); + } +} diff --git a/src/Controller/Admin/TravelMailing/IndexController.php b/src/Controller/Admin/TravelMailing/IndexController.php new file mode 100644 index 00000000..bbc67a2e --- /dev/null +++ b/src/Controller/Admin/TravelMailing/IndexController.php @@ -0,0 +1,51 @@ +createForm(TravelMailingLookupType::class, $lookup, [ + 'action' => $this->generateUrl('app_admin_travelmailing'), + ]); + $form->handleRequest($request); + + $result = null; + $failed = false; + if ($form->isSubmitted() && $form->isValid()) { + try { + $result = $this->travelsClient->getMailingRecipients((string) $lookup->travelCode); + } catch (BpnConnectException) { + // The client has already logged the cause; the page only needs to say it failed. + $failed = true; + } + } + + return $this->render('admin/travel_mailing/index.html.twig', [ + 'form' => $form, + 'result' => $result, + 'failed' => $failed, + ]); + } +} diff --git a/src/Form/Admin/TravelMailingLookupType.php b/src/Form/Admin/TravelMailingLookupType.php new file mode 100644 index 00000000..d2dd243e --- /dev/null +++ b/src/Form/Admin/TravelMailingLookupType.php @@ -0,0 +1,44 @@ + + */ +class TravelMailingLookupType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('travelCode', TextType::class, [ + 'label' => 'Reisecode', + 'attr' => [ + 'placeholder' => 'z. B. LPJMP281126', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => TravelMailingLookupDto::class, + 'method' => 'GET', + 'csrf_protection' => false, + ]); + } + + public function getBlockPrefix(): string + { + return ''; + } +} diff --git a/src/Form/Model/TravelMailingLookupDto.php b/src/Form/Model/TravelMailingLookupDto.php new file mode 100644 index 00000000..324d4fff --- /dev/null +++ b/src/Form/Model/TravelMailingLookupDto.php @@ -0,0 +1,13 @@ + [['pattern' => '/^app_admin_log/']], ], ]); + $menu->addChild('Reise-Mailingliste', [ + 'route' => 'app_admin_travelmailing', + 'linkAttributes' => [ + 'title' => 'Reise-Mailingliste', + ], + 'extras' => [ + 'icon' => 'mail', + ], + ]); } $this->addLogoutItem($menu); diff --git a/templates/admin/travel_mailing/index.html.twig b/templates/admin/travel_mailing/index.html.twig new file mode 100644 index 00000000..702d8814 --- /dev/null +++ b/templates/admin/travel_mailing/index.html.twig @@ -0,0 +1,71 @@ +{% extends 'layout_admin.html.twig' %} + +{% form_theme form 'forms_admin.html.twig' %} + +{% block content %} + Reise-Mailingliste + {{ form_start(form) }} +
+
+ {{ form_row(form.travelCode) }} +
+ +
+ {{ form_rest(form) }} + {{ form_end(form) }} + + {% if failed %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + messages: ['Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.'], + } %} + {% elseif result is not null %} + {% if result.data is empty %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + messages: ['Keine Empfänger für %s gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)], + } %} + {% else %} +

+ {{ result.data|length }} Empfänger für {{ result.travelCode }} +

+
+
+ + + + + + + + + + {% for recipient in result.data %} + + + + + + {% endfor %} + +
+ Vorname + + Nachname + + E-Mail +
+ {{ recipient.firstName }} + + {{ recipient.lastName }} + + {{ recipient.email }} +
+
+
+ {% endif %} + {% endif %} +{% endblock %} diff --git a/tests/BpnConnect/TravelsClientTest.php b/tests/BpnConnect/TravelsClientTest.php new file mode 100644 index 00000000..584eb46c --- /dev/null +++ b/tests/BpnConnect/TravelsClientTest.php @@ -0,0 +1,89 @@ +jsonResponse([ + 'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 2, 'execution_time_ms' => 12], + 'data' => [ + ['email' => 'erika.muster@example.com', 'first_name' => 'Erika', 'last_name' => 'Muster'], + ['email' => 'max.muster@example.com', 'first_name' => 'Max', 'last_name' => 'Muster'], + ], + ]); + }); + + $response = $this->client($http)->getMailingRecipients('LPJMP281126'); + + self::assertSame('GET', $requests[0][0]); + self::assertSame('https://bpn-connect.test/api/v1/travels/mailing?travelCode=LPJMP281126', $requests[0][1]); + self::assertContains('X-API-KEY: secret', $requests[0][2]['headers']); + self::assertSame('LPJMP281126', $response->travelCode); + self::assertEquals([ + new TravelMailingRecipient('erika.muster@example.com', 'Erika', 'Muster'), + new TravelMailingRecipient('max.muster@example.com', 'Max', 'Muster'), + ], $response->data); + } + + public function testAnUnknownTravelCodeYieldsAnEmptyList(): void + { + $http = new MockHttpClient($this->jsonResponse([ + 'meta' => ['travel_code' => 'NOPE', 'row_count' => 0, 'execution_time_ms' => 3], + 'data' => [], + ])); + + $response = $this->client($http)->getMailingRecipients('NOPE'); + + self::assertSame('NOPE', $response->travelCode); + self::assertSame([], $response->data); + } + + public function testAnHttpErrorIsWrappedInADomainException(): void + { + $http = new MockHttpClient(new MockResponse('{"error":"internal error"}', ['http_code' => 500])); + + $this->expectException(BpnConnectException::class); + + $this->client($http)->getMailingRecipients('LPJMP281126'); + } + + public function testAnUnconfiguredClientFailsBeforeRequesting(): void + { + $client = new TravelsClient(new MockHttpClient(), new NullLogger()); + + $this->expectException(BpnConnectException::class); + + $client->getMailingRecipients('LPJMP281126'); + } + + /** + * @param array $payload + */ + private function jsonResponse(array $payload): MockResponse + { + return new MockResponse(json_encode($payload, JSON_THROW_ON_ERROR), [ + 'response_headers' => ['content-type' => 'application/json'], + ]); + } + + private function client(MockHttpClient $http): TravelsClient + { + return new TravelsClient($http, new NullLogger(), 'https://bpn-connect.test', 'secret'); + } +}