Compare commits

...
9 Commits
34 changed files with 1220 additions and 523 deletions

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
# TYPO3 API key (X-Api-Key), sent by the typo3.client scoped client and by app:content:sync;
# the real value per stage goes in .env.local
APP_CMS_API_KEY=
# Bearer tokens for the chatbot MCP endpoint (/mcp), one per client: "name:token,name:token"
# Bearer tokens for the chatbot MCP endpoints (/mcp, /mcp/trips), one per client: "name:token,name:token"
# (e.g. "n8n:…,claude-desktop:…"); empty refuses every request
APP_MCP_TOKENS=
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
Generated
+242 -242
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -14,3 +14,17 @@ mcp:
allowed_hosts: false
registry:
tools: ['App\Chatbot\Mcp\Tool\']
# The same trip tools without the website's editorial text: no search_content and
# get_document, and a get_trip that leaves out the product and hotel page texts.
# Same tokens as /mcp; a client picks the variant by the path it is pointed at.
chatbot_trips:
name: 'MyE&P chatbot (trips only)'
version: '1.0.0'
instructions: 'Answers questions about the trips of E&P Reisen and its brands: dates, rooms, prices, availability, pickups and inclusions, all from the booking system. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. Website texts such as descriptions, FAQs and terms are not available here: for anything the tools do not answer, refer the user to the url of the trip.'
http:
path: /mcp/trips
allowed_hosts: false
registry:
tools:
- 'App\Chatbot\Mcp\Tool\SearchTripsTool'
- 'App\Chatbot\Mcp\TripsOnly\'
+5 -5
View File
@@ -22,9 +22,9 @@ security:
users: []
role_hierarchy:
# ROLE_CUSTOMER_EXPERT gates the booking-draft and log surfaces, which used to be
# ROLE_ADMIN only. Inheriting it here is what keeps those surfaces open to administrators
# after the #[IsGranted] attributes moved to the narrower role.
# ROLE_CUSTOMER_EXPERT gates the customer-service surfaces (booking drafts, travel
# mailing). Inheriting it here is what keeps them open to administrators: the booking
# drafts used to be ROLE_ADMIN only before their #[IsGranted] moved to the narrower role.
ROLE_ADMIN:
- ROLE_GROUPS_ADMIN
- ROLE_CUSTOMER_EXPERT
@@ -51,7 +51,7 @@ security:
http_basic:
realm: 'Mailjet Webhook'
mcp:
pattern: ^/mcp$
pattern: ^/mcp(/trips)?$
stateless: true
provider: mcp_provider
access_token:
@@ -69,7 +69,7 @@ security:
# Note: Only the *first* access control that matches will be used
access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
- { path: ^/mcp$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
- { path: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
+3 -8
View File
@@ -181,14 +181,6 @@ services:
method: createMainMenu
alias: admin_main
App\Menu\GroupsMenuBuilder:
arguments:
$factory: '@knp_menu.factory'
tags:
- name: knp_menu.menu_builder
method: createMainMenu
alias: groups_main
App\Service\BpnXmlSyncManager:
arguments:
$xmlSource: '@xml_source.storage'
@@ -435,6 +427,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)%'
+39 -3
View File
@@ -13,7 +13,8 @@ TYPO3 sends no dates or prices, and nothing flows back to TYPO3.
## Endpoint
`POST /mcp` (Streamable HTTP; `GET`/`DELETE` are part of the transport), served by `symfony/mcp-bundle`
from `config/packages/mcp.yaml`. Tools only, no resources or prompts.
from `config/packages/mcp.yaml`. Tools only, no resources or prompts. `POST /mcp/trips` is the same
server without the editorial content, see [Trips only](#trips-only-mcptrips).
Authentication is a static bearer token per client, `Authorization: Bearer <token>`, checked by
`App\Security\McpAccessTokenHandler` on the `mcp` firewall. `APP_MCP_TOKENS` lists the clients as
@@ -39,11 +40,28 @@ that prices and availability are a snapshot (`dataAsOf`) and only the booking pa
| Tool | Use | Returns |
|---|---|---|
| `search_trips` | The website's travel search: which trips exist, when, where, how much | One result per trip × hotel × travel date (default 10, max 25), with "ab" price, nights, inclusions, room types, availability, link; `total` and `dataAsOf` |
| `search_trips` | The website's travel search: which trips exist, when, where, how much | One result per trip × hotel × travel date (default 10, max 25), with trip `code` and `hotelCode`, "ab" price, nights, inclusions, room types, availability, link; `total` and `dataAsOf` |
| `get_trip` | One trip by product `code`, or one date of it by date code such as `SSLMP061226` (optionally `hotelCode`) | Product text with FAQs, hotels with facts, all upcoming dates with bookable rooms, pickups for the first 3 dates |
| `search_content` | Questions not about dates or prices | Up to 8 hits with a ~600-character excerpt |
| `get_document` | The full text of one `search_content` hit, by `url` | Markdown body, truncated at 20 000 characters |
### Trips only: `/mcp/trips`
A second server (`chatbot_trips` in `config/packages/mcp.yaml`) for clients that must not answer from
editorial website text, which can contradict the booking system, most visibly on prices. A client picks
the variant by the path it is pointed at; tokens, rate limit and log are those of `/mcp`.
- Only `search_trips` and `get_trip`. `search_content` and `get_document` do not exist here.
- `get_trip` (`App\Chatbot\Mcp\TripsOnly\GetTripTool`) leaves out the product `description`, which
includes the FAQs, and each hotel's `description`.
- What remains from TYPO3 is structured: trip and hotel names, `url`, `site`, destination, concept and
the hotel `facts` (beds, altitude, distances, sauna, wifi, …; no prices). Which trips exist is still
decided by TYPO3's products and their `searchable` flag, and the free-text `query` of `search_trips`
still falls back to the page texts to find a trip, without returning any of it.
- The server instructions tell the model to refer to the trip's `url` for anything the tools don't answer.
To give a client the editorial content back, point it at `/mcp` again.
Editorial text (product descriptions, FAQs, pages) is maintained by hand in TYPO3 and can lag behind BusPro, e.g. an
old price or a changed inclusion. The server instructions and the tool descriptions therefore tell the model that the
structured trip fields of `search_trips` and `get_trip` override whatever the text says about dates, prices, pickups
@@ -57,6 +75,10 @@ Trip rules (`App\Chatbot\TripCatalog`):
- TYPO3 products are joined to the export on `product.code`. Several products can share a code, and
each is listed as its own trip. Hotels are joined on any entry of the TYPO3 hotel's `codes` (BusPro
uses the matchcode aliases). A code that is one hotel's own and another's alias resolves to its owner.
- BusPro has hotel names of its own (`hotel.xml`, joined on the hotel id). They are internal wording
("NEU Sportclub Lederer Wochenenden und Kurztrips", "Vacanceole Résidence L'Edelweiss"), so a hotel is
shown under its TYPO3 title, and under BusPro's name only when TYPO3 has no hotel for its code.
`query` searches both. Trips have no name in BusPro's export, only a code; their names are TYPO3's.
- A room is bookable unless its status is `Buchungsstop`. The baby row (room code `Baby`) is not a room.
`available` counts rooms, so the places of a date at a hotel are `Σ available × maxPax` over its
bookable rooms. The tools never output room counts, only `frei` or `auf Anfrage`.
@@ -91,6 +113,20 @@ hotel in `App\Model\Chatbot\TravelDateFacts`:
- **Inclusions:** selection groups 7 (Skipass), 71 (board), 86 (Programm), 87 (Kurse), 89 (Events), and the
outbound bus when its price is 0. `bus: true` asks for any bookable outbound bus.
- **Nights:** days between start and end. The site's "short" is ≤ 4 and its "long" is ≥ 5.
- **`query`:** the site has no free text. A code or a name is taken at its word, so that naming a trip or
hotel returns that one and not every page mentioning it. The first of these that finds anything decides
(`TripCatalog::resolveQuery()`, `App\Chatbot\Search\NameQuery`):
1. a product code, a hotel code or alias, or a date code;
2. names holding every word of the query, compared without accents, case and German stopwords, each
word as a prefix: "Lederer" is "Sportclub Lederer", "Chaviere" is "Val Chavière". Names are those of
products, hotels (TYPO3's and BusPro's), cities, regions, countries and concepts;
3. names the query spells out among other words ("Hotel Chamois d'Or in Val Thorens"), where the most
specific kind wins: product or hotel, then city, region, country or concept;
4. the page texts of those records, every term required, then any term.
A matching hotel selects its own travel dates, not all hotels of its trips. A destination or concept
selects its trips. Matches of one step are combined, so "Österreich Sportclub" is everything in Austria
plus every Sportclub; `country` and `concept` narrow instead.
- **Order:** by start date, then "ab" price. The site sorts by concept first, which the feed doesn't carry.
- Results link to the product page. Links with the hotel preselected and the site's "Restplätze" badge need
two fields the TYPO3 content feed doesn't send yet (`hotelUrls`, `lowContingent`).
@@ -152,4 +188,4 @@ After a complete run that changed something, the codes the trip join cannot plac
|---|---|
| `APP_CMS_API_BASE_URL` | TYPO3 base URL per stage (also used by `CmsDataProvider`) |
| `APP_CMS_API_KEY` | TYPO3 `X-Api-Key`: the content feed and its `contentUrl` pages, and `typo3.client` (`CmsDataProvider`) |
| `APP_MCP_TOKENS` | Bearer tokens for `/mcp`, one per client: `name:token,name:token` |
| `APP_MCP_TOKENS` | Bearer tokens for `/mcp` and `/mcp/trips`, one per client: `name:token,name:token` |
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
readonly class TravelMailingRecipient
{
public function __construct(
public string $email,
public string $firstName,
public string $lastName,
) {
}
}
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
readonly class TravelMailingResponse
{
/**
* @param TravelMailingRecipient[] $data
*/
public function __construct(
public string $travelCode,
public array $data,
) {
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\Model\TravelMailingResponse;
class TravelsClient extends AbstractApiClient
{
private const string MAILING_PATH = '/api/v1/travels/mailing';
/**
* An unknown travel code yields an empty list, not an error: bpn-connect cannot tell it
* apart from a travel without confirmed participants.
*/
public function getMailingRecipients(string $travelCode): TravelMailingResponse
{
$payload = $this->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);
}
}
+2 -2
View File
@@ -19,7 +19,7 @@ use Mcp\Schema\ToolAnnotations;
#[McpTool(
name: 'search_trips',
description: 'The website\'s travel search: find bookable trips by travel period, destination, concept, price, group size, accommodation, board, room type, duration and bus. Use for any question about which trips exist, when, where or how much. Returns one result per trip, hotel and travel date with the "ab" price per person in EUR, nights, what is included, room types and a link; get_trip gives the details of one. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
description: 'The website\'s travel search: find bookable trips by travel period, destination, concept, price, group size, accommodation, board, room type, duration and bus. Use for any question about which trips exist, when, where or how much. Returns one result per trip, hotel and travel date with the "ab" price per person in EUR, nights, what is included, room types and a link; get_trip gives the details of one, by its code and, for one hotel, its hotelCode. Prices and availability are a snapshot as of dataAsOf; the booking page is binding.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class SearchTripsTool
@@ -32,7 +32,7 @@ final class SearchTripsTool
}
public function __invoke(
#[Schema(description: 'Free text matched against trip, hotel, destination and concept names and texts, e.g. "Val Thorens" or "Snowboardkurs".')]
#[Schema(description: 'A trip, hotel, destination or concept name, or a trip, hotel or date code, e.g. "Sportclub Lederer", "Chamois d\'Or", "Val Thorens", "SSLMP". A hotel name returns only that hotel. Use the name alone, without a question around it. Words that name nothing are looked up in the page texts, e.g. "Snowboardkurs".')]
?string $query = null,
#[Schema(description: 'A named travel period of the website search: Vorsaison, um Weihnachten, Silvester, 1 Januarwoche, Ostern, Nachsaison. Use instead of dateFrom/dateTo.')]
?string $period = null,
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Mcp\TripsOnly;
use App\Chatbot\Mcp\ToolCallRunner;
use App\Chatbot\TripCatalog;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Capability\Attribute\Schema;
use Mcp\Exception\ToolCallException;
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\ToolAnnotations;
/**
* get_trip of the trips-only server (/mcp/trips): the same trip without the product and
* hotel page texts, whose prices and inclusions can contradict the booking system.
*/
#[McpTool(
name: 'get_trip',
description: 'Full details of one trip: destination, hotels with their facts, all upcoming dates with rooms, prices per person in EUR and availability, pickup points and departure times. Pickup surcharges are per person and per direction: surchargeOutbound for the way there, surchargeReturn for the way back, surchargeRoundTrip for both; quote the round trip unless the user travels one way only. Use after search_trips, or when the user names a specific trip or a booked date (date codes such as SSLMP061226 narrow the answer to that date). Prices and availability are a snapshot as of dataAsOf; the booking page is binding. For anything this does not answer, refer the user to the trip\'s url.',
annotations: new ToolAnnotations(readOnlyHint: true, openWorldHint: false),
)]
final class GetTripTool
{
public function __construct(
private readonly TripCatalog $catalog,
private readonly ToolCallRunner $runner,
) {
}
public function __invoke(
#[Schema(description: 'Trip code as returned by search_trips, or a date code (dateCode) for one date of a trip.', minLength: 1)]
string $code,
#[Schema(description: 'Narrows the dates to one hotel when a trip has several.')]
?string $hotelCode = null,
): CallToolResult {
return $this->runner->run('get_trip', get_defined_vars(), function () use ($code, $hotelCode): array {
$trip = $this->catalog->get(trim($code), $hotelCode, editorial: false);
if (null === $trip) {
throw new ToolCallException(sprintf('No trip or date with code "%s". Use search_trips to find trip codes.', $code));
}
return $trip;
});
}
}
+6 -4
View File
@@ -19,7 +19,7 @@ final class ContentSearchQuery
private const MIN_TOKEN_LENGTH = 3;
private const MAX_TERMS = 12;
private const STOPWORDS = [
public const STOPWORDS = [
'aber', 'alle', 'allem', 'allen', 'aller', 'alles', 'als', 'also', 'am', 'an', 'ander', 'andere', 'anderen',
'auch', 'auf', 'aus', 'bei', 'beim', 'bin', 'bis', 'bist', 'da', 'damit', 'dann', 'das', 'dass', 'dein',
'deine', 'dem', 'den', 'denn', 'der', 'des', 'dessen', 'die', 'dies', 'diese', 'diesem', 'diesen', 'dieser',
@@ -55,12 +55,14 @@ final class ContentSearchQuery
}
/**
* The boolean-mode query, or null when nothing searchable is left.
* The boolean-mode query, or null when nothing searchable is left. With $allTerms a
* document has to match every term, which a name or a phrase wants and a question
* does not.
*/
public static function booleanQuery(string $text): ?string
public static function booleanQuery(string $text, bool $allTerms = false): ?string
{
$terms = self::terms($text);
return [] === $terms ? null : implode(' ', array_map(static fn (string $term): string => $term.'*', $terms));
return [] === $terms ? null : implode(' ', array_map(static fn (string $term): string => ($allTerms ? '+' : '').$term.'*', $terms));
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Chatbot\Search;
use Symfony\Component\String\UnicodeString;
/**
* Compares free text with the names of trips, hotels, destinations and concepts.
*
* Names are compared word by word, without accents and case ("Chaviere" is "Chavière"),
* and without German stopwords. Unlike the full-text index, two-letter words count: they
* tell "Chamois d'Or" from "Chalets Chamois".
*/
final class NameQuery
{
private const MIN_TOKEN_LENGTH = 2;
/**
* @return list<string>
*/
public static function tokens(string $text): array
{
$words = preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($text), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
$tokens = [];
foreach ($words as $word) {
if (in_array($word, ContentSearchQuery::STOPWORDS, true)) {
continue;
}
$token = (new UnicodeString($word))->ascii()->lower()->toString();
if (strlen($token) >= self::MIN_TOKEN_LENGTH || ctype_digit($token)) {
$tokens[$token] = true;
}
}
return array_map(strval(...), array_keys($tokens));
}
/**
* Every word of the query begins a word of the name: "Arolles" is within
* "3* Hotel Les Arolles (inkl. VP)".
*
* @param list<string> $query
* @param list<string> $name
*/
public static function within(array $query, array $name): bool
{
if ([] === $query || [] === $name) {
return false;
}
foreach ($query as $word) {
if ([] === array_filter($name, static fn (string $nameWord): bool => str_starts_with($nameWord, $word))) {
return false;
}
}
return true;
}
/**
* The query spells the name out among other words: "Hotel Chamois d'Or in Val Thorens"
* contains "Chamois d'Or".
*
* @param list<string> $query
* @param list<string> $name
*/
public static function contains(array $query, array $name): bool
{
return [] !== $name && [] === array_diff($name, $query);
}
}
+202 -46
View File
@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Chatbot;
use App\BusProNet\Utility\DateCodeUtility;
use App\BusProNet\XmlLoader\HotelLoader;
use App\Chatbot\Search\ContentSearchQuery;
use App\Chatbot\Search\NameQuery;
use App\Entity\ContentDocument;
use App\Enum\HotelType;
use App\Enum\RoomType;
@@ -24,9 +26,11 @@ use Symfony\Component\Clock\ClockInterface;
* The join is on the product code; several TYPO3 products may share one, and each is its
* own trip. Hotels are joined on any of the TYPO3 hotel's `codes`, because BusPro uses the
* matchcode aliases; a code carried by more than one hotel resolves to the one whose own
* `code` it is.
* `code` it is. BusPro's own hotel names (hotel.xml) are internal wording, so they are
* only searched, and shown just where TYPO3 has no hotel for a code.
*
* search() mirrors the website's travel search, get() details one trip. A room is bookable
* search() mirrors the website's travel search, get() details one trip. Free text is read
* as a code or a name before it is looked up in the page texts, see resolveQuery(). A room is bookable
* unless its status is `Buchungsstop`; places and prices are derived per date x hotel by
* TravelDateFacts. Room counts never leave this class: the website does not show them, so
* the tools say only whether a date is freely bookable or on request.
@@ -47,6 +51,7 @@ class TripCatalog
public function __construct(
private readonly ProductExportBuilder $exportBuilder,
private readonly ContentDocumentRepository $repository,
private readonly HotelLoader $hotelLoader,
private readonly ClockInterface $clock,
private readonly LoggerInterface $logger,
) {
@@ -63,6 +68,7 @@ class TripCatalog
{
$catalog = $this->load();
$today = $this->today();
$named = null === $criteria->query ? null : $this->resolveQuery($catalog, $criteria->query);
$results = [];
foreach ($this->candidateProducts($catalog, $criteria) as $product) {
@@ -75,6 +81,15 @@ class TripCatalog
$hotel = $catalog['hotelsByCode'][$this->normalize($row['hotel']['code'] ?? null)][0] ?? null;
$hotelData = $hotel?->getData() ?? [];
if (null !== $named
&& !isset($named['products'][$product->getUid()])
&& !isset($named['hotels'][$hotel?->getUid() ?? 0])
&& !isset($named['busProHotels'][$row['hotel']['busProId'] ?? 0])
&& (null === $named['dateCode'] || $named['dateCode'] !== $this->normalize($row['date']['code'] ?? null))) {
continue;
}
$facts = TravelDateFacts::fromExportRow($row);
if (!$this->matches($row, $facts, HotelType::tryFrom((int) ($hotelData['type'] ?? 0)), $criteria)) {
@@ -87,7 +102,8 @@ class TripCatalog
'product' => $header['name'],
'code' => $header['code'],
'dateCode' => $row['date']['code'],
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotel' => $this->hotelName($catalog, $hotel, $row),
'hotelCode' => $row['hotel']['code'],
'accommodation' => HotelType::tryFrom((int) ($hotelData['type'] ?? 0))?->label(),
'destination' => $header['destination'],
'concept' => $header['concept'],
@@ -126,9 +142,12 @@ class TripCatalog
* booking documents (SSLMP061226 is product SSLMP on 06.12.26); it narrows the trip to
* that date, shown even when it has already started.
*
* Without $editorial the product and hotel page texts are left out, and only what is
* structured remains: names, links, hotel facts and everything from the booking system.
*
* @return array<string, mixed>|null
*/
public function get(string $code, ?string $hotelCode = null): ?array
public function get(string $code, ?string $hotelCode = null, bool $editorial = true): ?array
{
$catalog = $this->load();
$code = (new DateCodeUtility())->sanitize(trim($code));
@@ -175,8 +194,8 @@ class TripCatalog
static fn (ContentDocument $other): array => ['site' => $other->getSite(), 'url' => $other->getUrl()],
array_filter(array_slice($products, 1), static fn (ContentDocument $other): bool => null !== $other->getUrl()),
)),
'description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT),
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product),
] + ($editorial ? ['description' => $this->truncate($product->getBodyMd(), self::BODY_LIMIT)] : []) + [
'hotels' => $this->hotelDetails($catalog, $hotelCodes, $product, $editorial),
'dates' => $this->dates($catalog, $rows),
'dataAsOf' => $this->dataAsOf($catalog['export']),
];
@@ -225,6 +244,7 @@ class TripCatalog
* productsByCode: array<string, list<ContentDocument>>,
* hotelsByCode: array<string, list<ContentDocument>>,
* rowsByCode: array<string, list<array<string, mixed>>>,
* busProHotels: array<int, array{code: string, name: ?string}>,
* }
*/
private function load(): array
@@ -261,11 +281,21 @@ class TripCatalog
}
$rowsByCode = [];
$busProHotels = [];
$masterData = $this->hotelLoader->loadAll();
foreach ($export->rows as $row) {
$code = $this->normalize($row['product']['code'] ?? null);
if ('' !== $code) {
$rowsByCode[$code][] = $row;
}
$hotelId = (int) ($row['hotel']['busProId'] ?? 0);
if (0 !== $hotelId && !isset($busProHotels[$hotelId])) {
// BusPro breaks names over lines ("Vacanceole\nRésidence L'Edelweiss").
$name = trim((string) preg_replace('/\s+/u', ' ', isset($masterData[$hotelId]) ? (string) $masterData[$hotelId]->name : ''));
$busProHotels[$hotelId] = ['code' => $this->normalize($row['hotel']['code'] ?? null), 'name' => '' === $name ? null : $name];
}
}
return [
@@ -274,12 +304,14 @@ class TripCatalog
'productsByCode' => $productsByCode,
'hotelsByCode' => $hotelsByCode,
'rowsByCode' => $rowsByCode,
'busProHotels' => $busProHotels,
];
}
/**
* Published products narrowed by everything the content index can resolve: site, day
* trip, concept, destination and free text.
* trip, concept and destination. Free text is applied per travel date, since it may
* name one hotel of a trip.
*
* @param array<string, mixed> $catalog
*
@@ -294,7 +326,6 @@ class TripCatalog
$countryUids = null === $criteria->country ? null : $this->matchingUids($records, ['country'], $criteria->country, matchCode: true);
$regionUids = null === $criteria->region ? null : $this->matchingUids($records, ['region'], $criteria->region);
$cityUids = null === $criteria->region ? null : $this->matchingUids($records, ['city'], $criteria->region);
$queryUids = null === $criteria->query ? null : $this->productUidsMatchingText($records, $criteria->query);
$products = [];
@@ -312,8 +343,7 @@ class TripCatalog
|| (null !== $criteria->site && $record->getSite() !== $criteria->site)
|| (null !== $criteria->daytrip && (bool) ($data['daytrip'] ?? false) !== $criteria->daytrip)
|| (null !== $conceptUids && !in_array($data['conceptUid'] ?? null, $conceptUids, true))
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))
|| (null !== $queryUids && !in_array($record->getUid(), $queryUids, true))) {
|| (null !== $countryUids && !in_array($data['countryUid'] ?? null, $countryUids, true))) {
continue;
}
@@ -363,27 +393,155 @@ class TripCatalog
}
/**
* Products reached by a full-text match on themselves or on a hotel, destination or
* concept they belong to.
* What a free-text query asks for: whole trips, single hotels of trips, or one travel
* date. Null when it restricts nothing.
*
* @param array<string, ContentDocument> $records
* A code or a name is taken at its word, so naming a trip or hotel returns that one and
* not every page that mentions it. In order, stopping at the first that finds anything:
* a product, hotel or date code; names holding every word of the query; names the query
* spells out among other words; and only then the page texts.
*
* @return list<int>
* @param array<string, mixed> $catalog
*
* Hotels are known twice: by their TYPO3 record and by BusPro's own name and code, which
* also covers a hotel TYPO3 has no record for
*
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: ?string}|null
*/
private function productUidsMatchingText(array $records, string $text): array
private function resolveQuery(array $catalog, string $text): ?array
{
$booleanQuery = ContentSearchQuery::booleanQuery($text);
/** @var array<string, ContentDocument> $records */
$records = $catalog['records'];
if (null === $booleanQuery) {
return array_values(array_map(static fn (ContentDocument $record): int => $record->getUid(), array_filter($records, static fn (ContentDocument $record): bool => 'product' === $record->getType())));
$matched = $this->recordsWithCode($catalog, $text) ?? $this->recordsNamed($catalog, $text);
if (null !== $matched) {
return $this->tripsOf($records, $matched);
}
$dateCode = (new DateCodeUtility())->sanitize(trim($text));
foreach ($catalog['export']->rows as $row) {
if ($this->normalize($row['date']['code'] ?? null) === $dateCode) {
return ['products' => [], 'hotels' => [], 'busProHotels' => [], 'dateCode' => $dateCode];
}
}
$terms = ContentSearchQuery::terms($text);
if ([] === $terms) {
return null;
}
// Every term first: "Sauna Whirlpool" means both. A question rarely uses the page's
// own words for everything, so any term is the fallback.
foreach (count($terms) > 1 ? [true, false] : [false] as $allTerms) {
$matched = [];
foreach ($this->repository->matchRecords((string) ContentSearchQuery::booleanQuery($text, $allTerms), self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
}
if ([] !== $matched) {
break;
}
}
return $this->tripsOf($records, $matched);
}
/**
* The products and the hotel carrying the text as their code, hotel aliases included.
* Matches are keyed by record type, BusPro's hotels as `busProHotel` by their id.
*
* @param array<string, mixed> $catalog
*
* @return array<string, array<int, true>>|null
*/
private function recordsWithCode(array $catalog, string $text): ?array
{
$code = (new DateCodeUtility())->sanitize(trim($text));
$matched = [];
foreach ($this->repository->matchRecords($booleanQuery, self::RECORD_TYPES, self::LANGUAGE) as $hit) {
$matched[$hit['type']][$hit['uid']] = true;
foreach ($catalog['productsByCode'][$code] ?? [] as $product) {
$matched['product'][$product->getUid()] = true;
}
$uids = [];
if (isset($catalog['hotelsByCode'][$code][0])) {
$matched['hotel'][$catalog['hotelsByCode'][$code][0]->getUid()] = true;
}
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
if ($busProHotel['code'] === $code) {
$matched['busProHotel'][$hotelId] = true;
}
}
return [] === $matched ? null : $matched;
}
/**
* The records the text names. A name holding every word of the query wins: "Lederer"
* is "Sportclub Lederer". Otherwise the query may spell a name out among other words,
* and there the most specific kind wins, so that "Sportclub Lederer Saalbach" is the
* hotel and not everything in Saalbach.
*
* @param array<string, mixed> $catalog
*
* @return array<string, array<int, true>>|null
*/
private function recordsNamed(array $catalog, string $text): ?array
{
$query = NameQuery::tokens($text);
$names = [];
foreach ($catalog['records'] as $record) {
$names[] = [$record->getType(), $record->getUid(), $record->getTitle()];
}
foreach ($catalog['busProHotels'] as $hotelId => $busProHotel) {
$names[] = ['busProHotel', $hotelId, $busProHotel['name'] ?? ''];
}
$within = [];
$contained = [];
foreach ($names as [$type, $id, $title]) {
$name = NameQuery::tokens($title);
if (NameQuery::within($query, $name)) {
$within[$type][$id] = true;
} elseif (NameQuery::contains($query, $name)) {
$contained[$type][$id] = true;
}
}
if ([] !== $within) {
return $within;
}
foreach ([['product', 'hotel', 'busProHotel'], ['city'], ['region'], ['country', 'concept']] as $types) {
$matched = array_intersect_key($contained, array_flip($types));
if ([] !== $matched) {
return $matched;
}
}
return null;
}
/**
* Matched records as trips: a product, destination or concept stands for the whole
* trips, a hotel only for itself, since a trip may have many.
*
* @param array<string, ContentDocument> $records
* @param array<string, array<int, true>> $matched by type and uid
*
* @return array{products: array<int, true>, hotels: array<int, true>, busProHotels: array<int, true>, dateCode: null}
*/
private function tripsOf(array $records, array $matched): array
{
$products = [];
foreach ($records as $record) {
if ('product' !== $record->getType()) {
@@ -391,33 +549,18 @@ class TripCatalog
}
$data = $record->getData() ?? [];
$hotelUids = is_array($data['hotelUids'] ?? null) ? $data['hotelUids'] : [];
$regionUids = array_merge([$data['regionUid'] ?? null], is_array($data['additionalRegionUids'] ?? null) ? $data['additionalRegionUids'] : []);
$hit = isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($hotelUids, array_keys($matched['hotel'] ?? []))
if (isset($matched['product'][$record->getUid()])
|| [] !== array_intersect($regionUids, array_keys($matched['region'] ?? []))
|| isset($matched['country'][$data['countryUid'] ?? -1])
|| isset($matched['city'][$data['cityUid'] ?? -1])
|| isset($matched['concept'][$data['conceptUid'] ?? -1]);
// Hotels also name their products, for products whose hotelUids miss them.
if (!$hit) {
foreach (array_keys($matched['hotel'] ?? []) as $hotelUid) {
$hotelProducts = ($records['hotel:'.$hotelUid] ?? null)?->getData()['productUids'] ?? [];
if (is_array($hotelProducts) && in_array($record->getUid(), $hotelProducts, true)) {
$hit = true;
break;
}
}
}
if ($hit) {
$uids[] = $record->getUid();
|| isset($matched['concept'][$data['conceptUid'] ?? -1])) {
$products[$record->getUid()] = true;
}
}
return $uids;
return ['products' => $products, 'hotels' => $matched['hotel'] ?? [], 'busProHotels' => $matched['busProHotel'] ?? [], 'dateCode' => null];
}
/**
@@ -495,7 +638,7 @@ class TripCatalog
*
* @return list<array<string, mixed>>
*/
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product): array
private function hotelDetails(array $catalog, array $hotelCodes, ContentDocument $product, bool $editorial): array
{
$hotels = [];
@@ -506,8 +649,9 @@ class TripCatalog
}
}
// A trip without upcoming dates still describes its hotels.
if ([] === $hotels) {
// A trip without upcoming dates still describes its hotels. One whose dates are at
// a hotel the website does not know has none to describe.
if ([] === $hotelCodes) {
foreach ((array) ($product->getData()['hotelUids'] ?? []) as $uid) {
$hotel = is_int($uid) ? ($catalog['records']['hotel:'.$uid] ?? null) : null;
if (null !== $hotel) {
@@ -521,8 +665,7 @@ class TripCatalog
'name' => $hotel->getTitle(),
'url' => $hotel->getUrl(),
'facts' => is_array($hotel->getData()['facts'] ?? null) ? array_filter($hotel->getData()['facts'], static fn (mixed $value): bool => null !== $value && '' !== $value) : [],
'description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT),
], $hotels));
] + ($editorial ? ['description' => $this->truncate($hotel->getBodyMd(), self::HOTEL_BODY_LIMIT)] : []), $hotels));
}
/**
@@ -566,7 +709,7 @@ class TripCatalog
];
$dates[$dateId]['hotels'][] = [
'hotel' => $hotel?->getTitle() ?? $row['hotel']['code'],
'hotel' => $this->hotelName($catalog, $hotel, $row),
'hotelCode' => $row['hotel']['code'],
'rooms' => $rooms,
'bookable' => [] !== $rooms,
@@ -642,6 +785,19 @@ class TripCatalog
return array_values(array_filter(array_unique(array_map($this->normalize(...), $codes))));
}
/**
* The website's name for the hotel of a travel date, else BusPro's, else its code.
*
* @param array<string, mixed> $catalog
* @param array<string, mixed> $row
*/
private function hotelName(array $catalog, ?ContentDocument $hotel, array $row): string
{
return $hotel?->getTitle()
?? $catalog['busProHotels'][$row['hotel']['busProId'] ?? 0]['name']
?? (string) $row['hotel']['code'];
}
private function dataAsOf(Export $export): string
{
return ($export->sourceGeneratedAt ?? $export->generatedAt)->format('c');
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Security\Voter\AdministrativeAccessVoter;
use App\Service\XmlDumpReader;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class DownloadController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
@@ -8,13 +8,14 @@ use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\LogEntryFilterType;
use App\Form\Model\Filter\LogEntryFilterDto;
use App\Repository\LogEntryRepository;
use App\Security\Voter\AdministrativeAccessVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class FilterController extends AbstractController
{
use ListFilterTrait;
+2 -1
View File
@@ -8,6 +8,7 @@ use App\Controller\Traits\ListFilterTrait;
use App\Form\Admin\Filter\LogEntryFilterType;
use App\Form\Model\Filter\LogEntryFilterDto;
use App\Repository\LogEntryRepository;
use App\Security\Voter\AdministrativeAccessVoter;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -15,7 +16,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class IndexController extends AbstractController
{
use ListFilterTrait;
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Entity\LogEntry;
use App\Security\Voter\AdministrativeAccessVoter;
use App\Service\XmlDumpReader;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -12,7 +13,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class XmlDumpController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\TravelMailing;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\TravelsClient;
use App\Form\Admin\TravelMailingLookupType;
use App\Form\Model\TravelMailingLookupDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CUSTOMER_EXPERT')]
class IndexController extends AbstractController
{
public function __construct(
private readonly TravelsClient $travelsClient,
) {
}
#[Route('/admin/travel-mailing', name: 'app_admin_travelmailing')]
public function index(Request $request): Response
{
$lookup = new TravelMailingLookupDto();
$form = $this->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,
]);
}
}
@@ -17,7 +17,8 @@ interface DashboardWidgetProviderInterface
{
/**
* The role this widget requires, checked with isGranted() — so a widget requiring
* ROLE_GROUPS_MANAGER also appears for the roles above it in the hierarchy.
* ROLE_GROUPS_MANAGER also appears for the roles above it in the hierarchy. A voter
* attribute such as ADMINISTRATIVE_ACCESS works too, for a widget every admin-area role sees.
*/
public function getRequiredRole(): string;
@@ -9,7 +9,7 @@ use App\Entity\LogEntry;
use App\Model\DashboardWidget;
use App\Model\DashboardWidgetEntry;
use App\Repository\LogEntryRepository;
use App\Security\Role;
use App\Security\Voter\AdministrativeAccessVoter;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
@@ -37,7 +37,7 @@ class RecentLogEntriesWidgetProvider implements DashboardWidgetProviderInterface
public function getRequiredRole(): string
{
return Role::CUSTOMER_EXPERT;
return AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS;
}
public function getPriority(): int
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Form\Model\TravelMailingLookupDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Submits as GET with an empty block prefix, so a lookup is the plain URL `?travelCode=…`
* and can be bookmarked or shared.
*
* @extends AbstractType<TravelMailingLookupDto>
*/
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 '';
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
class TravelMailingLookupDto
{
#[Assert\NotBlank(message: 'Bitte einen Reisecode angeben.')]
public ?string $travelCode = null;
}
-137
View File
@@ -1,137 +0,0 @@
<?php
namespace App\Menu;
use App\Security\Voter\AdministrativeAccessVoter;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractMenuBuilder
{
public function __construct(
protected FactoryInterface $factory,
protected Security $security,
protected RequestStack $requestStack,
protected TranslatorInterface $translator,
) {
}
protected function createRootElement(): ItemInterface
{
return $this->factory->createItem('root');
}
/**
* @return array<string, mixed>
*/
protected function getDefaultRouteParameters(string $parameter = 'id', string $default = '0'): array
{
$request = $this->requestStack->getMainRequest();
return [
$parameter => $request->get($parameter, $default),
'r' => $request->get('r'),
];
}
protected function addAdminItem(ItemInterface $menu): void
{
if ($this->security->isGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)) {
$this->addDivider($menu);
$menu->addChild('zum Adminbereich', [
'route' => 'app_admin_index',
'linkAttributes' => [
'title' => 'zum Adminbereich',
],
'extras' => [
'icon' => 'user',
],
]);
}
}
protected function addManagerItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_MANAGER')) {
$this->addDivider($menu);
$menu->addChild('zum Managementbereich', [
'route' => 'app_manager_index',
'linkAttributes' => [
'title' => 'zum Managementbereich',
],
'extras' => [
'icon' => 'user',
],
]);
}
}
protected function addTeamerItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_TEAMER')) {
$this->addDivider($menu);
$menu->addChild('zum Teambereich', [
'route' => 'app_teamer_index',
'linkAttributes' => [
'title' => 'zum Teambereich',
],
'extras' => [
'icon' => 'user',
],
]);
}
}
protected function addHouseManagerItem(ItemInterface $menu): void
{
if ($this->security->isGranted('ROLE_HOUSE_MANAGER')) {
$this->addDivider($menu);
$menu->addChild('zum Hausleitungsbereich', [
'route' => 'app_house_manager_index',
'linkAttributes' => [
'title' => 'zum Hausleitungsbereich',
],
'extras' => [
'icon' => 'user',
],
]);
}
}
protected function addLogoutItem(ItemInterface $menu): void
{
$this->addDivider($menu);
$menu->addChild('MyE&P', [
'route' => 'app_account',
'linkAttributes' => [
'title' => 'zu MyE&P',
],
'extras' => [
'icon' => 'logout',
],
]);
$menu->addChild('Logout', [
'route' => 'app_logout',
'linkAttributes' => [
'title' => 'Logout',
],
'extras' => [
'icon' => 'logout',
],
]);
}
protected function addDivider(ItemInterface $menu): void
{
$menu->addChild(Uuid::v4(), [
'extras' => [
'divider' => true,
],
]);
}
}
+62 -8
View File
@@ -2,16 +2,25 @@
namespace App\Menu;
use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Uid\Uuid;
class AdminMenuBuilder extends AbstractMenuBuilder
class AdminMenuBuilder
{
public function __construct(
private readonly FactoryInterface $factory,
private readonly Security $security,
) {
}
/**
* @param array<string, mixed> $options
*/
public function createMainMenu(array $options): ItemInterface
{
$menu = $this->createRootElement();
$menu = $this->factory->createItem('root');
$menu->addChild('Dashboard', [
'route' => 'app_admin_dashboard',
@@ -35,7 +44,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder
]);
}
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
if ($this->security->isGranted('ROLE_GROUPS_MANAGER')) {
$menu->addChild('Gruppenbuchungen', [
'route' => 'app_admin_accommodationbooking',
'linkAttributes' => [
@@ -46,6 +55,8 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
],
]);
}
if ($this->security->isGranted('ROLE_GROUPS_ADMIN')) {
$menu->addChild('Gruppenhäuser', [
'route' => 'app_admin_accommodation',
'linkAttributes' => [
@@ -70,20 +81,63 @@ class AdminMenuBuilder extends AbstractMenuBuilder
]);
}
if ($this->security->isGranted('ROLE_CUSTOMER_EXPERT')) {
$menu->addChild('Logs', [
'route' => 'app_admin_log',
$menu->addChild('Reise-Mailingliste', [
'route' => 'app_admin_travelmailing',
'linkAttributes' => [
'title' => 'Logs',
'title' => 'Reise-Mailingliste',
],
'extras' => [
'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_log/']],
'icon' => 'mail',
],
]);
}
$menu->addChild('Logs', [
'route' => 'app_admin_log',
'linkAttributes' => [
'title' => 'Logs',
],
'extras' => [
'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_log/']],
],
]);
$this->addLogoutItem($menu);
return $menu;
}
private function addLogoutItem(ItemInterface $menu): void
{
$this->addDivider($menu);
$menu->addChild('MyE&P', [
'route' => 'app_account',
'linkAttributes' => [
'title' => 'zu MyE&P',
],
'extras' => [
'icon' => 'logout',
],
]);
$menu->addChild('Logout', [
'route' => 'app_logout',
'linkAttributes' => [
'title' => 'Logout',
],
'extras' => [
'icon' => 'logout',
],
]);
}
private function addDivider(ItemInterface $menu): void
{
$menu->addChild(Uuid::v4(), [
'extras' => [
'divider' => true,
],
]);
}
}
-50
View File
@@ -1,50 +0,0 @@
<?php
namespace App\Menu;
use Knp\Menu\ItemInterface;
class GroupsMenuBuilder extends AbstractMenuBuilder
{
/**
* @param array<string, mixed> $options
*/
public function createMainMenu(array $options): ItemInterface
{
$menu = $this->createRootElement();
$menu->addChild('Dashboard', [
'route' => 'app_admin_dashboard',
'linkAttributes' => [
'title' => 'Dashboard',
],
'extras' => [
'icon' => 'chart',
],
]);
$menu->addChild('Gruppenbuchungen', [
'route' => 'app_admin_accommodationbooking',
'linkAttributes' => [
'title' => 'Buchungen',
],
'extras' => [
'icon' => 'list',
'routes' => [['pattern' => '/^app_admin_accommodationbooking\//']],
],
]);
$menu->addChild('Gruppenhäuser', [
'route' => 'app_admin_accommodation',
'linkAttributes' => [
'title' => 'Gruppenhäuser',
],
'extras' => [
'icon' => 'house',
'routes' => [['pattern' => '/^app_admin_accommodation\//']],
],
]);
$this->addLogoutItem($menu);
return $menu;
}
}
@@ -0,0 +1,71 @@
{% extends 'layout_admin.html.twig' %}
{% form_theme form 'forms_admin.html.twig' %}
{% block content %}
<twig:page:heading>Reise-Mailingliste</twig:page:heading>
{{ form_start(form) }}
<div class="flex flex-wrap items-end gap-4 pb-8">
<div class="w-64">
{{ form_row(form.travelCode) }}
</div>
<button type="submit"
class="button button--primary button--small">
abrufen
</button>
</div>
{{ 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 <code>%s</code> gefunden: Reisecode unbekannt oder keine bestätigten Teilnehmer.'|format(result.travelCode|e)],
} %}
{% else %}
<p class="pb-4 text-sm text-gray-700">
{{ result.data|length }} Empfänger für <code>{{ result.travelCode }}</code>
</p>
<div class="data-table-wrapper">
<div class="data-table-wrapper__inner text-sm">
<table class="data-table">
<thead>
<tr>
<th>
Vorname
</th>
<th>
Nachname
</th>
<th>
E-Mail
</th>
</tr>
</thead>
<tbody>
{% for recipient in result.data %}
<tr>
<td>
{{ recipient.firstName }}
</td>
<td>
{{ recipient.lastName }}
</td>
<td>
<a href="mailto:{{ recipient.email }}">{{ recipient.email }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
+2 -1
View File
@@ -49,8 +49,9 @@
{% include '_partials/_flashes.html.twig' %}
<hgroup>
{% set addressee = booking.groupName ?: [booking.firstName, booking.lastName] | filter(v => v) | join(' ') %}
<h1 class="text-2xl lg:text-4xl font-bold mb-2">
{{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}
{{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}{% if addressee %}, {{ addressee }}{% endif %}
</h1>
<p class="text-2xl">
{{ ctx.accommodation.name }}
+2 -3
View File
@@ -1,7 +1,6 @@
{% extends 'base_admin.html.twig' %}
{% block body %}
{% set main_menu = is_granted('ROLE_ADMIN') ? 'admin_main' : 'groups_main' %}
<div class="max-w-screen-2xl mx-auto px-4 lg:px-8 lg:grid lg:grid-cols-4 gap-8 py-8">
<div class="lg:col-span-4 flex items-center justify-between pb-4 lg:pb-0">
<a href="{{ path(app.user ? 'app_admin_dashboard' : 'app_login') }}">
@@ -20,7 +19,7 @@
</div>
<div class="hidden lg:block sticky top-0 h-screen">
{% if app.user %}
{{ knp_menu_render(knp_menu_get(main_menu)) }}
{{ knp_menu_render(knp_menu_get('admin_main')) }}
{% endif %}
</div>
<div class="lg:col-span-3">
@@ -41,7 +40,7 @@
</div>
<div class="w-full h-full p-8 pt-0 overflow-y-scroll">
{% if app.user %}
{{ knp_menu_render(knp_menu_get(main_menu)) }}
{{ knp_menu_render(knp_menu_get('admin_main')) }}
{% endif %}
</div>
</div>
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Tests\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\TravelsClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class TravelsClientTest extends TestCase
{
public function testGetMailingRecipientsQueriesTheTravelCodeAndMapsTheRecipients(): void
{
$requests = [];
$http = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
$requests[] = [$method, $url, $options];
return $this->jsonResponse([
'meta' => ['travel_code' => 'LPJMP281126', 'row_count' => 2, 'execution_time_ms' => 12],
'data' => [
['email' => '[email protected]', 'first_name' => 'Erika', 'last_name' => 'Muster'],
['email' => '[email protected]', '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('[email protected]', 'Erika', 'Muster'),
new TravelMailingRecipient('[email protected]', '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<string, mixed> $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');
}
}
@@ -18,6 +18,11 @@ class ContentSearchQueryTest extends TestCase
);
}
public function testAllTermsCanBeRequired(): void
{
self::assertSame('+sauna* +whirlpool*', ContentSearchQuery::booleanQuery('Sauna mit Whirlpool', allTerms: true));
}
public function testTermsAreUniqueAndLowerCased(): void
{
self::assertSame(['gepäck', 'snowboard'], ContentSearchQuery::terms('Gepäck GEPÄCK Snowboard'));
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Tests\Chatbot\Search;
use App\Chatbot\Search\NameQuery;
use PHPUnit\Framework\TestCase;
class NameQueryTest extends TestCase
{
public function testTokensAreFoldedUniqueAndWithoutStopwords(): void
{
self::assertSame(['hotel', 'chamois', 'or', 'val', 'chaviere'], NameQuery::tokens('Das Hotel "Chamois d\'Or" in Val Chavière, Hotel!'));
self::assertSame(['chalet', '2100', '3'], NameQuery::tokens('Chalet 2100 (3*)'));
self::assertSame(['waldschlossli', 'grosse'], NameQuery::tokens('WALDSCHLÖSSLI Größe'));
self::assertSame([], NameQuery::tokens('Was ist das?'));
}
public function testWithinNeedsEveryQueryWordToBeginAWordOfTheName(): void
{
$name = NameQuery::tokens('3* Hotel Les Arolles (inkl. VP)');
self::assertTrue(NameQuery::within(NameQuery::tokens('arolles'), $name));
self::assertTrue(NameQuery::within(NameQuery::tokens('Hotel Aroll'), $name));
self::assertFalse(NameQuery::within(NameQuery::tokens('Sportclub Arolles'), $name));
self::assertFalse(NameQuery::within(NameQuery::tokens('rolles'), $name));
self::assertFalse(NameQuery::within([], $name));
}
public function testContainsNeedsTheWholeNameInTheQuery(): void
{
$query = NameQuery::tokens('Hotel Chamois d\'Or in Val Thorens');
self::assertTrue(NameQuery::contains($query, NameQuery::tokens('Chamois d\'Or')));
self::assertTrue(NameQuery::contains($query, NameQuery::tokens('Val Thorens')));
self::assertFalse(NameQuery::contains($query, NameQuery::tokens('Chalets Chamois')));
self::assertFalse(NameQuery::contains($query, []));
}
}
+122 -4
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Chatbot;
use App\BusProNet\Model\Hotel;
use App\BusProNet\XmlLoader\HotelLoader;
use App\Chatbot\TripCatalog;
use App\Entity\ContentDocument;
use App\Enum\ContentFetchState;
@@ -35,6 +37,7 @@ class TripCatalogTest extends TestCase
'code' => 'VTH',
'dateCode' => 'VTH2',
'hotel' => 'Hotel Zwei',
'hotelCode' => 'HOT2',
'accommodation' => null,
'destination' => ['country' => 'Frankreich'],
'concept' => 'Skiurlaub ohne Programm',
@@ -115,6 +118,91 @@ class TripCatalogTest extends TestCase
self::assertCount(0, $this->catalog($rows)->search(new TripCriteria(concept: 'party'))['results']);
}
public function testANameInTheQueryReturnsWhatItNamesAndNotWhatMentionsIt(): void
{
$rows = [
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]),
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]),
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]),
];
// Every page mentions every word: the texts must not be asked when a name answers.
$catalog = $this->catalog($rows, static fn (): array => [['type' => 'product', 'uid' => 1]]);
$search = static fn (string $query): array => array_map(
static fn (array $result): string => $result['dateCode'].'/'.$result['hotelCode'],
$catalog->search(new TripCriteria(query: $query))['results'],
);
self::assertSame(['VTH2/HOT2'], $search('Hotel Zwei'), 'a hotel stands for itself, not for its trip');
self::assertSame(['VTH2/HOT2'], $search('zwei'));
self::assertSame(['VTH2/HOT2'], $search('Gibt es das Hotel Zwei in Frankreich?'), 'spelled out among other words, the hotel beats the country');
self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('Hotel'));
self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('val thorens'));
self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('Urlaub in Frankreich'));
self::assertSame([], $search('Gruppenreise Uni X'), 'a hidden trip is not offered, and nothing else in its place');
}
public function testACodeInTheQueryFindsItsTripHotelOrDate(): void
{
$catalog = $this->catalog([
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]),
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]),
$this->row('VTH', 3, '2027-01-09', 'HOT1', [$this->room(520)]),
]);
$search = static fn (string $query): array => array_map(
static fn (array $result): string => $result['dateCode'].'/'.$result['hotelCode'],
$catalog->search(new TripCriteria(query: $query))['results'],
);
self::assertSame(['VTH2/HOT2', 'VTH2/HOT1', 'VTH3/HOT1'], $search('vth'));
self::assertSame(['VTH2/HOT1', 'VTH3/HOT1'], $search('HOT1ALIAS'));
self::assertSame(['VTH3/HOT1'], $search('vth-3'));
}
public function testHotelsAreAlsoFoundByTheirBusProNameAndShownByItWithoutAWebsiteRecord(): void
{
$catalog = $this->catalog([
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]),
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]),
$this->row('VTH', 2, '2026-12-05', 'HOT3', [$this->room(520)]),
]);
$search = static fn (string $query): array => array_map(
static fn (array $result): string => $result['hotelCode'].'/'.$result['hotel'],
$catalog->search(new TripCriteria(query: $query))['results'],
);
self::assertSame(['HOT3/Oceanpeak'], $search('oceanpeak'), 'no website record: found and shown by BusPro\'s name');
self::assertSame(['HOT3/Oceanpeak'], $search('hot3'));
self::assertSame(['HOT3/Oceanpeak'], $search('Hotel Oceanpeak in Frankreich'));
self::assertSame(['HOT1/Hotel Eins'], $search('Residence Eins'), 'BusPro\'s wording finds the hotel, the website\'s names it');
self::assertSame('Oceanpeak', $catalog->get('VTH', 'HOT3')['dates'][0]['hotels'][0]['hotel'] ?? null);
self::assertSame([], $catalog->get('VTH', 'HOT3')['hotels'] ?? null, 'not the trip\'s other hotels');
}
public function testOtherWordsAreLookedUpInThePageTextsEveryTermBeforeAnyTerm(): void
{
$rows = [
$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(500)]),
$this->row('VTH', 2, '2026-12-05', 'HOT2', [$this->room(480)]),
];
$asked = [];
$hits = static function (string $booleanQuery) use (&$asked): array {
$asked[] = $booleanQuery;
return match ($booleanQuery) {
'+sauna* +whirlpool*' => [['type' => 'hotel', 'uid' => 11]],
'kamin* whirlpool*' => [['type' => 'country', 'uid' => 2]],
default => [],
};
};
$search = fn (string $query): array => array_column($this->catalog($rows, $hits)->search(new TripCriteria(query: $query))['results'], 'hotelCode');
self::assertSame(['HOT2'], $search('Sauna Whirlpool'));
self::assertSame(['+sauna* +whirlpool*'], $asked);
self::assertSame(['HOT2', 'HOT1'], $search('Kamin Whirlpool'), 'no page has both, so any of them');
self::assertSame([], $search('Kamin'));
self::assertSame(['HOT2', 'HOT1'], $search('was ist das'), 'nothing searchable restricts nothing');
}
public function testGetJoinsHotelsOnTheirAliasesAndPreferTheOwner(): void
{
$trip = $this->catalog([
@@ -131,6 +219,23 @@ class TripCatalogTest extends TestCase
self::assertArrayNotHasKey('guide', $trip['dates'][0]);
}
public function testGetWithoutEditorialKeepsTheStructuredFieldsAndDropsThePageTexts(): void
{
$catalog = $this->catalog([$this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)])]);
$full = $catalog->get('VTH');
$plain = $catalog->get('VTH', editorial: false);
self::assertNotNull($full);
self::assertNotNull($plain);
self::assertArrayHasKey('description', $full);
self::assertArrayHasKey('description', $full['hotels'][0]);
self::assertArrayNotHasKey('description', $plain);
self::assertArrayNotHasKey('description', $plain['hotels'][0]);
unset($full['description'], $full['hotels'][0]['description']);
self::assertSame($full, $plain);
}
public function testPickupSurchargesArePerLegAndAnUnpricedLegIsFree(): void
{
$row = $this->row('VTH', 2, '2026-12-05', 'HOT1', [$this->room(450)]);
@@ -175,9 +280,10 @@ class TripCatalogTest extends TestCase
}
/**
* @param list<array<string, mixed>> $rows
* @param list<array<string, mixed>> $rows
* @param (\Closure(string): list<array{type: string, uid: int}>)|null $textHits the full-text index, by boolean query
*/
private function catalog(array $rows): TripCatalog
private function catalog(array $rows, ?\Closure $textHits = null): TripCatalog
{
$export = $this->createStub(ProductExportBuilder::class);
$export->method('build')->willReturn(new Export($rows, 0, 0, new \DateTimeImmutable('2026-10-08 09:00:00+00:00'), new \DateTimeImmutable('2026-10-08 06:00:00+00:00')));
@@ -196,9 +302,21 @@ class TripCatalogTest extends TestCase
$repository = $this->createStub(ContentDocumentRepository::class);
$repository->method('findRecords')->willReturn($records);
$repository->method('matchRecords')->willReturn([]);
$repository->method('matchRecords')->willReturnCallback($textHits ?? static fn (): array => []);
return new TripCatalog($export, $repository, new MockClock('2026-10-08 10:00:00'), new NullLogger());
$hotels = [];
foreach (['HOT1' => "Agence X\nRésidence Eins Wochenreisen", 'HOT3' => 'Oceanpeak'] as $code => $name) {
$hotel = new Hotel();
$hotel->id = crc32($code);
$hotel->code = $code;
$hotel->name = $name;
$hotels[$hotel->id] = $hotel;
}
$hotelLoader = $this->createStub(HotelLoader::class);
$hotelLoader->method('loadAll')->willReturn($hotels);
return new TripCatalog($export, $repository, $hotelLoader, new MockClock('2026-10-08 10:00:00'), new NullLogger());
}
/**
@@ -7,7 +7,7 @@ namespace App\Tests\Dashboard;
use App\Dashboard\Widget\RecentLogEntriesWidgetProvider;
use App\Entity\LogEntry;
use App\Repository\LogEntryRepository;
use App\Security\Role;
use App\Security\Voter\AdministrativeAccessVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
@@ -57,7 +57,7 @@ class RecentLogEntriesWidgetProviderTest extends TestCase
$provider = new RecentLogEntriesWidgetProvider($repository, $this->urlGenerator());
$this->assertSame(Role::CUSTOMER_EXPERT, $provider->getRequiredRole());
$this->assertSame(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS, $provider->getRequiredRole());
$this->assertNotNull($provider->build());
}