Compare commits

185 Commits
Author SHA1 Message Date
fromme 77960fe81f chore: update project dependencies 2026-11-09 18:15:36 +01:00
fromme 9ce42de4c1 refactor: drop edit-mode room assignment code 2026-11-09 18:01:25 +01:00
fromme d50995a21c fix: pass BusPro room assignments through on edit 2026-11-09 18:00:47 +01:00
fromme 897a1ac7f9 fix: show booked room count in edit summary 2026-11-09 18:00:47 +01:00
fromme 2705401393 feat: add bus-only travel mailing lists 2026-11-09 15:53:53 +01:00
fromme f5894a6bef chore: update project dependencies 2026-11-04 16:50:54 +01:00
fromme e7df5ac03a feat: use a Mailjet sub-account for travel lists 2026-11-04 16:21:15 +01:00
fromme c16f368293 feat: create Mailjet lists for travel mailings 2026-11-04 15:32:50 +01:00
fromme d63fa10a56 fix: show warning flashes in the admin layout 2026-11-04 15:32:49 +01:00
fromme e8fa71175d chore: remove obsolete booking draft repair command 2026-11-04 10:14:10 +01:00
fromme 09610889f3 fix: map live service status to export wording 2026-11-04 10:12:34 +01:00
fromme d8fe9a3875 fix: stop counting held seats in edit usage 2026-11-04 10:10:37 +01:00
fromme e9766dbdce fix: strip invalid XML characters from responses 2026-11-03 15:07:47 +01:00
fromme b047465339 chore: update project dependencies 2026-10-29 15:52:06 +01:00
fromme a60bf68345 docs: document contingent hotel code contract 2026-10-29 15:34:27 +01:00
fromme 97c6330a9a feat: sync contingents for TYPO3 calendar hotels 2026-10-29 15:05:04 +01:00
fromme ec36b61275 feat: add raw contingent availability endpoint 2026-10-29 11:19:31 +01:00
fromme 7c8dbeef48 fix: validate newly entered phone numbers 2026-10-28 13:48:51 +01:00
fromme e293bd3b1c chore: update project dependencies 2026-10-26 16:42:20 +01:00
fromme a7b845751b chore: update project dependencies 2026-10-23 16:22:50 +02:00
fromme 234f3a0bd2 fix: submit BusPro nationality token for applicant 2026-10-23 16:20:37 +02:00
fromme f794d882ca chore: disable unused ssh-agent in ddev web container 2026-10-23 16:11:53 +02:00
fromme 05e348e9b7 chore: upgrade htmx from 1.9 to 2.0 2026-10-20 14:59:54 +02:00
fromme 654214cf37 chore: decide on npm install scripts 2026-10-20 14:58:13 +02:00
fromme 8a6f73bd9b chore: bump node to 24 2026-10-20 14:53:53 +02:00
fromme a6a8783319 fix: show scrollbars only when an inner area overflows 2026-10-20 14:43:10 +02:00
fromme 1d20524678 chore: raise browser targets to tailwind 4's floor 2026-10-20 14:41:43 +02:00
fromme 88f6807399 chore: upgrade tailwind css from 3.4 to 4.3 2026-10-20 14:41:25 +02:00
fromme 91270fdfc3 chore: apply frontend dependency quick fixes 2026-10-20 14:35:56 +02:00
fromme e8d1b69407 chore: update project dependencies 2026-10-20 13:41:57 +02:00
fromme 3428408db5 chore: upgrade to webpack encore 7 2026-10-20 10:06:35 +02:00
fromme 32083c7966 fix: clear inquiry status on room reselection 2026-10-20 09:41:33 +02:00
fromme d4296f30a5 fix: list boarding surcharges per leg and count them once in the summary 2026-10-19 10:38:17 +02:00
fromme 57b86a8fbc chore: remove boarding point price levelling 2026-10-19 10:29:12 +02:00
fromme 3f941bb4bf chore: update message to use informal language 2026-10-19 10:09:41 +02:00
fromme 60de688859 chore: update project dependencies 2026-10-19 09:20:10 +02:00
fromme 69eba9767a docs: committed bookings accept participants without email on the test system 2026-10-19 09:18:22 +02:00
fromme 1d9c852113 feat: make email optional for under-16 participants except applicant 2026-10-19 09:18:22 +02:00
fromme b958d1a371 feat: offer non-travelling applicant choice only on selected travels 2026-10-19 09:18:22 +02:00
fromme 5b75938c73 fix: applicant split in edit mode, drop create-mode agency checks 2026-10-19 09:18:22 +02:00
fromme 1ff293f41f feat: split booking between participants and applicant
addresses #869f2q1vu
2026-10-19 09:18:22 +02:00
fromme 2d49212b08 chore: update project dependencies 2026-10-18 15:27:48 +02:00
fromme b67f25606a fix: use correct config key for sbw subsite 2026-10-17 15:02:46 +02:00
fromme 0ee8fafbe1 fix: name missing fields the user cannot edit on personal data page 2026-10-16 17:46:05 +02:00
fromme 6d2db1b258 feat: add the product code to the travel export 2026-10-15 17:26:12 +02:00
fromme 6f6bf3695c feat: itemised price breakdown on the accommodation booking edit page 2026-10-14 17:16:31 +02:00
fromme a5f8d75caa feat: show and sort accommodation bookings by timestamp of last update 2026-10-14 17:06:50 +02:00
fromme ff722b5cef feat: show the travel name above the mailing list 2026-10-14 14:05:42 +02:00
fromme d305774bee chore: update project dependencies 2026-10-14 12:43:27 +02:00
fromme 1a733a6ba6 feat: open the logs to every administrative role 2026-10-14 12:41:57 +02:00
fromme ade30c6b06 refactor: single admin menu gated per entry by the controller's role 2026-10-14 12:36:35 +02:00
fromme e796dd01ee feat: admin view listing a travel's mailing recipients from bpn-connect 2026-10-14 12:32:23 +02:00
fromme 63ffe0b0b4 chore: update project dependencies 2026-10-13 16:49:34 +02:00
fromme 25edb3f337 feat: show group or contact name in the offer headline 2026-10-13 10:14:55 +02:00
fromme fe40e1807e feat: chatbot trip search finds trips by trip name, hotel name and code 2026-10-13 09:29:27 +02:00
fromme 26d4140c18 feat: trips-only chatbot MCP endpoint without editorial website text 2026-10-12 14:43:46 +02:00
fromme 9f805aaec7 chore: update project dependencies 2026-10-12 10:09:21 +02:00
fromme 849a19b67a fix: exclude cancelled participants' services from price breakdown 2026-10-09 12:33:43 +02:00
fromme 553ec901f3 fix: omit zero-amount surcharges from the booking summary 2026-10-09 12:22:32 +02:00
fromme 0afa4f4855 feat: chatbot prefers structured trip fields over editorial text 2026-10-09 10:09:42 +02:00
fromme 3cb1548f2f fix: give chatbot pickups per-leg surcharges including the return leg 2026-10-09 08:38:57 +02:00
fromme ac5ca7b18b feat: enable scheduled content sync with typo3 for mcp 2026-10-08 15:35:23 +02:00
fromme 4ab2714bde feat: turn search_trips into the website's travel search 2026-10-08 13:14:12 +02:00
fromme bfdedefae3 feat: give every MCP client its own token, rate limit and log name 2026-10-08 12:37:49 +02:00
fromme 3af41c06ab feat: let get_trip resolve a date code to its trip and date 2026-10-08 11:46:59 +02:00
fromme 754360605c fix: leave access denials on stateless firewalls to the plain 401/403 2026-10-08 11:46:59 +02:00
fromme 1b4384e3f3 feat: add MCP server for the website chatbot over a mirrored TYPO3 content feed 2026-10-08 11:46:58 +02:00
fromme ff7ddf7a9e feat: drop the TYPO3 api key query parameter 2026-10-08 11:45:24 +02:00
fromme 2d876152aa fix: drop unreachable null check 2026-10-08 11:10:13 +02:00
fromme 24498e6fd1 chore: update project dependencies 2026-10-08 11:09:41 +02:00
fromme de5d80c2df feat: replace hardcoded dummy api-key with env variable 2026-10-08 11:05:42 +02:00
fromme 18d3edcd53 chore: list the active unprefixed legacy promo codes 2026-10-06 11:34:33 +02:00
fromme 56af0f921b fix: stop caching rejected voucher checks, document response codes 2026-10-06 11:29:14 +02:00
fromme a14c41de4e fix: keep icon-only buttons at least one line tall 2026-10-06 11:22:54 +02:00
fromme 9a8879dfbe fix: give buttons the input box model so they align with fields 2026-10-06 10:50:30 +02:00
fromme 21d756a2e3 feat: fold purchase and promo voucher fields into one code field 2026-10-06 10:41:30 +02:00
fromme 93512b8345 feat: add employee-only supervisor role nominated by crm selection 1487 2026-10-05 17:06:37 +02:00
fromme 5f95492a7f feat: add flag to disable hx-boost locally 2026-10-05 11:51:25 +02:00
fromme 627b190fcb feat: update voucher purchase urls to match established patterns 2026-10-05 09:10:19 +02:00
fromme e42f836d9b chore: higher resolution voucher thumbnails, drop source pdfs 2026-10-05 09:09:17 +02:00
fromme b755a6f43e refactor: split buspro ApiClient into domain clients 2026-10-02 13:55:30 +02:00
fromme 0a55544ca8 refactor: move buspro XML protocol into XmlChannel 2026-10-02 13:55:16 +02:00
fromme 3c81c79819 refactor: move buspro socket framing into SocketTransport 2026-10-02 13:55:04 +02:00
fromme e51664df6c refactor: deduplicate request building in buspro api client 2026-10-02 13:54:50 +02:00
fromme f3dd122678 feat: voucher purchase flow 2026-10-01 15:03:27 +02:00
fromme a628e96d06 chore: re-add buspro documentation 2026-10-01 12:25:18 +02:00
fromme 2bb0148e49 chore: replace nelexa/zip with maennchen/zipstream-php 2026-10-01 10:48:49 +02:00
fromme 289741d5d4 chore: bump php platform requirement to 8.5 2026-10-01 10:48:44 +02:00
fromme f070f4117c chore: update project dependencies 2026-10-01 09:50:19 +02:00
fromme 72ef3ff105 chore: bump project to php 8.5 2026-10-01 09:48:37 +02:00
fromme 1301df80c2 chore: update company address in email footer 2026-10-01 08:54:29 +02:00
fromme 56deb1e24b feat: name the accommodation booking PDF after its record label 2026-10-01 08:46:12 +02:00
fromme ec10ca3623 feat: offer the accommodation booking PDF download in every status 2026-10-01 08:46:06 +02:00
fromme 67c0c2fb0c fix: date group heading in offer summary 2026-10-01 08:45:59 +02:00
fromme eb45693257 feat: panel layout for the accommodation booking edit and detail pages 2026-09-30 18:06:45 +02:00
fromme 5fdee68ccd fix: show each admin money field's own currency 2026-09-30 18:06:39 +02:00
fromme 7001bc0cf2 feat: split nights off an accommodation booking as separate periods
addresses #869f6z36v
2026-09-30 18:06:20 +02:00
fromme 972839286c fix: never reuse a collection entry index after a removal 2026-09-30 18:05:23 +02:00
fromme ede8fd840a fix: emit export pickup times as clock readings without an offset 2026-09-30 14:52:04 +02:00
fromme 6c700ea18b chore: update project dependencies 2026-09-30 14:12:35 +02:00
fromme fe18ebf479 fix: give the product export builder the tag-aware bpn.cache pool 2026-09-30 14:09:50 +02:00
fromme 1d42565a84 feat: unfiltered product export for the website's catalogue import 2026-09-30 12:19:10 +02:00
fromme 272c811f4f feat: report the source export timestamp on every travel export page 2026-09-30 12:19:04 +02:00
fromme c2c55bc8b2 fix: answer null instead of 500 when the export marker is missing 2026-09-30 12:18:59 +02:00
fromme 8a6ce7606f feat: add a cleanup command for accounts without recent activity 2026-09-29 16:23:32 +02:00
fromme 7ec21c1cac chore: update project dependencies 2026-09-29 16:18:17 +02:00
fromme f951242465 chore: document the travel export endpoint and the new hotel fields 2026-09-29 15:16:47 +02:00
fromme 7dadb6806a feat: add a paginated travel export for the teamer app 2026-09-29 14:46:36 +02:00
fromme 6b539f43f3 fix: skip travels whose hotel is missing from the master data 2026-09-29 14:46:27 +02:00
fromme 1845441bc2 feat: expose hotel cost unit and iso country code via the api 2026-09-29 14:46:20 +02:00
fromme 33c31d088c feat: enable client credentials grant for machine-to-machine api clients 2026-09-29 14:46:13 +02:00
fromme d0e7ba017c fix: resync room selections with travel data before step 2 2026-09-29 10:59:13 +02:00
fromme be0e15b3d4 chore: update project dependencies 2026-09-29 10:12:41 +02:00
fromme f5e6d2105d feat: make boarding point price levelling switchable 2026-09-29 10:11:03 +02:00
fromme 0c6126010f fix: always adopt outbound pickup price for inbound when set 2026-09-28 16:33:36 +02:00
fromme 29f295abf6 feat: duplicate an accommodation booking into a new draft
addresses #869f6z44f
2026-09-28 08:55:01 +02:00
fromme 2aa98b81fe feat: dedicated login page for oauth2 logins 2026-09-27 13:43:17 +02:00
fromme 56adf72d12 fix: refuse implausible accommodation booking prices 2026-09-26 15:39:04 +02:00
fromme 773cd4f847 fix: keep post-booking newsletter failures out of the booking flow 2026-09-26 14:00:26 +02:00
fromme 876e69e62c feat: offer a newsletter opt-in for the other participants
addresses #869etn6c5
2026-09-26 13:53:10 +02:00
fromme 96b7b3f451 fix: anonymize user first and last names 2026-09-23 17:22:19 +02:00
fromme e751eed4a0 chore: update project dependencies 2026-09-23 16:53:49 +02:00
fromme bad916cfb7 feat: identify accounts by a uuid in the oauth2 sub claim 2026-09-23 15:17:09 +02:00
fromme cc3d32fcbb feat: gate oauth2 authorization on per-client roles 2026-09-23 13:46:56 +02:00
fromme 79709e3a3e feat: give client_credentials tokens a one-hour lifetime 2026-09-22 16:56:06 +02:00
fromme ab7d00b35d feat: emit team admin role in userinfo claims when applicable 2026-09-21 11:43:36 +02:00
fromme 679a864906 chore: update project dependencies 2026-09-21 10:10:59 +02:00
frommeandClaude Opus 5 f080d05dd6 fix: normalize the login email case
The login address was only trimmed, so the casing somebody happened to type at
their very first login was frozen into the user row forever — createOrUpdateLocalUser()
never wrote the address back. Everything downstream re-sends the stored address
rather than the one just authenticated with, which also leaked that casing into the
OAuth2 email claim and the log identities.

Harmless in practice, since BusPro matches an address case-insensitively and so does
the utf8mb4_unicode_ci column, but it left User out of step with the newsletter
entities, which have always normalized.

Fold the case once in authenticate(), which covers the BusPro calls, the lookup and a
new account alike, and write the address back on every login so an account created
before this converges instead of staying frozen. No backfill: a row nobody logs into
again is matched case-insensitively either way.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-21 10:03:06 +02:00
fromme 00637042e2 chore: adjust tests to changed role labels 2026-09-21 10:02:29 +02:00
fromme fbb3ebb8c1 fix: make bpn response failures diagnosable 2026-09-19 11:51:15 +02:00
fromme ec83ad598f feat: identify the authenticated account in the userinfo claims 2026-09-19 10:54:06 +02:00
fromme 2cb4871268 chore: update wording of role labels 2026-09-17 18:18:13 +02:00
fromme c1415dee41 chore: update project dependencies 2026-09-17 18:14:41 +02:00
fromme 540cd8eeb7 feat: reserve administrative roles for staff email addresses 2026-09-17 18:13:52 +02:00
fromme 725e32daab feat: gender role label correctly 2026-09-16 15:33:40 +02:00
fromme 160ebef39e fix: stop offering unbookable services in the booking edit flow 2026-09-16 15:28:07 +02:00
fromme 672fc30d7e doc: update bpn xml api reference 2026-09-16 15:25:50 +02:00
fromme ba4f66f117 chore: enable basic auth in staging environment 2026-09-16 15:25:16 +02:00
fromme 12fe4d8e15 feat: offer contact validation flow 2026-09-16 10:40:01 +02:00
fromme 5d7ffd7d58 feat: configurable recipient list for notifications of role nominations 2026-09-16 09:21:04 +02:00
fromme 6a8328a564 fix: cachetool 404 on deploy 2026-09-15 15:42:01 +02:00
fromme 019de4e705 feat: surface stuck booking edits to customer experts 2026-09-15 15:13:47 +02:00
fromme a1d1fdde14 feat: gate functionality for customer expert users 2026-09-15 15:13:47 +02:00
fromme 1d14f00bb7 feat: additional role 'customer expert' 2026-09-15 15:00:59 +02:00
fromme 042037867b feat: cli command to repair corrupted booking snapshots 2026-09-15 13:57:41 +02:00
fromme 5f6ff13ce7 chore: cleanup and optimize deployer config 2026-09-15 11:55:51 +02:00
fromme f08d9a9f40 chore: update project dependencies 2026-09-15 09:16:07 +02:00
fromme 7b49375efa chore: add deployer config for staging environment 2026-09-14 17:01:52 +02:00
fromme 91522ab32d fix: anonymize the person ids, not the participant slot ids 2026-09-14 15:37:38 +02:00
fromme a3e8c737de fix: key participant status like the participants they belong to 2026-09-14 15:13:21 +02:00
fromme 67c685f6c1 chore: update project dependencies 2026-09-14 15:03:33 +02:00
fromme 7aad54e6f2 fix: keep the booking room assignment when restoring an edit draft 2026-09-14 15:02:04 +02:00
fromme b41820c39d feat: email the administrators when a role nomination appears 2026-09-13 12:50:01 +02:00
fromme fab89dead6 feat: derive ROLE_EMPLOYEE from the account's email domain 2026-09-13 12:49:51 +02:00
fromme 443a3ed248 chore: update deployer config after final migration to new host 2026-09-12 08:22:11 +02:00
fromme 5b133f724d chore: cgl fixes 2026-09-11 19:37:00 +02:00
fromme 5f8a586385 fix: derive contingent change signal from the sync diff 2026-09-11 19:34:29 +02:00
fromme e863025fb1 chore: align local dev environment with prod 2026-09-11 19:24:32 +02:00
fromme 91a38faecd chore: update scheduler notification config 2026-09-11 12:58:09 +02:00
fromme 1d3263373e feat: update filename formatting of booking confirmation pdf 2026-09-09 08:39:10 +02:00
fromme f8ff4b82a3 feat: replace custom discount with absolute value in favor of percentage 2026-09-08 17:11:00 +02:00
fromme d6c2cfaf1c fix: remove incorrect validation group 2026-09-08 16:25:43 +02:00
fromme cfe901b9c2 feat: include accommodation bookings in db anonymization 2026-09-08 15:41:28 +02:00
fromme 8443dae5ad feat: additional custom discount applied to total price 2026-09-08 15:29:00 +02:00
fromme dea7a3dc97 chore: update wording of maintenance mode message 2026-09-08 13:53:08 +02:00
fromme 9e3be24c51 chore: cleanup obsolete environment variables 2026-09-08 13:02:08 +02:00
fromme e744ebbe33 chore: update project dependencies 2026-09-08 12:40:25 +02:00
fromme 45ca0d2b46 feat: maintenance mode 2026-09-08 12:35:50 +02:00
fromme f12561c62b feat: switch to sftp source for buspro xml data 2026-09-04 11:57:03 +02:00
fromme 976b30ccc0 feat: add cms product code for future use 2026-09-03 17:40:29 +02:00
fromme 198fbfe6a2 chore: prepare switching to sftp source for buspro xml data 2026-09-03 16:23:49 +02:00
fromme acb719730c feat: add root config option for sftp connection in flysystem 2026-09-03 10:24:01 +02:00
fromme 0e3d4cbedb chore: adjust deployer config for new hetzner host 2026-09-03 10:23:40 +02:00
fromme 715fae8e33 chore: add bpn xml api documentation extract for agents 2026-09-02 10:33:47 +02:00
fromme f595183c05 fix: don't require email address from participants under 16 2026-09-01 15:15:58 +02:00
fromme 98e0cfcf6f chore: remove obsolete deployer rsync fix 2026-09-01 10:58:39 +02:00
Björn Fromme e14d29f7d4 fix: restore room occupancy lost to case-sensitive xml attribute lookup 2026-08-31 14:15:21 +02:00
Björn Fromme 24df764efb chore: update project dependencies 2026-08-31 12:56:21 +02:00
Björn Fromme e3c08afe73 fix: adjust deployer config to prevent symlink loop issue with rsync 2026-08-31 12:55:28 +02:00
Björn Fromme 81b724c11a fix: false draft-restored message 2026-08-31 09:31:00 +02:00
Björn Fromme bfad61f1dd feat: upgrade phpunit to 12.5 2026-08-31 09:31:00 +02:00
Björn Fromme 010cfe56b2 feat: upgrade project to symfony 7.4 2026-08-31 09:03:44 +02:00
Björn Fromme c1fa08fed0 chore: add clickup http request file and credentials 2026-08-31 08:58:55 +02:00
Björn Fromme ec41e2b02d chore: update project dependencies 2026-08-31 08:58:34 +02:00
Björn Fromme bbc1b6c68a feat: make booking init flow entry-point post only 2026-08-29 09:37:23 +02:00
636 changed files with 41120 additions and 10730 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# Tailwind 4's floor: browsers without cascade layers, or Chrome before 111, can't render the
# app's CSS, so the JS isn't transpiled for them either.
defaults and fully supports css-cascade-layers
not chrome < 111
+48 -34
View File
@@ -1,21 +1,25 @@
name: myep
type: php type: php
docroot: public docroot: public
php_version: "8.3" php_version: "8.5"
webserver_type: apache-fpm webserver_type: apache-fpm
xdebug_enabled: false xdebug_enabled: false
additional_hostnames: additional_hostnames:
- mysnz - mysnz
- mysbw - mysbw
- myser - myser
additional_fqdns: [] additional_fqdns: [ ]
database: database:
type: mysql type: mariadb
version: "8.4" version: "11.8"
webimage_extra_packages: [ ripgrep, bubblewrap, socat ]
use_dns_when_possible: true use_dns_when_possible: true
timezone: Europe/Berlin
composer_version: "2" composer_version: "2"
web_environment: [] web_environment: [ ]
nodejs_version: "24"
corepack_enable: false corepack_enable: false
nodejs_version: "22" omit_containers: [ddev-ssh-agent]
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:
@@ -24,16 +28,16 @@ nodejs_version: "22"
# If the name is omitted, the project will take the name of the enclosing directory, # If the name is omitted, the project will take the name of the enclosing directory,
# which is useful if you want to have a copy of the project side by side with this one. # which is useful if you want to have a copy of the project side by side with this one.
# type: <projecttype> # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress # type: <projecttype> # asterios, backdrop, cakephp, codeigniter, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, drupal12, generic, joomla, laravel, magento, magento2, maho, modx, php, shopware6, silverstripe, symfony, typo3, wordpress, wp-bedrock
# See https://docs.ddev.com/en/stable/users/quickstart/ for more # See https://docs.ddev.com/en/stable/users/quickstart/ for more
# information on the different project types # information on the different project types
# docroot: <relative_path> # Relative path to the directory containing index.php. # docroot: <relative_path> # Relative path to the directory containing index.php.
# php_version: "8.3" # PHP version to use, "5.6" through "8.5" # php_version: "8.4" # PHP version to use, "5.6" through "8.5"
# You can explicitly specify the webimage but this # You can explicitly specify the webimage but this
# is not recommended, as the images are often closely tied to DDEV's' behavior, # is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades. # so this can break upgrades.
# webimage: <docker_image> # webimage: <docker_image>
@@ -43,10 +47,18 @@ nodejs_version: "22"
# database: # database:
# type: <dbtype> # mysql, mariadb, postgres # type: <dbtype> # mysql, mariadb, postgres
# version: <version> # database version, like "10.11" or "8.0" # version: <version> # database version, like "10.11" or "8.0"
# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 # MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8, 12.3
# MySQL versions can be 5.5-8.0, 8.4 # MySQL versions can be 5.5-8.0, 8.4, 9.7
# PostgreSQL versions can be 9-18 # PostgreSQL versions can be 9-18
# You can explicitly specify the dbimage but this
# is not recommended, as the images are often closely tied to DDEV's behavior,
# so this can break upgrades.
# dbimage: <docker_image>
# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason.
# Typically, this means additions to the existing db image using a .ddev/db-build/Dockerfile.*
# router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80) # router_http_port: <port> # Port to be used for http (defaults to global configuration, usually 80)
# router_https_port: <port> # Port for https (defaults to global configuration, usually 443) # router_https_port: <port> # Port for https (defaults to global configuration, usually 443)
@@ -66,8 +78,7 @@ nodejs_version: "22"
# bind_all_ports is used (normally with router disabled) # bind_all_ports is used (normally with router disabled)
# xhprof_mode: [prepend|xhgui|global] # xhprof_mode: [prepend|xhgui|global]
# Set to "xhgui" to enable XHGui features # Default is "xhgui"
# "xhgui" will become default in a future major release
# webserver_type: nginx-fpm, apache-fpm, generic # webserver_type: nginx-fpm, apache-fpm, generic
@@ -85,22 +96,24 @@ nodejs_version: "22"
# commands are executed. # commands are executed.
# composer_version: "2" # composer_version: "2"
# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 # You can set it to "" or "2" (default) for Composer v2
# to use the latest major version available at the time your container is built. # to use the latest major version available at the time your container is built.
# It is also possible to use each other Composer version channel. This includes: # It is also possible to use any other Composer version channel. This includes:
# - 2.2 (latest Composer LTS version) # - 2.2 (latest Composer LTS version)
# - stable # - stable
# - preview # - preview
# - snapshot # - snapshot
# Alternatively, an explicit Composer version may be specified, for example "2.2.18". # Alternatively, an explicit Composer version may be specified, for example "2.2.18".
# To reinstall Composer after the image was built, run "ddev debug rebuild". # To reinstall Composer after the image was built, run "ddev utility rebuild".
# nodejs_version: "22" # nodejs_root: <relative_path>
# Relative path to the directory containing the Node.js version file from the
# project root. Only used with "nodejs_version: auto" or "nodejs_version: engine".
# nodejs_version: "24"
# change from the default system Node.js version to any other version. # change from the default system Node.js version to any other version.
# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information # See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information
# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, # and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation.
# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use,
# can specify any version, and is more robust than using 'nvm'.
# corepack_enable: false # corepack_enable: false
# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm
@@ -160,9 +173,7 @@ nodejs_version: "22"
# - "global": uses the value from the global config. # - "global": uses the value from the global config.
# - "none": disables performance optimization for this project. # - "none": disables performance optimization for this project.
# - "mutagen": enables Mutagen for this project. # - "mutagen": enables Mutagen for this project.
# - "nfs": enables NFS for this project.
# #
# See https://docs.ddev.com/en/stable/users/install/performance/#nfs
# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # See https://docs.ddev.com/en/stable/users/install/performance/#mutagen
# fail_on_hook_fail: False # fail_on_hook_fail: False
@@ -192,10 +203,10 @@ nodejs_version: "22"
# The mailpit port is not normally bound on the host at all, instead being routed # The mailpit port is not normally bound on the host at all, instead being routed
# through ddev-router, but it can be bound directly to localhost if specified here. # through ddev-router, but it can be bound directly to localhost if specified here.
# webimage_extra_packages: [php7.4-tidy, php-bcmath] # webimage_extra_packages: ['php${DDEV_PHP_VERSION}-tidy', 'php${DDEV_PHP_VERSION}-yac']
# Extra Debian packages that are needed in the webimage can be added here # Extra Debian packages that are needed in the webimage can be added here
# dbimage_extra_packages: [telnet,netcat] # dbimage_extra_packages: [netcat, telnet, sudo]
# Extra Debian packages that are needed in the dbimage can be added here # Extra Debian packages that are needed in the dbimage can be added here
# use_dns_when_possible: true # use_dns_when_possible: true
@@ -207,12 +218,15 @@ nodejs_version: "22"
# project_tld: ddev.site # project_tld: ddev.site
# The top-level domain used for project URLs # The top-level domain used for project URLs
# The default "ddev.site" allows DNS lookup via a wildcard # The default "ddev.site" allows DNS lookup via a wildcard
# If you prefer you can change this to "ddev.local" to preserve
# pre-v1.9 behavior.
# ngrok_args: --basic-auth username:pass1234 # share_default_provider: ngrok
# Provide extra flags to the "ngrok http" command, see # The default share provider to use for "ddev share"
# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # Defaults to global configuration, usually "ngrok"
# Can be "ngrok" or "cloudflared" or the name of a custom provider from .ddev/share-providers/
# share_provider_args: --basic-auth username:pass1234
# Provide extra flags to the share provider script
# See https://docs.ddev.com/en/stable/users/configuration/config/#share_provider_args
# disable_settings_management: false # disable_settings_management: false
# If true, DDEV will not create CMS-specific settings files like # If true, DDEV will not create CMS-specific settings files like
@@ -274,7 +288,7 @@ nodejs_version: "22"
# override_config: false # override_config: false
# By default, config.*.yaml files are *merged* into the configuration # By default, config.*.yaml files are *merged* into the configuration
# But this means that some things can't be overridden # But this means that some things can't be overridden
# For example, if you have 'use_dns_when_possible: true'' you can't override it with a merge # For example, if you have 'use_dns_when_possible: true' you can't override it with a merge
# and you can't erase existing hooks or all environment variables. # and you can't erase existing hooks or all environment variables.
# However, with "override_config: true" in a particular config.*.yaml file, # However, with "override_config: true" in a particular config.*.yaml file,
# 'use_dns_when_possible: false' can override the existing values, and # 'use_dns_when_possible: false' can override the existing values, and
@@ -284,7 +298,7 @@ nodejs_version: "22"
# web_environment: [] # web_environment: []
# or # or
# additional_hostnames: [] # additional_hostnames: []
# can have their intended affect. 'override_config' affects only behavior of the # can have their intended effect. 'override_config' affects only behavior of the
# config.*.yaml file it exists in. # config.*.yaml file it exists in.
# Many DDEV commands can be extended to run tasks before or after the # Many DDEV commands can be extended to run tasks before or after the
+27 -14
View File
@@ -25,7 +25,7 @@ APP_SECRET=642ead390b8a38f0a017ec84d5362129
# #
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
# DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:[email protected]:3306/app?serverVersion=8.0.32&charset=utf8mb4"
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.4.0&charset=utf8mb4" DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=mariadb-11.8.9&charset=utf8mb4"
# DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8" # DATABASE_URL="postgresql://app:[email protected]:5432/app?serverVersion=16&charset=utf8"
###< doctrine/doctrine-bundle ### ###< doctrine/doctrine-bundle ###
@@ -44,6 +44,9 @@ MAILJET_API_KEY=
MAILJET_API_SECRET= MAILJET_API_SECRET=
MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST
MAILJET_DEFAULT_LIST_ID= MAILJET_DEFAULT_LIST_ID=
# Sub-account for the travel mailing lists
MAILJET_TRAVEL_MAILING_API_KEY=
MAILJET_TRAVEL_MAILING_API_SECRET=
# Development default for HTTP Basic Auth password; override in production secrets. # Development default for HTTP Basic Auth password; override in production secrets.
MAILJET_WEBHOOK_BASIC_PASSWORD=secret MAILJET_WEBHOOK_BASIC_PASSWORD=secret
APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1 APP_NEWSLETTER_CONFIRMATION_TTL_HOURS=1
@@ -82,6 +85,12 @@ APP_LEGAL_NOTICE_URL="https://www.ep-reisen.de/unternehmen/impressum-skireisever
APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/" APP_TERMS_AND_CONDITIONS_URL="https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/"
APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/" APP_PRIVACY_POLICY_URL="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/"
APP_CMS_API_BASE_URL="https://www.ep-reisen.de/" 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 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/" APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_BASE_URL="https://my.ep-reisen.de" APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]" APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
@@ -92,10 +101,17 @@ APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true APP_TRAVEL_ENABLE_FALLBACK=true
APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14 APP_TRAVEL_SNAPSHOT_RETENTION_BUFFER_DAYS=14
# Off turns every navigation into a full page load, which the web debug toolbar needs: its loader
# script never runs on a boosted body swap. Explicit hx-get/hx-post requests are unaffected.
# Only for local debugging; prod and the boosted code paths (HxRedirectResponse, error modal) need it on.
APP_HTMX_BOOST=true
# Emails # Emails
APP_DEFAULT_EMAIL_FROM=[email protected] APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected] APP_DEFAULT_EMAIL_TO=[email protected]
ACCOMMODATION_INQUIRY_EMAIL=[email protected] ACCOMMODATION_INQUIRY_EMAIL=[email protected]
# Comma-separated; recipients of the role nomination notification. Empty means disable notification.
APP_ROLE_NOMINATION_EMAILS=
# Global common defaults # Global common defaults
APP_SEASON_WINTER_FROM=2026-10-01 APP_SEASON_WINTER_FROM=2026-10-01
@@ -112,19 +128,11 @@ APP_UNDERSUBSCRIPTION_SURCHARGE_40_CHF=2.5
# Use 'O' during beta phase, switch to 'F' for production # Use 'O' during beta phase, switch to 'F' for production
DEFAULT_BOOKING_STATUS=F DEFAULT_BOOKING_STATUS=F
XML_EXPORT_PATH="%kernel.project_dir%/var/xmlexport" SFTP_XML_SOURCE_HOST=
XML_EXPORT_CONTINGENTS_PATH="%kernel.project_dir%/var/xmlexportzimmer" SFTP_XML_SOURCE_PORT=
JSON_EXPORT_PATH="%kernel.project_dir%/var/jsonexport" SFTP_XML_SOURCE_USER=
SFTP_XML_SOURCE_PASSWORD=
SFTP_XML_EXPORT_HOST= SFTP_XML_SOURCE_ROOT=
SFTP_XML_EXPORT_PORT=
SFTP_XML_EXPORT_USER=
SFTP_XML_EXPORT_PASSWORD=
SFTP_XML_EXPORT_CONTINGENTS_HOST=
SFTP_XML_EXPORT_CONTINGENTS_PORT=
SFTP_XML_EXPORT_CONTINGENTS_USER=
SFTP_XML_EXPORT_CONTINGENTS_PASSWORD=
###> league/oauth2-server-bundle ### ###> league/oauth2-server-bundle ###
OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key OAUTH_PRIVATE_KEY=%kernel.project_dir%/config/secret/private.key
@@ -137,3 +145,8 @@ OAUTH_ENCRYPTION_KEY=580084fd179e67399467f59ee96658ac
# postgresql+advisory://db_user:db_password@localhost/db_name # postgresql+advisory://db_user:db_password@localhost/db_name
LOCK_DSN=flock LOCK_DSN=flock
###< symfony/lock ### ###< symfony/lock ###
MAINTENANCE_MODE_ENABLED=false
MAINTENANCE_MODE_IP_WHITELIST=188.245.62.21
MAINTENANCE_MODE_ROUTE_WHITELIST=
MAINTENANCE_MODE_PATH_WHITELIST=/api,/token
+5 -5
View File
@@ -8,13 +8,9 @@
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> phpunit/phpunit ###
/phpunit.xml
.phpunit.result.cache
###< phpunit/phpunit ###
###> symfony/phpunit-bridge ### ###> symfony/phpunit-bridge ###
.phpunit.result.cache .phpunit.result.cache
.phpunit.cache
/phpunit.xml /phpunit.xml
###< symfony/phpunit-bridge ### ###< symfony/phpunit-bridge ###
@@ -37,3 +33,7 @@ yarn-error.log
###> phpstan/phpstan ### ###> phpstan/phpstan ###
phpstan.neon phpstan.neon
###< phpstan/phpstan ### ###< phpstan/phpstan ###
###> config reference ###
/config/reference.php
###< config reference ###
+1
View File
@@ -0,0 +1 @@
24
@@ -0,0 +1,22 @@
import {Controller} from '@hotwired/stimulus'
/*
* Keeps buttons disabled until the given fields pass the browser's constraint validation
* (required, min, max, step, …), which the form types render from the same rules the server
* validates. A text field holding only whitespace counts as empty, as NotBlank does.
*
* Works on forms with novalidate too: that only stops the browser from blocking the submit.
*/
export default class extends Controller {
static targets = ['field', 'button']
connect() {
this.update()
}
update() {
const invalid = this.fieldTargets.some(field => !field.checkValidity()
|| ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim()))
this.buttonTargets.forEach(button => button.disabled = invalid)
}
}
@@ -11,10 +11,13 @@ export default class extends Controller {
connect() { connect() {
this.itemsCountValue = this.fieldTargets.length this.itemsCountValue = this.fieldTargets.length
// A running counter rather than the current count: after a remove, count + 1 can be an
// index a remaining entry still holds, and the two would submit into the same slot.
this.nextIndex = this.fieldTargets.length
} }
addItem() { addItem() {
const index = this.fieldTargets.length + 1 const index = this.nextIndex++
const prototype = JSON.parse(this.prototypeValue) const prototype = JSON.parse(this.prototypeValue)
const newField = prototype.replace(/__name__/g, index) const newField = prototype.replace(/__name__/g, index)
this.fieldsTarget.insertAdjacentHTML('beforeend', newField) this.fieldsTarget.insertAdjacentHTML('beforeend', newField)
@@ -0,0 +1,26 @@
import {Controller} from '@hotwired/stimulus'
/*
* Shows an indicator over an iframe until its content has loaded.
*
* The controller sets the frame's src itself, after the load listener is attached, so a fast
* response cannot finish before anybody listens.
*/
export default class extends Controller {
static targets = ['frame', 'indicator']
static values = {src: String}
connect() {
this.loaded = this.loaded.bind(this)
this.frameTarget.addEventListener('load', this.loaded)
this.frameTarget.src = this.srcValue
}
disconnect() {
this.frameTarget.removeEventListener('load', this.loaded)
}
loaded() {
this.indicatorTarget.classList.add('hidden')
}
}
+1 -1
View File
@@ -90,7 +90,7 @@ function handleHistoryRestore() {
function handleTimeout() { function handleTimeout() {
// Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout // Don't decrement here — htmx:afterRequest always fires alongside htmx:timeout
// and handles the counter. This handler only shows the alert. // and handles the counter. This handler only shows the alert.
alert('Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut. Falls das Problem weiterhin besteht, kontaktieren Sie bitte unseren Support.') alert('Die Anfrage hat zu lange gedauert. Bitte versuche es erneut. Falls das Problem weiterhin besteht, kontaktiere bitte unseren Support.')
} }
function handleBeforeHistorySave() { function handleBeforeHistorySave() {
+9 -9
View File
@@ -1,9 +1,9 @@
@import "components/button.css"; @import "./components/button.css";
@import "components/forms.css"; @import "./components/forms.css";
@import "components/gallery.css"; @import "./components/gallery.css";
@import "components/pagination.css"; @import "./components/pagination.css";
@import "components/price_calendar.css"; @import "./components/price_calendar.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/typography.css"; @import "./components/typography.css";
@import "components/rte-content.css"; @import "./components/rte-content.css";
+10 -10
View File
@@ -1,10 +1,10 @@
@import "components/autocomplete.css"; @import "./components/autocomplete.css";
@import "components/badge.css"; @import "./components/badge.css";
@import "components/button.css"; @import "./components/button.css";
@import "components/datatable.css"; @import "./components/datatable.css";
@import "components/datepicker.css"; @import "./components/datepicker.css";
@import "components/forms.css"; @import "./components/forms.css";
@import "components/pagination.css"; @import "./components/pagination.css";
@import "components/toast.css"; @import "./components/toast.css";
@import "components/tooltip.css"; @import "./components/tooltip.css";
@import "components/typography.css"; @import "./components/typography.css";
+66
View File
@@ -0,0 +1,66 @@
/* Shared by app.css and admin.css, imported right after tailwindcss */
@source "../../templates";
@source "../../src";
@source "../../assets";
@source inline("rotate-90");
@plugin "@tailwindcss/forms";
@theme {
--font-sans: Lato, sans-serif;
--color-primary: #3d8ccb;
--color-secondary: #f9c700;
--color-primary-bg: #ebf3fa;
--color-primary-light: #0070e0;
--color-primary-medium: #165883;
--color-primary-dark: #18527b;
--color-pink: #fa1a8c;
--color-sbw-primary: #B3820F;
--color-sbw-secondary: #E91C26;
--color-sbw-primary-dark: #8D7207;
--color-sbw-overlay: #c20000;
--color-snz-primary: #82c3c6;
--color-snz-secondary: #f9c33d;
--color-snz-primary-dark: #82c3c6;
--color-ser-primary: #006fb6;
--color-ser-secondary: #DE2C1B;
--color-ser-primary-light: #04A4CC;
--background-image-badge-gradient: linear-gradient(120deg, rgba(150,1,103,1) 0%, rgba(158,3,106,1) 40%, rgba(250,26,140,1) 100%);
--background-image-brand-gradient: linear-gradient(to top right, #009fff, #165883, #18527b);
}
/*
* Tailwind 3 defaults that Tailwind 4 changed: borders were gray-200 instead of currentColor,
* and buttons had a pointer cursor.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}
/*
* Thin scrollbars without a track for inner scrolling areas. The page scrollbar takes its
* style from <html>, which body * leaves out. Firefox and Chrome/Edge 121+ honour these;
* Safari keeps its own overlay scrollbars.
*/
@layer base {
body * {
scrollbar-width: thin;
scrollbar-color: var(--color-gray-300) transparent;
}
}
+4 -6
View File
@@ -1,7 +1,5 @@
@layer utilities { /* Reserve the scrollbar gutter so content does not shift width when a
/* Reserve the scrollbar gutter so content does not shift width when a scrollbar appears (e.g. when a collapsible section is opened). */
scrollbar appears (e.g. when a collapsible section is opened). */ @utility scroll-stable {
.scroll-stable { scrollbar-gutter: stable;
scrollbar-gutter: stable;
}
} }
+4 -7
View File
@@ -1,7 +1,4 @@
@import "tailwindcss/base"; @import "tailwindcss" source(none);
@import "_base.css"; @import "./_theme.css";
@import "./_base.css" layer(base);
@import "tailwindcss/components"; @import "./_components_admin.css" layer(components);
@import "_components_admin.css";
@import "tailwindcss/utilities";
+5 -8
View File
@@ -1,8 +1,5 @@
@import "tailwindcss/base"; @import "tailwindcss" source(none);
@import "_base.css"; @import "./_theme.css";
@import "./_base.css" layer(base);
@import "tailwindcss/components"; @import "./_components.css" layer(components);
@import "_components.css"; @import "./_utilities.css";
@import "tailwindcss/utilities";
@import "_utilities.css";
+11 -5
View File
@@ -1,16 +1,22 @@
.button { .button {
@apply inline-flex items-center justify-center space-x-2 h-8 leading-8 md:h-10 md:leading-10 px-4 md:px-8 cursor-pointer; /* Same box as the @tailwindcss/forms inputs (1px border, py-2, 24px line), so a button lines up with a field beside it.
@apply uppercase leading-none text-center text-sm md:text-base; The line height is set with each font size (text-sm/6), because text-sm's own line height would override a plain leading-6. */
@apply transition-colors outline-none focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark; @apply inline-flex items-center justify-center space-x-2 px-4 md:px-8 py-2 border border-transparent bg-origin-border cursor-pointer;
@apply uppercase text-center text-sm/6 md:text-base/6;
@apply transition-colors outline-hidden focus:outline-2 focus:outline-offset-2 focus:outline-primary-dark;
@apply rounded-md; @apply rounded-md;
/* At least one line tall even when the content is only a smaller icon: line + padding + border */
min-height: calc(1lh + 2 * --spacing(2) + 2px);
} }
.button--small { .button--small {
@apply px-2 py-1 md:px-4 text-xs sm:text-sm; @apply px-2 py-1 md:px-4 text-xs/6 sm:text-sm/6;
min-height: calc(1lh + 2 * --spacing(1) + 2px);
} }
.button--primary { .button--primary {
background: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%); /* Only the image: the background shorthand would reset .button's background-origin */
background-image: linear-gradient(120deg, #960167 0%, #9e036a 40%, #fa1a8c 100%);
@apply text-white; @apply text-white;
@apply hover:bg-none hover:bg-pink/80; @apply hover:bg-none hover:bg-pink/80;
} }
+1 -1
View File
@@ -3,5 +3,5 @@
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn, [data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn,
[data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover { [data-vc-theme=light] .vc-date[data-vc-date-selected] .vc-date__btn:hover {
background-color: theme('colors.secondary'); background-color: var(--color-secondary);
} }
+4 -4
View File
@@ -1,5 +1,5 @@
.pc-day { .pc-day {
@apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded text-white cursor-pointer; @apply relative w-full text-center py-1 px-0.5 text-sm leading-tight rounded-sm text-white cursor-pointer;
} }
.pc-day--out-of-month { .pc-day--out-of-month {
@@ -11,11 +11,11 @@
} }
.pc-day[data-status="blocked-to-ok"] { .pc-day[data-status="blocked-to-ok"] {
background: linear-gradient(to bottom right, theme('colors.gray.400') 50%, theme('colors.emerald.500') 50%); background: linear-gradient(to bottom right, var(--color-gray-400) 50%, var(--color-emerald-500) 50%);
} }
.pc-day[data-status="checkout-only"] { .pc-day[data-status="checkout-only"] {
background: linear-gradient(to bottom right, theme('colors.emerald.500') 50%, theme('colors.gray.400') 50%); background: linear-gradient(to bottom right, var(--color-emerald-500) 50%, var(--color-gray-400) 50%);
} }
.pc-day[data-status="past"] { .pc-day[data-status="past"] {
@@ -33,7 +33,7 @@
.pc-day.pc-day--pending-start, .pc-day.pc-day--pending-start,
.pc-day.pc-day--selected-start, .pc-day.pc-day--selected-start,
.pc-day.pc-day--selected-end { .pc-day.pc-day--selected-end {
@apply bg-none outline-none bg-secondary text-gray-700; @apply bg-none outline-hidden bg-secondary text-gray-700;
} }
.pc-day.pc-day--in-range { .pc-day.pc-day--in-range {
+5 -5
View File
@@ -1,25 +1,25 @@
@import "toastify-js/src/toastify.css"; @import "toastify-js/src/toastify.css";
.toastify { .toastify {
@apply shadow-lg rounded; @apply shadow-lg rounded-sm;
} }
.toastify--success { .toastify--success {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.emerald.600'); background: var(--color-emerald-600);
} }
.toastify--warning { .toastify--warning {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.600'); background: var(--color-red-600);
} }
.toastify--error { .toastify--error {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.red.700'); background: var(--color-red-700);
} }
.toastify--info { .toastify--info {
@apply text-gray-100; @apply text-gray-100;
background: theme('colors.primary'); background: var(--color-primary);
} }
+20 -20
View File
@@ -1,71 +1,71 @@
@import "tippy.js/dist/tippy.css"; @import "tippy.js/dist/tippy.css";
.tippy-box { .tippy-box {
background-color: theme('colors.primary'); background-color: var(--color-primary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.primary'); border-top-color: var(--color-primary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.primary'); border-bottom-color: var(--color-primary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.primary'); border-left-color: var(--color-primary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.primary'); border-right-color: var(--color-primary);
} }
.sbw { .sbw {
.tippy-box { .tippy-box {
background-color: theme('colors.sbw.secondary'); background-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.sbw.secondary'); border-top-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.sbw.secondary'); border-bottom-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.sbw.secondary'); border-left-color: var(--color-sbw-secondary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.sbw.secondary'); border-right-color: var(--color-sbw-secondary);
} }
} }
.snz { .snz {
.tippy-box { .tippy-box {
background-color: theme('colors.snz.primary-dark'); background-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.snz.primary-dark'); border-top-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.snz.primary-dark'); border-bottom-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.snz.primary-dark'); border-left-color: var(--color-snz-primary-dark);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.snz.primary-dark'); border-right-color: var(--color-snz-primary-dark);
} }
} }
.ser { .ser {
.tippy-box { .tippy-box {
background-color: theme('colors.ser.secondary'); background-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='top'] > .tippy-arrow::before { .tippy-box[data-placement^='top'] > .tippy-arrow::before {
border-top-color: theme('colors.ser.secondary'); border-top-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='bottom'] > .tippy-arrow::before { .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {
border-bottom-color: theme('colors.ser.secondary'); border-bottom-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='left'] > .tippy-arrow::before { .tippy-box[data-placement^='left'] > .tippy-arrow::before {
border-left-color: theme('colors.ser.secondary'); border-left-color: var(--color-ser-secondary);
} }
.tippy-box[data-placement^='right'] > .tippy-arrow::before { .tippy-box[data-placement^='right'] > .tippy-arrow::before {
border-right-color: theme('colors.ser.secondary'); border-right-color: var(--color-ser-secondary);
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+4
View File
@@ -0,0 +1,4 @@
### list custom fields
GET {{clickup_baseurl}}/list/{{clickup_list_id}}/field?include_applied_objects=true
Authorization: {{clickup_api_token}}
+42 -39
View File
@@ -4,7 +4,7 @@
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true, "prefer-stable": true,
"require": { "require": {
"php": ">=8.1", "php": ">=8.5",
"ext-ctype": "*", "ext-ctype": "*",
"ext-dom": "*", "ext-dom": "*",
"ext-iconv": "*", "ext-iconv": "*",
@@ -21,47 +21,50 @@
"knplabs/knp-paginator-bundle": "^6.10", "knplabs/knp-paginator-bundle": "^6.10",
"league/flysystem-bundle": "^3.4", "league/flysystem-bundle": "^3.4",
"league/flysystem-sftp-v3": "^3.29", "league/flysystem-sftp-v3": "^3.29",
"league/html-to-markdown": "^5.1",
"league/oauth2-server-bundle": "^1.0", "league/oauth2-server-bundle": "^1.0",
"nelexa/zip": "^4.0", "maennchen/zipstream-php": "^3.2",
"nesbot/carbon": "^3.8", "nesbot/carbon": "^3.8",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpoffice/phpspreadsheet": "^5.4", "phpoffice/phpspreadsheet": "^5.4",
"phpstan/phpdoc-parser": "^2.0", "phpstan/phpdoc-parser": "^2.0",
"spatie/crypto": "^2.1", "spatie/crypto": "^2.1",
"symfony/apache-pack": "^1.0", "symfony/apache-pack": "^1.0",
"symfony/asset": "6.4.*", "symfony/asset": "7.4.*",
"symfony/console": "6.4.*", "symfony/console": "7.4.*",
"symfony/doctrine-messenger": "6.4.*", "symfony/doctrine-messenger": "7.4.*",
"symfony/dom-crawler": "6.4.*", "symfony/dom-crawler": "7.4.*",
"symfony/dotenv": "6.4.*", "symfony/dotenv": "7.4.*",
"symfony/expression-language": "6.4.*", "symfony/expression-language": "7.4.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "6.4.*", "symfony/form": "7.4.*",
"symfony/framework-bundle": "6.4.*", "symfony/framework-bundle": "7.4.*",
"symfony/html-sanitizer": "6.4.*", "symfony/html-sanitizer": "7.4.*",
"symfony/http-client": "6.4.*", "symfony/http-client": "7.4.*",
"symfony/intl": "6.4.*", "symfony/intl": "7.4.*",
"symfony/lock": "6.4.*", "symfony/lock": "7.4.*",
"symfony/mailer": "6.4.*", "symfony/mailer": "7.4.*",
"symfony/mime": "6.4.*", "symfony/mcp-bundle": "^0.14.1",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0", "symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.4.*", "symfony/notifier": "7.4.*",
"symfony/process": "6.4.*", "symfony/process": "7.4.*",
"symfony/property-access": "6.4.*", "symfony/property-access": "7.4.*",
"symfony/property-info": "6.4.*", "symfony/property-info": "7.4.*",
"symfony/runtime": "6.4.*", "symfony/rate-limiter": "7.4.*",
"symfony/security-bundle": "6.4.*", "symfony/runtime": "7.4.*",
"symfony/serializer": "6.4.*", "symfony/security-bundle": "7.4.*",
"symfony/serializer": "7.4.*",
"symfony/stimulus-bundle": "^2.21", "symfony/stimulus-bundle": "^2.21",
"symfony/string": "6.4.*", "symfony/string": "7.4.*",
"symfony/translation": "6.4.*", "symfony/translation": "7.4.*",
"symfony/twig-bundle": "6.4.*", "symfony/twig-bundle": "7.4.*",
"symfony/uid": "6.4.*", "symfony/uid": "7.4.*",
"symfony/ux-twig-component": "^2.36", "symfony/ux-twig-component": "^2.36",
"symfony/validator": "6.4.*", "symfony/validator": "7.4.*",
"symfony/web-link": "6.4.*", "symfony/web-link": "7.4.*",
"symfony/webpack-encore-bundle": "^2.2", "symfony/webpack-encore-bundle": "^2.2",
"symfony/yaml": "6.4.*", "symfony/yaml": "7.4.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/html-extra": "^3.15", "twig/html-extra": "^3.15",
"twig/intl-extra": "*", "twig/intl-extra": "*",
@@ -77,7 +80,7 @@
}, },
"sort-packages": true, "sort-packages": true,
"platform": { "platform": {
"php": "8.3.30" "php": "8.5.0"
} }
}, },
"autoload": { "autoload": {
@@ -119,7 +122,7 @@
"extra": { "extra": {
"symfony": { "symfony": {
"allow-contrib": true, "allow-contrib": true,
"require": "6.4.*", "require": "7.4.*",
"docker": true "docker": true
} }
}, },
@@ -129,14 +132,14 @@
"phpstan/phpstan": "^2.1", "phpstan/phpstan": "^2.1",
"phpstan/phpstan-doctrine": "^2.0", "phpstan/phpstan-doctrine": "^2.0",
"phpstan/phpstan-symfony": "^2.0", "phpstan/phpstan-symfony": "^2.0",
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^12",
"rector/rector": "^2.4", "rector/rector": "^2.4",
"symfony/browser-kit": "6.4.*", "symfony/browser-kit": "7.4.*",
"symfony/css-selector": "6.4.*", "symfony/css-selector": "7.4.*",
"symfony/debug-bundle": "6.4.*", "symfony/debug-bundle": "7.4.*",
"symfony/maker-bundle": "^1.0", "symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^7.1", "symfony/phpunit-bridge": "^7.4",
"symfony/stopwatch": "6.4.*", "symfony/stopwatch": "7.4.*",
"symfony/web-profiler-bundle": "6.4.*" "symfony/web-profiler-bundle": "7.4.*"
} }
} }
Generated
+3253 -2157
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -20,4 +20,5 @@ return [
Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true], Knp\Bundle\MenuBundle\KnpMenuBundle::class => ['all' => true],
Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true],
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
]; ];
+1 -4
View File
@@ -9,8 +9,7 @@ doctrine:
profiling_collect_backtrace: '%kernel.debug%' profiling_collect_backtrace: '%kernel.debug%'
use_savepoints: true use_savepoints: true
orm: orm:
auto_generate_proxy_classes: true enable_native_lazy_objects: true
enable_lazy_ghost_objects: true
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true auto_mapping: true
@@ -33,8 +32,6 @@ when@test:
when@prod: when@prod:
doctrine: doctrine:
orm: orm:
auto_generate_proxy_classes: false
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
query_cache_driver: query_cache_driver:
type: pool type: pool
pool: doctrine.system_cache_pool pool: doctrine.system_cache_pool
+7 -6
View File
@@ -2,16 +2,17 @@ flysystem:
storages: storages:
xml_export.storage: xml_export.storage:
local: local:
directory: '%env(resolve:XML_EXPORT_PATH)%' directory: '%kernel.project_dir%/var/xmlexport'
xml_source.storage: xml_source.storage:
sftp: sftp:
host: '%env(SFTP_XML_EXPORT_HOST)%' host: '%env(SFTP_XML_SOURCE_HOST)%'
port: '%env(int:SFTP_XML_EXPORT_PORT)%' port: '%env(int:SFTP_XML_SOURCE_PORT)%'
username: '%env(SFTP_XML_EXPORT_USER)%' username: '%env(SFTP_XML_SOURCE_USER)%'
password: '%env(SFTP_XML_EXPORT_PASSWORD)%' password: '%env(SFTP_XML_SOURCE_PASSWORD)%'
root: '%env(SFTP_XML_SOURCE_ROOT)%'
xml_dump.storage: xml_dump.storage:
local: local:
directory: '%kernel.project_dir%/var/bpn' directory: '%kernel.project_dir%/var/bpn'
json_export.storage: json_export.storage:
local: local:
directory: '%env(resolve:JSON_EXPORT_PATH)%' directory: '%kernel.project_dir%/var/jsonexport'
+8 -1
View File
@@ -1,7 +1,6 @@
# see https://symfony.com/doc/current/reference/configuration/framework.html # see https://symfony.com/doc/current/reference/configuration/framework.html
framework: framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
annotations: false
http_method_override: false http_method_override: false
handle_all_throwables: true handle_all_throwables: true
@@ -17,12 +16,20 @@ framework:
php_errors: php_errors:
log: true log: true
rate_limiter:
# Per MCP client. A chatbot turn makes a few tool calls; this catches runaway loops.
chatbot_mcp:
policy: sliding_window
limit: 60
interval: '1 minute'
http_client: http_client:
scoped_clients: scoped_clients:
typo3.client: typo3.client:
base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%' base_uri: '%env(resolve:APP_CMS_API_BASE_URL)%'
headers: headers:
accept: 'text/json' accept: 'text/json'
X-Api-Key: '%env(APP_CMS_API_KEY)%'
when@test: when@test:
framework: framework:
+10
View File
@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory
@@ -19,6 +19,9 @@ league_oauth2_server:
doctrine: null doctrine: null
client: client:
allow_plaintext_secrets: false allow_plaintext_secrets: false
# App\Entity\OAuth2Client adds the required_roles column that
# App\EventListener\AuthorizationCodeListener gates authorization on.
classname: App\Entity\OAuth2Client
when@test: when@test:
league_oauth2_server: league_oauth2_server:
+30
View File
@@ -0,0 +1,30 @@
# The website chatbot's MCP server, called by the n8n AI Agent through its MCP Client Tool
# node. Read-only tools over the mirrored TYPO3 content and our own trip data; see
# docs/chatbot-mcp.md. Authentication is the "mcp" firewall in security.yaml.
mcp:
servers:
chatbot:
name: 'MyE&P chatbot'
version: '1.0.0'
instructions: 'Answers questions about the trips and website content of E&P Reisen and its brands. Every result carries a url to cite. Prices and availability are a snapshot as of dataAsOf; the booking page is binding. The structured trip fields of search_trips and get_trip (dates, rooms, prices, pickups, inclusions) come from the booking system and override anything editorial text says about them: product descriptions, FAQs and search_content/get_document results can be out of date.'
http:
path: /mcp
# DNS rebinding protection guards unauthenticated local servers. This one is
# public, behind a bearer token, and never called from a browser.
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\'
+1
View File
@@ -26,6 +26,7 @@ framework:
Symfony\Component\Notifier\Message\SmsMessage: async Symfony\Component\Notifier\Message\SmsMessage: async
App\Message\MailjetNewsletterEventMessage: async App\Message\MailjetNewsletterEventMessage: async
App\Message\CreateClickUpBookingTaskMessage: async App\Message\CreateClickUpBookingTaskMessage: async
App\Message\RoleNominationMessage: async
# Route your messages to the transports # Route your messages to the transports
# 'App\Message\YourMessage': async # 'App\Message\YourMessage': async
+15 -2
View File
@@ -5,6 +5,7 @@ monolog:
- core - core
- auth - auth
- admin - admin
- chatbot
when@dev: when@dev:
monolog: monolog:
@@ -33,11 +34,16 @@ when@dev:
path: "%kernel.logs_dir%/%kernel.environment%.admin.log" path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: debug level: debug
channels: ["admin"] channels: ["admin"]
chatbot:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
level: debug
channels: ["chatbot"]
main: main:
type: stream type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log" path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug level: debug
channels: ["!event", "!bpn", "!core", "!auth", "!admin"] channels: ["!event", "!bpn", "!core", "!auth", "!admin", "!chatbot"]
# uncomment to get logging in your browser # uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration # you may have to allow bigger header sizes in your Web server configuration
#firephp: #firephp:
@@ -96,13 +102,20 @@ when@prod: &prod_config
path: "%kernel.logs_dir%/%kernel.environment%.admin.log" path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
level: info level: info
channels: ["admin"] channels: ["admin"]
# MCP tool calls, file only: the log for judging search quality later.
chatbot:
type: rotating_file
max_files: 30
path: "%kernel.logs_dir%/%kernel.environment%.chatbot.log"
level: info
channels: ["chatbot"]
main: main:
type: fingers_crossed type: fingers_crossed
action_level: error action_level: error
handler: nested handler: nested
excluded_http_codes: [404, 405] excluded_http_codes: [404, 405]
buffer_size: 50 # How many messages should be saved? Prevent memory leaks buffer_size: 50 # How many messages should be saved? Prevent memory leaks
channels: ["!bpn", "!core", "!auth", "!admin"] channels: ["!bpn", "!core", "!auth", "!admin", "!chatbot"]
nested: nested:
type: rotating_file type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.framework.log" path: "%kernel.logs_dir%/%kernel.environment%.framework.log"
+3
View File
@@ -0,0 +1,3 @@
framework:
property_info:
with_constructor_extractor: true
+6 -3
View File
@@ -2,9 +2,12 @@ framework:
router: router:
utf8: true utf8: true
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands. # How URLs are generated where there is no request to take the host from: scheduled
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands # commands and, notably, the Messenger workers that send mail off the request path.
#default_uri: http://localhost # Without it those links would point at http://localhost. Only ever consulted in a
# non-HTTP context, so web requests keep using their own host — which matters here,
# since one codebase serves four brands.
default_uri: '%env(APP_BASE_URL)%'
when@prod: when@prod:
framework: framework:
+16
View File
@@ -15,10 +15,19 @@ security:
mailjet: mailjet:
password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%' password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%'
roles: [ ROLE_MAILJET_WEBHOOK ] roles: [ ROLE_MAILJET_WEBHOOK ]
# Never consulted: McpAccessTokenHandler builds the user from the client name its
# token belongs to. Named only because a firewall must have a provider.
mcp_provider:
memory:
users: []
role_hierarchy: role_hierarchy:
# 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_ADMIN:
- ROLE_GROUPS_ADMIN - ROLE_GROUPS_ADMIN
- ROLE_CUSTOMER_EXPERT
ROLE_GROUPS_ADMIN: ROLE_GROUPS_ADMIN:
- ROLE_GROUPS_MANAGER - ROLE_GROUPS_MANAGER
firewalls: firewalls:
@@ -41,6 +50,12 @@ security:
provider: mailjet_webhook_provider provider: mailjet_webhook_provider
http_basic: http_basic:
realm: 'Mailjet Webhook' realm: 'Mailjet Webhook'
mcp:
pattern: ^/mcp(/trips)?$
stateless: true
provider: mcp_provider
access_token:
token_handler: App\Security\McpAccessTokenHandler
main: main:
lazy: true lazy: true
provider: app_user_provider provider: app_user_provider
@@ -54,6 +69,7 @@ security:
# Note: Only the *first* access control that matches will be used # Note: Only the *first* access control that matches will be used
access_control: access_control:
- { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https } - { path: ^/webhooks/mailjet/newsletter$, roles: ROLE_MAILJET_WEBHOOK, requires_channel: https }
- { path: ^/mcp(/trips)?$, roles: ROLE_CHATBOT_MCP, requires_channel: https }
- { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https } - { path: ^/authorize, roles: IS_AUTHENTICATED_REMEMBERED, requires_channel: https }
- { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https } - { path: ^/, roles: PUBLIC_ACCESS, requires_channel: https }
+1
View File
@@ -5,6 +5,7 @@ twig:
'%kernel.project_dir%/assets/images': images '%kernel.project_dir%/assets/images': images
globals: globals:
htmx_change_trigger: 'change delay:100ms' htmx_change_trigger: 'change delay:100ms'
htmx_boost: '%env(bool:APP_HTMX_BOOST)%'
website_base_url: '%env(APP_WEBSITE_BASE_URL)%' website_base_url: '%env(APP_WEBSITE_BASE_URL)%'
customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%' customer_service_email: '%env(APP_CUSTOMER_SERVICE_EMAIL)%'
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%' legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
+3 -1
View File
@@ -14,4 +14,6 @@ when@test:
intercept_redirects: false intercept_redirects: false
framework: framework:
profiler: { collect: false } profiler:
collect: false
collect_serializer_data: true
+19 -2
View File
@@ -3,13 +3,14 @@ zenstruck_schedule:
mailer: mailer:
service: mailer service: mailer
default_to: [email protected] default_to: [email protected]
default_from: info@ep-reisen.de default_from: noreply@ep-reisen.de
subject_prefix: "[MyE&P]" subject_prefix: "[MyE&P]"
schedule_extensions: schedule_extensions:
email_on_failure: email_on_failure:
enabled: true enabled: true
to: [email protected]
tasks: tasks:
- task: app:cleanup:xml-dumps - task: app:cleanup:xml-dumps
@@ -28,6 +29,10 @@ zenstruck_schedule:
frequency: "45 1 * * *" frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests" description: "Removes expired pending newsletter double opt-in requests"
- task: app:cleanup:travel-mailing-lists
frequency: "50 1 * * *"
description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
- task: league:oauth2-server:clear-expired-tokens - task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *" frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly" description: "Removes expired oauth2 tokens hourly"
@@ -66,3 +71,15 @@ when@prod:
- task: app:bpn:sync-contingents - task: app:bpn:sync-contingents
frequency: "5 20-23,0-7 * * *" frequency: "5 20-23,0-7 * * *"
description: "Syncs the full contingent horizon hourly outside peak hours" description: "Syncs the full contingent horizon hourly outside peak hours"
- task: app:content:sync
frequency: "0 * * * *"
description: "Mirrors changes in the TYPO3 content feed for the chatbot MCP server"
- task: app:content:sync --refresh
frequency: "5 1 * * 0"
description: "Refetches TYPO3 content stored more than 7 days ago, within the night window"
- task: app:content:check-freshness --max-age 150
frequency: "10 * * * *"
description: "Fails when the TYPO3 content mirror has missed more than one hourly sync"
+3
View File
@@ -0,0 +1,3 @@
mcp:
resource: .
type: mcp
+125 -19
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
# This file is the entry point to configure your own services. # This file is the entry point to configure your own services.
# Files in the packages/ subdirectory configure your dependencies. # Files in the packages/ subdirectory configure your dependencies.
@@ -14,6 +15,27 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%' accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
# reading it once the last date has passed.
legacy_promo_voucher_codes:
BEWERTUNG25: '2026-10-31'
ANNA50: '2026-12-20'
SIDNEY50: '2026-12-20'
LENA20: '2027-03-04'
BOTSCHAFTER2627: '2027-04-30'
# Named travel periods of the website search ("Reisezeitraum"), offered by the chatbot's
# search_trips. Duplicated from the TYPO3 search configuration on purpose: update both each
# season. Periods may overlap; one whose end has passed is no longer offered.
chatbot_travel_periods:
- { label: 'Vorsaison', from: '2026-11-01', to: '2026-12-20' }
- { label: 'um Weihnachten', from: '2026-12-20', to: '2026-12-28' }
- { label: 'Silvester', from: '2026-12-26', to: '2027-01-03' }
- { label: '1 Januarwoche', from: '2027-01-02', to: '2027-01-09' }
- { label: 'Nachsaison', from: '2027-03-20', to: '2027-05-01' }
- { label: 'Ostern', from: '2027-03-20', to: '2027-04-03' }
# MailJet list ids and their labels # MailJet list ids and their labels
mailjet_lists: mailjet_lists:
@@ -30,6 +52,12 @@ parameters:
10321389: 'Reisen-Alert Stubaital' 10321389: 'Reisen-Alert Stubaital'
10554990: 'Reisen-Alert Ski & Boarderweek' 10554990: 'Reisen-Alert Ski & Boarderweek'
# Email domains whose accounts are members of staff. BusPro has no CRM selection expressing
# "works here", so ROLE_EMPLOYEE is derived from the address instead. Matched exactly on the
# domain part, never as a suffix.
employee_email_domains:
- 'ep-reisen.de'
# BusProNet "Hausleitung {CODE}" CRM selections, by selection id. # BusProNet "Hausleitung {CODE}" CRM selections, by selection id.
# DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing # DEPLOYMENT-CRITICAL: roles and hotel codes are synced on every login, so an id missing
# here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel # here does not merely fail to nominate a Hausleitung — it revokes the role and the hotel
@@ -120,18 +148,22 @@ services:
- '../src/Model' - '../src/Model'
- '../src/Kernel.php' - '../src/Kernel.php'
App\BusProNet\ApiClient: App\BusProNet\Transport\XmlChannel:
arguments: arguments:
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
$options: $options:
bpn_username: '%env(APP_BPN_USER)%' bpn_username: '%env(APP_BPN_USER)%'
bpn_password: '%env(APP_BPN_PASSWORD)%' bpn_password: '%env(APP_BPN_PASSWORD)%'
bpn_api_ip: '%env(APP_BPN_IP)%'
bpn_api_ports: '%env(csv:APP_BPN_PORTS)%'
debug: '%env(bool:APP_BPN_DEBUG)%' debug: '%env(bool:APP_BPN_DEBUG)%'
connection_timeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
stream_timeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%' App\BusProNet\Transport\SocketTransport:
total_timeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%' arguments:
$logger: '@monolog.logger.bpn'
$host: '%env(APP_BPN_IP)%'
$ports: '%env(csv:APP_BPN_PORTS)%'
$connectionTimeout: '%env(int:APP_BPN_CONNECTION_TIMEOUT)%'
$streamTimeout: '%env(int:APP_BPN_STREAM_TIMEOUT)%'
$totalTimeout: '%env(int:APP_BPN_TOTAL_TIMEOUT)%'
App\Twig\AppRuntime: App\Twig\AppRuntime:
arguments: arguments:
@@ -149,14 +181,6 @@ services:
method: createMainMenu method: createMainMenu
alias: admin_main 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: App\Service\BpnXmlSyncManager:
arguments: arguments:
$xmlSource: '@xml_source.storage' $xmlSource: '@xml_source.storage'
@@ -195,6 +219,10 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\BusProNet\Utility\CountryCodeMapper:
arguments:
$logger: '@monolog.logger.bpn'
App\BusProNet\XmlLoader\HotelLoader: App\BusProNet\XmlLoader\HotelLoader:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
@@ -207,17 +235,49 @@ services:
arguments: arguments:
$cache: '@bpn.cache' $cache: '@bpn.cache'
App\Service\TravelExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\ProductExportBuilder:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherValidator: App\Service\VoucherValidator:
arguments: arguments:
$cache: '@cache.app' $cache: '@cache.app'
$logger: '@monolog.logger.bpn' $logger: '@monolog.logger.bpn'
App\Service\VoucherCodeClassifier:
arguments:
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
# Voucher purchase flow (/gutschein)
App\Service\VoucherTemplateProvider:
arguments:
$cache: '@bpn.cache'
$logger: '@monolog.logger.bpn'
App\Service\VoucherThumbnailResolver:
arguments:
$manifestPath: '%kernel.project_dir%/public/build/manifest.json'
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step1Controller:
arguments:
$logger: '@monolog.logger.bpn'
App\Controller\Voucher\Step3Controller:
arguments:
$logger: '@monolog.logger.bpn'
# Field Handlers with dependencies # Field Handlers with dependencies
App\Form\Service\ParticipantInsuranceFieldHandler: ~ App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~ App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~ App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~ App\Form\Service\ParticipantVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services # Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry: App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -243,8 +303,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler' - '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' - '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler' - '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules # Booking Status Rules
@@ -259,6 +318,7 @@ services:
- '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule' - '@App\BusProNet\Service\StatusRule\ChaperonServiceStatusRule'
# Dashboard Widgets # Dashboard Widgets
App\Dashboard\Widget\StuckBookingDraftsWidgetProvider: ~
App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~ App\Dashboard\Widget\OpenGroupBookingsWidgetProvider: ~
App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~ App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider: ~
App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~ App\Dashboard\Widget\RecentLogEntriesWidgetProvider: ~
@@ -267,14 +327,27 @@ services:
App\Dashboard\DashboardWidgetRegistry: App\Dashboard\DashboardWidgetRegistry:
arguments: arguments:
$providers: $providers:
- '@App\Dashboard\Widget\StuckBookingDraftsWidgetProvider'
- '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider' - '@App\Dashboard\Widget\OpenGroupBookingsWidgetProvider'
- '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider' - '@App\Dashboard\Widget\PendingRoleApprovalsWidgetProvider'
- '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider' - '@App\Dashboard\Widget\RecentLogEntriesWidgetProvider'
App\ContentFeed\ContentFeedClient:
arguments:
$baseUrl: '%env(APP_CMS_API_BASE_URL)%'
$apiKey: '%env(APP_CMS_API_KEY)%'
App\Chatbot\TravelPeriods:
arguments:
$periods: '%chatbot_travel_periods%'
App\Security\McpAccessTokenHandler:
arguments:
$tokens: '%env(APP_MCP_TOKENS)%'
App\Service\CmsDataProvider: App\Service\CmsDataProvider:
arguments: arguments:
$httpClient: '@typo3.client' $httpClient: '@typo3.client'
$apiKey: 'AbcAbc123'# dummy key, not yet implemented
App\Logger\RequestIdProcessor: App\Logger\RequestIdProcessor:
tags: tags:
@@ -298,6 +371,14 @@ services:
arguments: arguments:
$houseManagerIds: '%bpn_crm_house_manager_ids%' $houseManagerIds: '%bpn_crm_house_manager_ids%'
App\Security\EmployeeDomainMatcher:
arguments:
$domains: '%employee_email_domains%'
App\MessageHandler\RoleNominationHandler:
arguments:
$notificationRecipients: '%role_nomination_notification_emails%'
App\Service\DomainConfigProvider: App\Service\DomainConfigProvider:
arguments: arguments:
$domainConfig: '%domain_config%' $domainConfig: '%domain_config%'
@@ -342,6 +423,9 @@ services:
App\BpnConnect\ContingentsClient: App\BpnConnect\ContingentsClient:
parent: App\BpnConnect\AbstractApiClient parent: App\BpnConnect\AbstractApiClient
App\BpnConnect\TravelsClient:
parent: App\BpnConnect\AbstractApiClient
App\ClickUp\ApiClient: App\ClickUp\ApiClient:
arguments: arguments:
$apiToken: '%env(default::CLICKUP_API_TOKEN)%' $apiToken: '%env(default::CLICKUP_API_TOKEN)%'
@@ -356,3 +440,25 @@ services:
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%' $apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%' $defaultListId: '%env(default::MAILJET_DEFAULT_LIST_ID)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%' $contactMetadataFields: '%mailjet_contact_metadata_fields%'
# Travel mailing lists live in a Mailjet sub-account, which has its own key pair and keeps
# its contacts and lists apart from the newsletter on the main account.
app.mailjet.travel_mailing_client:
class: App\Mailjet\ApiClient
arguments:
$apiKey: '%env(default::MAILJET_TRAVEL_MAILING_API_KEY)%'
$apiSecret: '%env(default::MAILJET_TRAVEL_MAILING_API_SECRET)%'
$apiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%'
$contactMetadataFields: '%mailjet_contact_metadata_fields%'
App\Service\TravelMailingListManager:
arguments:
$mailjet: '@app.mailjet.travel_mailing_client'
App\EventListener\MaintenanceModeListener:
arguments:
$options:
enabled: '%env(bool:MAINTENANCE_MODE_ENABLED)%'
whitelisted_ips: '%env(csv:MAINTENANCE_MODE_IP_WHITELIST)%'
whitelisted_routes: '%env(csv:MAINTENANCE_MODE_ROUTE_WHITELIST)%'
whitelisted_paths: '%env(csv:MAINTENANCE_MODE_PATH_WHITELIST)%'
+1 -1
View File
@@ -5,7 +5,7 @@ parameters:
theme: base theme: base
gtm_id: GTM-56LWRF gtm_id: GTM-56LWRF
cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js cmp_url: https://consent.cookiefirst.com/sites/my.ep-reisen.de-28eabc35-7085-4d8f-b450-522bd1a5247c/consent.js
myswb.ddev.site: mysbw.ddev.site:
theme: sbw theme: sbw
gtm_id: GTM-MS2C8CH gtm_id: GTM-MS2C8CH
cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js cmp_url: https://consent.cookiefirst.com/sites/my.ski-boarderweek.de-d3641c34-8183-4b8d-b56e-c052e7a9223e/consent.js
+34 -38
View File
@@ -9,7 +9,6 @@ require 'recipe/symfony.php';
add('shared_dirs', [ add('shared_dirs', [
'config/secret', 'config/secret',
'var/bpn', 'var/bpn',
'var/log',
'var/sessions', 'var/sessions',
'var/jsonexport', 'var/jsonexport',
'var/xmlexport', 'var/xmlexport',
@@ -35,10 +34,13 @@ $rsyncOptions = [
'.jj', '.jj',
'node_modules', 'node_modules',
'.editorconfig', '.editorconfig',
'.env.local',
'.env.local.php',
'.env.dev.local', '.env.dev.local',
'.env.dev.local.php', '.env.dev.local.php',
'.env.test', '.env.test',
'.env.test.local', '.env.test.local',
'.browserslistrc',
'.gitignore', '.gitignore',
'.nvmrc', '.nvmrc',
'.php-cs-fixer.cache', '.php-cs-fixer.cache',
@@ -57,8 +59,7 @@ $rsyncOptions = [
'phpunit.xml.dist', 'phpunit.xml.dist',
'public/.htaccess', 'public/.htaccess',
'ray.php', 'ray.php',
'tailwind.config.js', 'webpack.config.mjs',
'webpack.config.js',
'bin/.phpunit', 'bin/.phpunit',
'temp/', 'temp/',
'tests/', 'tests/',
@@ -76,75 +77,70 @@ $rsyncOptions = [
'timeout' => 300, 'timeout' => 300,
]; ];
// Shared by all hosts.
set('bin/php', '/usr/bin/php');
set('http_user', 'myepsf');
// Must stay here: contrib/rsync.php sets the same key, but its __DIR__ is the vendor dir.
set('rsync_src', __DIR__);
set('rsync', $rsyncOptions);
host('prod') host('prod')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setDeployPath('/usr/www/users/myepsf/prod')
->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.de')
; ;
host('staging') host('staging')
->setHostname('185.243.135.29') ->setHostname('dedi10193.your-server.de')
->setRemoteUser('p704161') ->setDeployPath('/usr/www/users/myepsf/staging')
->setRemoteUser('myepsf')
->setForwardAgent(true) ->setForwardAgent(true)
->setSshMultiplexing(true) ->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-staging')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://my.ep-reisen.net --web-basic-auth=myep:staging')
->add('shared_files', [ ->add('shared_files', [
'public/.htpasswd', 'public/.htaccess',
])
;
host('develop')
->setHostname('185.243.135.29')
->setRemoteUser('p704161')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/home/www/p704161/html/myep-develop')
->set('writable_mode', 'chmod')
->set('http_user', 'p704161')
->set('bin/php', '/usr/local/bin/php')
->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{current_path}}/public/ --web-url=https://mydev.ep-reisen.net --web-basic-auth=myep:develop')
->add('shared_files', [
'public/.htpasswd', 'public/.htpasswd',
]) ])
; ;
task('deploy', [ task('deploy', [
'deploy:info', 'deploy:info',
'deploy:assets',
'deploy:setup', 'deploy:setup',
'deploy:lock', 'deploy:lock',
'deploy:release', 'deploy:release',
'deploy:assets',
'rsync', 'rsync',
'deploy:shared', 'deploy:shared',
'deploy:writable', 'deploy:writable',
'deploy:cache:clear', 'deploy:cache:warmup',
'database:migrate', 'database:migrate',
// Must run before the symlink flip: the cachetool probe file is only reachable through
// the release the docroot currently resolves to. It still pays off, because it drops the
// opcache entries keyed under current/public/*.
'cachetool:clear:opcache', 'cachetool:clear:opcache',
'deploy:publish', 'deploy:publish',
'deploy:stop-workers', 'deploy:stop-workers',
]); ]);
// Purely local, so it runs before anything is created on the remote. once(), so
// deploying multiple hosts at the same time builds the bundle only once.
task('deploy:assets', function () { task('deploy:assets', function () {
runLocally('ddev exec npm ci'); runLocally('ddev exec npm ci');
runLocally('ddev exec npm run build'); runLocally('ddev exec npm run build');
})->once();
// The recipe's deploy:cache:clear only does something when composer ran with
// --no-scripts, which never happens here: vendor/ is rsynced, composer never runs
// remotely. A fresh release has no var/cache to clear, so warm it up directly.
// Runs after deploy:writable so the default ACLs are already in place.
task('deploy:cache:warmup', function () {
run('{{bin/console}} cache:warmup {{console_options}}');
}); });
// Workers keep running the previous release's code until they are told to stop.
task('deploy:stop-workers', function () { task('deploy:stop-workers', function () {
run('{{bin/console}} messenger:stop-workers'); run('{{bin/console}} messenger:stop-workers');
}); });
+399 -17
View File
@@ -10,7 +10,10 @@ A Symfony application exposing a read-mostly JSON API under the `/api` prefix. A
- Token endpoint: `POST /token` (unauthenticated). - Token endpoint: `POST /token` (unauthenticated).
- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled.
- Access token TTL: **10 minutes** — clients must refresh/re-request frequently. - Access token TTL is **10 minutes** for every grant.
Do not hardcode it — read `expires_in` from the token response, it is authoritative and may be tuned.
A token can therefore expire part-way through a long page walk of `GET /api/exports/*`; re-request one on
401 and resume at the page you were on. At the default `perPage=500` either export is a single page today.
- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`.
- Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Each granted scope maps to a role `ROLE_OAUTH2_<SCOPE_UPPERCASE>` (e.g. scope `api` → `ROLE_OAUTH2_API`).
- Send the token as `Authorization: Bearer <access_token>`. - Send the token as `Authorization: Bearer <access_token>`.
@@ -47,7 +50,9 @@ There is no single error envelope; three shapes occur:
- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code. - `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code.
- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter). - `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter).
Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/exports/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**.
`GET /api/exports/*` deliberately uses real status codes throughout and never the 200-with-an-`error`-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them.
@@ -59,11 +64,23 @@ The HTTP methods listed below are the intended ones. Several read routes do not
#### `GET /api/hotels` — scope `api` #### `GET /api/hotels` — scope `api`
List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`. List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`,
`costUnit`, `countryCode`.
- `country` is the **raw** upstream token, unnormalised, and is the join key into `GET /api/countries[].token`.
It mixes conventions: ISO-2 (`CH`, `NL`), European vehicle registration codes (`D`, `A`, `F`, `S`, `FL`) and
ISO-3 (`AUT`, `PRT`, `HRV`), sometimes several for the same country.
- `countryCode` (`string|null`) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick
a flag. A token with no confident mapping yields **`null`**, never a guess and never `""`, so treat null as
"unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until
the mapping table is extended.
- `costUnit` (`string|null`) is the raw upstream cost centre. It is a **string, not a number**: most hotels have
none, and some carry a space-separated list such as `"84 85"`. Store it as text and do not parse it.
#### `GET /api/hotels/{id}` — scope `api` #### `GET /api/hotels/{id}` — scope `api`
Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown. Single hotel by numeric id, same field set as the list, including `costUnit` and `countryCode`.
`404 {"message":"Not found"}` if unknown.
#### `GET /api/countries` — scope `api` #### `GET /api/countries` — scope `api`
@@ -75,7 +92,9 @@ Product master data from the upstream BusProNet system, cached server-side for 1
#### `GET /api/last-update` — scope `api` #### `GET /api/last-update` — scope `api`
Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown". Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}` in `Europe/Berlin` local time.
If the export marker is missing or unreadable (for instance mid-sync), the answer is `{"timestamp": null}` with
status 200 — treat null as "unknown", not as "changed".
--- ---
@@ -122,6 +141,272 @@ This is a full overwrite, not a merge.
--- ---
### Bulk exports
#### `GET /api/exports/travels` — scope `api`
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over `GET /api/travels` plus a per-pair detail call.
The two `/api/exports/*` endpoints are the only paginated ones and the only ones with a `meta` envelope.
It sits under `/api/exports`, not `/api/travels`, on purpose: `/api/travels/{dateCode}` accepts an
arbitrary string, so an export hung off that collection would share a namespace with every date code.
`/api/exports` never takes a parameterised segment, so there is nothing for it to collide with.
Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
| `page` | int ≥ 1 | `1` | Out of range is **not** clamped — see below |
| `perPage` | int 1–1000 | `500` | |
| `changedSince` | — | — | **Reserved, not implemented.** Sending it today is ignored; it is named here only so it is not claimed for anything else. |
A `page`/`perPage` outside those bounds returns **400** with Symfony's constraint-violation body (send
`Accept: application/json` to get it as JSON rather than an HTML error page). A `page` past the last one is *not*
an error: it returns `200` with an empty `data` array and the usual `meta`.
```json
{
"data": [
{
"busProId": 12794,
"code": "PCJSER261226",
"productCode": "PCJSER",
"product": "Semesterende - Portes du Soleil - Joliment",
"dateFrom": "2026-12-26",
"dateTo": "2026-12-30",
"hotel": {
"busProId": 225444,
"code": "PCJSER1",
"name": "NEU SER Jolimont 1",
"costUnit": "863",
"country": "CH",
"countryCode": "ch"
},
"pickups": [
{
"busProId": 18,
"code": "ESS-Route",
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00"
}
]
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 415,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
```
**Row identity.** `(busProId, hotel.busProId)` is unique across the whole export — upsert on that pair. `busProId`
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
empty, never null.
**`productCode`** is the code of the travel (product) the date belongs to, e.g. `DKIWE`. Key on it to
recognise dates of the same product. Do not derive it from the date `code` by cutting off the date: some
products run dates under a different code (`DWWMP` runs `DWWMW…`), and group travels carry each group's own
code on the date. It is `null` for the few travels that have no code upstream.
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
from the hotel master data. Expect `0`. A non-zero value means upstream data is inconsistent, not that you have
lost rows you previously had.
- `pickupsWithoutMasterData` — count of **distinct** pickup ids referenced by a travel but absent from
`GET /api/pickups`. Those entries are still emitted, with `code`, `city` and `street` null: a departure point
with no name is more useful to a disposition view than one that silently vanished.
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
an offset to compare or sort them; they already sort correctly as strings.
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
`datetime` carries that resolution for bare-time sources too.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a
day earlier than the running order implies. This is upstream data, not a transformation applied here.
**Consistency.** The export is built from the same cached master data as `GET /api/hotels` and `GET /api/pickups`,
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
#### `GET /api/exports/products` — scope `api`
Bulk export of every travel date × hotel pair with its **whole record**, for clients that import the product
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
boarding points with prices, and the guide. Same grain, paging, query parameters, `meta` envelope, status codes and
page-consistency rule as `GET /api/exports/travels` above; only the rows differ.
**Unfiltered on purpose.** Nothing is dropped or rewritten for you. Return-leg (`RUECK`) services, `EIN` services,
`Buchungsstop` rooms and services, rooms without a price and boarding points with id `0` are all emitted. Which of
them a website shows, and how, is the client's rule, not this endpoint's.
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default `perPage=500`
returns it in one page.
```json
{
"data": [
{
"product": { "code": "DKIWE", "busProId": 1127 },
"date": {
"code": "DKIWE041226",
"busProId": 12437,
"from": "2026-12-04",
"to": "2026-12-06",
"type": "F"
},
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
"pseudoPrice": null,
"selectionGroups": [
{
"busProId": 7,
"label": "Leistungen Skipass",
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
}
],
"transportServices": [
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
],
"dateServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"hotelServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"rooms": [
{
"busProId": 74,
"code": "MBZET",
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
"category": "GR",
"minPax": 1,
"maxPax": 1,
"nights": 2,
"price": 229,
"available": 24,
"status": "Frei",
"boardCode": "Lt. Auss.",
"boardBusProId": 2
}
],
"pickups": [
{
"busProId": 4,
"code": "FRA-Route",
"city": "Frankfurt",
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
"price": 5.9,
"returnLeg": true,
"returnPrice": 5.9,
"datetime": "2026-12-03T23:30:00"
}
],
"guide": null
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 419,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
"generatedAt": "2026-09-30T14:05:00+02:00"
}
}
```
**Row identity.** `(date.busProId, hotel.busProId)` is unique across the whole export — upsert on that pair.
`product.code` and `hotel.code` are verbatim and suitable for matching against your own records. No row depends on
hotel master data, so `skipped` counts only travel dates whose dates could not be parsed.
**Types.**
- Every price is a JSON **number** in euros, never a formatted string: `229`, `5.4`, `-50`. A price the source does
not state is `null`, and `0` means a stated zero ("included"). Do not treat the two alike.
- `selectionGroups`, `selections`, `transportServices`, `dateServices`, `hotelServices`, `rooms` and `pickups` are
always arrays, possibly empty, never null. `pseudoPrice` and `guide` are nullable.
- All arrays are in **source order**. Sort for display yourself; do not depend on the order.
**Rooms.** One entry per room offered on the date at that hotel.
- `code` is trimmed. `available` is a count of **rooms**, not persons — person capacity is `available × maxPax`.
- `status` is passed through verbatim: `Frei`, `Anfrage` or `Buchungsstop`.
- `available`, `price`, `minPax`, `maxPax`, `nights` and `boardBusProId` are `null` where the source leaves them
empty.
**`pseudoPrice`.** A struck-through comparison price for the date, carried upstream by a pseudo room with code
`PDGS`. It is lifted onto the row and never appears in `rooms`. It is `null` in most rows.
**Services.** Three lists, deliberately kept apart:
- `transportServices` — the date's transport services (bus, own arrival, train …). Both directions.
- `dateServices` — the date's general services.
- `hotelServices` — the services listed on this hotel for this date. Upstream repeats most date-level services
here, possibly with hotel-specific prices, so expect overlap with `dateServices`. If you merge the two, key by
`busProId`: it identifies a service across both lists.
`subType` is the upstream service subtype (`BUS`, `PKW`, `BAH`, `SON`, `SPA`, `VER`, `EIN`, …), passed through.
`direction` is `HIN` or `RUECK` on transport services and `null` on general ones. A negative `price` is a discount,
such as for arriving by car.
**Selection groups.** Every group on the date, not only the `Leistungen *` ones. Match groups by `busProId`, not by
`label`: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
| `busProId` | Label |
|---|---|
| 7 | Leistungen Skipass |
| 8 | Leistungen Sonstige |
| 71 | Leistungen Verpflegung |
| 85 | Leistungen Übernachtung |
| 86 | Leistungen Programm |
| 87 | Leistungen Kurse |
| 89 | Leistungen Events |
| 90 | Leistungen Transport |
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
none. The surcharge is charged **per leg**: `returnLeg` says whether the point is also served on the way back, and
`returnPrice` is its return surcharge, again verbatim and `null` where upstream states none. Neither price is levelled
against the other. A point served on the return leg only is not emitted. An id absent from
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
of `0` is emitted as-is but not counted.
**`guide`.** `{ "name": …, "phone": … }` for the first outbound bus service that has a guide assigned, else `null`.
---
### Travels ### Travels
#### `GET /api/travels` — scope `api` #### `GET /api/travels` — scope `api`
@@ -222,21 +507,74 @@ Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations
`502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel. `502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel.
#### `GET /api/contingents/availability?hotelCode=…&dateFrom=…&dateTo=…` — scope `api`
The raw contingent snapshot per day, without prices. Built for the iCal feed that external booking portals import, which blocks only what the contingent itself reports as unavailable.
Parameters are the same as for `calendar`: `hotelCode` is required, `dateFrom` and `dateTo` are optional, `Y-m-d`, inclusive, and must be supplied together (at most 366 days). **Omit both to get every snapshot day from today to the end of the snapshot** (the 24-month sync horizon). Prices play no part in that span.
Each entry, ordered by date:
```json
{"date": "2027-01-30", "status": "BLOCKED"}
```
`status` is `OK`, `BLOCKED` or `ON_REQUEST`.
Two differences from `calendar`:
- **No price rule.** A day without a maintained price keeps the status the contingent reports; it is not forced to `BLOCKED`.
- **Days the snapshot lacks are left out**, not returned as `BLOCKED`. The response is not contiguous: a day without an entry has no availability data, and the feed shows it as free, as it did when it read the upstream service directly.
- **Not limited to accommodations.** `hotelCode` may be any synced hotel code: an accommodation's calendar code, or the calendar hotel of a visible TYPO3 product (TYPO3's `/api/calendar-hotels`). Winter-only hotels without an accommodation are therefore served here, while `calendar` and `prices` still need an accommodation, since they need its prices.
Errors are the same as for `calendar`: `400 {"error":"Hotel not found for hotelCode."}` when the code is not synced, `400` with violations for invalid parameters, and `502 {"error":"Failed to fetch contingent data."}` when there is no usable snapshot. For this endpoint the `502` is the important one: an empty list would show every day as free, so a consumer should answer with an error and let the portals keep their last import.
#### `GET /api/contingents/status` — scope `api`
One row per synced hotel code, sorted by code, for an overview of which hotel supports which calendar:
```json
{
"hotelCode": "DGS",
"name": "Sportclub Spinabad",
"sources": ["accommodation", "typo3"],
"accommodation": {"name": "Davos Glaris - Gruppenhaus Spinabad", "cmsCode": "DGSGRP"},
"pricedFrom": "2027-06-01",
"pricedTo": "2027-09-30",
"syncedAt": "2026-10-29T10:15:00+01:00",
"lastError": null,
"failureCount": 0,
"available": true
}
```
- `sources`: where the code comes from, `accommodation` and/or `typo3`.
- `name`: the hotel's name in TYPO3's calendar hotel list, `null` for a code TYPO3 does not list.
- `accommodation`: `null` without one; `cmsCode` is the code the accommodation is known by in TYPO3, which may differ from `hotelCode`.
- `pricedFrom`/`pricedTo`: the span of the accommodation's prices, `null` without prices. `calendar` and `prices` work only for hotels that have them.
- `syncedAt`, `lastError`, `failureCount`: the last successful sync (`null` if none) and the failures since.
- `available`: whether `availability` currently serves the hotel, i.e. it has synced within the last 6 hours. `false` means that endpoint answers `502`.
--- ---
### Operating the snapshot (deployment) ### Operating the snapshot (deployment)
The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability. The snapshot is filled only by the scheduler, so on an empty database (a new environment, or after the snapshot tables were truncated) every hotel returns `502` until the first successful sync — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability.
Rollout order: Bringing up an environment:
1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`. 1. **Confirm `BPN_CONNECT_API_KEY` is valid for the environment.** If it is rejected the sync cannot populate anything and the endpoints stay at `502`.
2. Deploy; `database:migrate` creates the empty tables. 2. Deploy; `database:migrate` creates the tables.
3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period. 3. Run `bin/console app:bpn:sync-contingents -v` on the host promptly, ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period.
4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response. 4. Verify with `GET /api/contingents/status`: every row `available: true`, `failureCount` 0.
Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally. Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally.
**Which hotels are synced.** Every accommodation's calendar code plus every code in TYPO3's `/api/calendar-hotels` (same `APP_CMS_API_KEY` as the other CMS calls). Each run fetches TYPO3's list first and stores it in `calendar_hotel`; when TYPO3 errors or answers with an empty list, the run logs a warning and uses the stored list, so TYPO3 being down never drops its hotels. `--hotel=<code>` accepts any code from either source. A full run deletes the snapshot of codes that are in neither source any more; they answer `400` from then on, and come back with the next run if they reappear.
**TYPO3's codes must be BusPro calendar codes.** The sync sends every code to bpn-connect as is. A TYPO3 hotel whose code is a CMS code rather than the BusPro hotel code (group houses often have both, e.g. calendar code `DKS`, CMS code `DKSGRP`) is answered with a `404` by bpn-connect: that code fails on every run, logs two errors each time, and shows up in `contingents/status` with `lastError` set and `available: false`. The task itself keeps succeeding, since only a total failure fails it. The fix belongs in TYPO3, by pointing the product at a hotel whose code is the BusPro code; MyE&P does not translate CMS codes. `contingents/status` is the place to spot such codes, and `accommodation.cmsCode` there shows both codes side by side for group houses.
### Freshness and propagation ### Freshness and propagation
There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side. There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side.
@@ -276,14 +614,35 @@ Single booking by UUID. `404 {"message":"Not found"}` if unknown.
"accommodationDiscount": null, "accommodationDiscount": null,
"boardServiceDiscount": null, "boardServiceDiscount": null,
"additionalServicesDiscount": null, "additionalServicesDiscount": null,
"totalDiscountAmount": null,
"totalDiscountLabel": null,
"totalPrice": 123456, "totalPrice": 123456,
"pricingCurrency": "EUR", "pricingCurrency": "EUR",
"pricingVersion": 3, "pricingVersion": 2,
"priceBreakdown": {} "priceBreakdown": {}
} }
``` ```
`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. `totalPrice` and `totalDiscountAmount` are integer **minor units** (cents) — unlike the contingent endpoints. The three `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure.
The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges, running costs and the lines of split-off periods sit in `total` but in no section, so the section discounts never reach them; only `totalDiscountAmount` does.
`dateFrom`/`dateTo`/`nights` span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. `paxCount`, `minorsCount`, `childrenCount`, `boardService` and `additionalServices` describe the **group**, which stays for what remains between the split-off periods. The split-off periods are listed in `priceBreakdown.periods`, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
`totalDiscountAmount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. It is a fixed sum, not a percentage, and is subtracted **after** the three section discounts have reduced `total`. It is capped at what is left of that subtotal, so `discountedTotal` is never negative and the amount shown in the breakdown may be smaller than `totalDiscountAmount` itself. The section discounts are each rounded to the nearest cent as they are applied.
`priceBreakdown` carries the resulting rows, all in minor units:
| field | meaning |
| --- | --- |
| `nights` | the group's nights, which are fewer than the booking's `nights` when periods were split off |
| `periods` | list of `{position, label, dateFrom, dateTo, nights, lines, total}`, at most one per side, in date order; `position` is `leading` (before the group) or `trailing` (after it), `label` is the office's name for the period or `null`, `lines` is a list of `{label, amount}`, and `total` is their sum |
| `periodsTotal` | the sum of all `periods[].total` |
| `total` | the price before discounts, the group's rows plus `periodsTotal` |
| `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix |
| `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscountAmount` applies *and* at least one section discount did, otherwise `null` |
| `totalDiscountDetails` | `{label, amount}` for the total discount, or `null`; `amount` is the capped figure actually subtracted |
| `discountedTotal` | the final price, equal to the booking's `totalPrice` |
#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api` #### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api`
@@ -364,9 +723,9 @@ Responses:
OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes: OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes:
- always: `email` - always: `sub` (UUID string, see below), `email`
- scope `id`: `person_id`, `address_id` - scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`) - scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
- scope `profile`: `profile` object: - scope `profile`: `profile` object:
```json ```json
@@ -379,7 +738,30 @@ OIDC-style claims for the authenticated user. The response contains only the cla
} }
``` ```
Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity. ##### Identifying the account: match on `sub`
A BusPro address record holds several email addresses (private and business, say), and BusPro
accepts **any** of them as a login. Each one is a **separate MyE&P account with its own roles** —
that is intended, not a duplicate: administrative roles are reserved for the company address, so
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
`@ep-reisen.de` address is an administrator.
All of those accounts report the **same** `person_id` and `address_id`, and the same
`profile.communication.email` — that value is the first contact address on the BusPro record and
is **not necessarily** the one signed in with. So:
- **`sub`** — a **UUID (v4, RFC 4122, 36 characters)**, stable for the life of the account and
unique across accounts. The only claim that identifies an account. Match your local user on it
and store it. Size the column at 36 characters and treat the value as opaque: it is random, it
carries no timestamp and no ordering, and nothing about it should be parsed or derived from.
- **`email`** — the address this session actually authenticated with. Distinct per account, but
treat it as a display and contact value; it is not the account key.
- **`person_id` / `address_id`** — the BusPro person behind the account. Shared between that
person's accounts, so they identify a *human*, never an account. Do not match on them.
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
exports the result as `ROLE_EMPLOYEE` in the `roles` claim; re-deriving it from `email` or from
`profile.communication.email` will disagree with MyE&P in both directions.
`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials. `400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials.
@@ -391,7 +773,7 @@ CRM selection attributes/segments for the authenticated user. `400 {"message": "
## Client implementation notes ## Client implementation notes
1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401. 1. **Token lifetime comes from `expires_in`** (currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh with a safety margin, and retry once on 401.
2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint. 2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint.
3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary. 3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries. 4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries.
+4
View File
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
- admin users also receive default hotel code `SSL` - admin users also receive default hotel code `SSL`
- no matched role means `ROLE_CUSTOMER` - no matched role means `ROLE_CUSTOMER`
These are the CRM's claims. The portal filters them before storing anything (staff-only
roles, approval markers), so a proxy must return the claims unfiltered — see
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
Candidate SQL for selected customer selections: Candidate SQL for selected customer selections:
```sql ```sql
+477
View File
@@ -0,0 +1,477 @@
# BusProNet XML API — working reference
Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project
uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a
broken font encoding, so anything unclear there is flagged rather than guessed at.
> **The PDF lags the live API.** The vendor has shipped elements since it was written —
> `<ausstiege>` (return boarding points) is one confirmed example. So *absent from the PDF* means
> "not in this snapshot", never "unsupported". Treat the document as a floor on what the API
> accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from
> silence.
Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer
different questions, and where they disagree the schema is the record of what happened.
**Use it for:** what an element is called and what it may contain; which codes a failure can
return; which documented capabilities we are not using; and which elements we send that the
vendor never documented — those are the ones that can break without warning.
## Envelope
Every request is `<anfrage>` with `<user>`, `<key>` and `<satz typ="…"/>`.
```
key = md5( user + password + date("YYYYMMDD") + satz-typ )
```
Per satz type and per day. `XmlChannel::createKey()` implements it, and `XmlChannel::replay()` regenerates
the key before sending, so a hand-built payload can carry a placeholder.
## Satz types
**Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`,
`AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`,
`CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`,
`GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`,
`KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`,
`STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`,
`VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`.
`SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section.
**Used here but undocumented:** `VERFUEGBARKEIT2` (`MessageType::AvailabilityExtended`) — the
extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`.
Its contract is observed, not specified.
**Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`,
`ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`.
## BUCHUNG — the create contract
`<buchungsart>` decides what the call does:
| Value | Effect |
|---|---|
| `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. |
| `Buchung` | Stores the booking. |
`<status>` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by
error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by
`982`/`983` when the travel forbids options or enquiries outright.
### Two accommodation models
The base payload books accommodation as an ordinary service:
```xml
<unterbringungen>
<unterbringung idleistung="22341" anzahl="1" zuordnung="1,2"/>
</unterbringungen>
```
For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by
`ferienzielunterbringungen`, **and** a root `<idpartner>` added:
```xml
<ferienzielunterbringungen>
<ferienzielunterbringung idzimmer="2" kategorie="Ik" idverpflegung="2"
anreise="30.01.2018" abreise="06.02.2018"
anzahl="1" zuordnung="1,2"/>
</ferienzielunterbringungen>
…
<idpartner>78687</idpartner>
```
> `<idpartner>` is introduced as the element you add when switching to the Ferienziel model, and
> `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a
> teamer booking with zero `BuchungTNFerienziel` rows still returns `<idpartner>222925</idpartner>`
> from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so
> either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the
> travel. That booking's product has exactly one hotel, so the response cannot distinguish the two;
> one read-only query settles it.
Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen
fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see
"Validation gate order" below. Room dates are cross-checked against the transport legs (`921`,
`923`).
### Full documented request shape
```xml
<anfrage>
<user/><key/><satz typ="BUCHUNG"/>
<buchungsart>Anfrage|Buchung</buchungsart>
<status>F|O|A</status>
<idagentur/> <idcrm/> <idreise/> <idpartner/>
<fremdvorgang/> <!-- your own reference, stored as Buchung.FremdVorgang -->
<datenschutztfmp/> <!-- ad consent, combinable: T Telefon, F Telefax, M EMail, P Post -->
<anmelder>
name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum,
<anschrift>strasse, plz, ort, ortsteil, land</anschrift>
<kommunikation>telefonmobil, email, telefonprivat</kommunikation>
<bemerkung/>
</anmelder>
<teilnehmerliste>
<teilnehmer id="1">
…same personal fields…
<wünsche><unterbringungswunsch/><beförderungswunsch/></wünsche>
<aktionscode/>
</teilnehmer>
</teilnehmerliste>
<beförderungen><beförderung idleistung anzahl zuordnung/></beförderungen>
<ferienzielunterbringungen>…</ferienzielunterbringungen>
<zusatzleistungen><zusatzleistung idleistung anzahl zuordnung/></zusatzleistungen>
<zustiege><zustieg idzustieg anzahl zuordnung/></zustiege>
<sitzpläne><sitzplan idleistung idsitzplan sitzplätze="1B;1A"/></sitzpläne>
<versicherungen><versicherung idversicherung anzahl zuordnung/></versicherungen>
<gutscheine><gutschein gutscheinnr einloesecode/></gutscheine>
<zuschlaege>
<zuschlag text betrag anzahl zuordnung
provision altersrabatt gruppenrabatt frühbucherrabatt bzberechnung/>
</zuschlaege>
<zahlung idzahlungsart bezeichnung art>
<bankverbindung kreditinstitut bic iban kontoinhaber/>
</zahlung>
</anfrage>
```
`zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout.
### Responses
`buchungsart=Anfrage` → `<buchung>möglich</buchung>` plus `<preise>`: one `<preis>` per line with
`position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`,
`preis`, `gesamtpreis`, `id`; then `<gesamtpreis>` and `<zahlungsbedingungen>` with `<anzahlung>`
and `<restzahlung>` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT`
accommodation, `SON` other, `ERM` discount, `VER` insurance.
`buchungsart=Buchung` → `<buchung>erfolgt</buchung>`, `<vorgang>`, `<rechnung>`, `<idbuchung>`,
`<email>`, `<pdfpasswort>`.
### Validation gate order (measured)
Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real
room-less teamer booking (Vorgang 110628) element for element:
1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being
valid. Nothing further is evaluated.
2. **Personal data**, once a room is present → `650` with the field list *"Fehlende
Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse,
Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**.
So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the
personal fields whether or not it could resolve them from the ids. Echoing the record back
(`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is
genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003
fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only
`telefonprivat` and no `nationalitaet`.
**Participants are held to less than the Anmelder** (measured 2026-09-16 on 2036, travel 12500, from a
real step-3 `Anfrage`). `telefonmobil` and an empty `anschrift` pass for participants. The field list
above (Strasse, Plz, Ort, Telefon-Mobil for every participant) was measured on the placeholder records and
is stricter than what BusPro applies to ordinary participants.
**The participant `Mail-Adresse` is BusPro configuration, not a fixed rule.** Until 2026-10-13 every
`teilnehmer` needed one once a room was present, children included: without it the answer was
`650 … 1.Teilnehmer-Mail-Adresse, 2.Teilnehmer-Mail-Adresse`, while an email identical to the Anmelder's
was accepted. On 2026-10-15 the requirement was switched off on the test system, and the same requests
(no `email`, no `kommunikation`, an empty `<email/>`, adults and children, with and without a travelling
Anmelder) all pass with an unchanged price. A committed `Buchung` (status `A`) with an email-less child
goes through as well, both with a travelling Anmelder and with one who does not travel. BusPro now accepts a participant without an email at any age,
so the rule MyE&P applies (required from 16, and for the applicant at any age) is enforced only by
`ParticipantDto` and `BookingDto` validation. Production needs the same configuration before under-16s
without an email can be booked there.
**The Anmelder does not have to travel.** An `<anmelder>` that matches no `teilnehmer` (a parent booking
for their children) returns `möglich` with the same price as the control where the Anmelder is
`teilnehmer 1`. Whether the committed booking binds to the Anmelder's existing customer record has not
been measured yet.
Note `650` again: both of those are hard rejections delivered under the code
`Notification::isError()` treats as success.
## BUCHUNGAENDERUNG — the edit contract
Structurally identical to `BUCHUNG` plus `<idbuchung>`, and the responses follow `buchungsart`
the same way. Ask `MOEGLICHEAENDERUNGEN` first (`<idreise>`), which answers:
```xml
<änderungen>
<änderung art="anzahl_teilnehmer" möglich="False"/>
<änderung art="beförderung" möglich="False"/>
<änderung art="zustieg" möglich="False"/>
<änderung art="sitztplatz" möglich="False"/> <!-- vendor's typo -->
<änderung art="unterbringung" möglich="False"/>
<änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/>
<änderung art="teilnehmerdaten" möglich="False"/>
</änderungen>
```
## How this app maps onto the contract
`BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the
`addTo…Payload()` helpers build edit; `BookingClient::createBooking()`/`updateBooking()` wrap them.
| Documented element | Sent on create | Sent on edit | Notes |
|---|---|---|---|
| `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`BookingClient::updateBooking()`) |
| `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below |
| `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room |
| `anmelder` | yes | yes | Create builds it from participant[0], or from `BookingDto::$applicant` when the applicant does not travel; edit uses `PersonalData::toPayload()`. BusPro accepts an Anmelder who is not a participant |
| `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` |
| `teilnehmerliste` | yes | yes | |
| `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | |
| `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` |
| `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` |
| `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton |
| `versicherungen` | yes | yes | |
| `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` |
| `zahlung` + `bankverbindung` | yes | yes | |
| `idcrm` | no | no | |
| `fremdvorgang` | no | no | Would give our own bookings a stable external reference |
| `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead |
| `sitzpläne` | no | no | See Seats |
| `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount |
### Newer than the PDF
These are absent from the document but work against the live API — vendor additions that
post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the
gaps in this reference are known.
| Element | Where | Note |
|---|---|---|
| `<ausstiege><ausstieg idzustieg …/></ausstiege>` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only `<zustiege>`. |
| `<status>` inside `<teilnehmer>` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. |
| `<login>J</login>` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. |
| `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. |
When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a
reading of this file.
### Caveat: the edit flow replays a derived status
`buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed
from the response's root `<status>` (`BookingParser.php:38`). But `Buchung` has **no status
column** — the root value is an aggregate over `BuchungTN.Status`, the only real store
(`<status>F</status>` beside `<status_teilnehmer>F/F/…/F</status_teilnehmer>` in
`tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one
status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed
booking before relying on it.
### Gap: edit never dry-runs
Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends
`buchungsart=Buchung` directly (`BookingClient::updateBooking()`), even though `BUCHUNGAENDERUNG` supports
`Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated
price *before* mutating a live booking.
## Seats
`SITZPLATZ` asks BusPro to pick seats:
```xml
<satz typ="SITZPLATZ"/>
<idreise/><idleistung_hin/><idleistung_rueck/><anzahl_personen/>
<wunsch_vornemittehinten>V|M|H</wunsch_vornemittehinten>
<wunsch_linksrechts>L|R</wunsch_linksrechts>
<wunsch_fenstergang>F|G</wunsch_fenstergang>
```
Response: one `<sitzplan>` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`,
`richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where
seats carry surcharges the response adds `<zuschläge>` (`id`, `bezeichnung`, `preis`, colours) and
the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure
instead of a picture.
Chosen seats go back into a booking via `<sitzpläne>`, landing in
`BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei
Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz
ermittelt werden`.
## KUNDENKONTO
`<art>`: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`,
`SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`,
`Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`,
`Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`.
`Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` —
the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a
`<personen>` list with `<ausweis>` data and answers `änderung`. `Versicherungspolice` returns
per-policy document URLs.
## AGENTURKONTO
`<art>`: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`,
`OptionInFest` — the last converts a held option into a firm booking, answering
`<optioninfestumgewandelt>true</optioninfestumgewandelt>`. `Vorgangdruck` takes `idbuchung`,
`Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return
base64 PDFs.
## Availability and payment
`VERFUEGBARKEIT` takes `<idreise>` plus a `<leistungen><leistung id="…"/></leistungen>` filter and
answers `<reise id status>` plus per-service `status` ("buchbar"), `frei`, `preis`,
`preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports
`buchungstatusmoeglich` and `pflicht` but is undocumented.
`ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking
(`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory).
`ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed
`anzahlung` or errors `1011`-`1018` follow.
## Selling vouchers (Kaufgutschein)
Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop
(`maske_gutschein.php`). `VoucherClient`, `MessageType::Voucher*`; not to be confused with
`MessageType::PurchaseVoucher` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking.
| Satz | Request | Response |
|---|---|---|
| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>data:application/pdf;base64,…` (measured, about 430 KB) |
| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields |
| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` |
| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived |
```xml
<satz typ="GUTSCHEINBUCHUNG"/>
<buchungsart>Anfrage|Buchung</buchungsart>
<gutscheinart>K</gutscheinart>
<idgutscheinvorlage>3</idgutscheinvorlage>
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
<kaufpreis>25</kaufpreis>
<text/> <!-- Zusatztext; the live shop sends it empty -->
<rechnungsempfänger>
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
</rechnungsempfänger>
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
```
`<preiszusaetze>` is documented but not sent; the total is the voucher value. `telefonmobil` is not
in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The
live shop also collects a business phone, whose element name is not documented anywhere, so the
flow does not collect it.
### Measured on the test system (2026-10-01)
`buchungsart=Anfrage` is a full dry run: a valid request answers `<buchung>möglich</buchung>` and
nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is
issued. `idagentur` is not required (619/620 never fired).
| Variant | Answer |
|---|---|
| Valid (control), 25 € | `möglich` |
| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` |
| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` |
| `kaufpreis` 5000 | `möglich` (bounds inclusive) |
| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) |
| `empfaenger` missing | `605 Empfängername fehlt` |
| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) |
| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` |
| `strasse` empty | `650 … Strasse` |
| `anrede` Herr, `telefonmobil` set | `möglich` |
Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats
every `HINWEIS` on this path as a rejection and sends it back to the step that owns it
(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old
shop, which suggests they are shared with production; confirm on the first production read.
## Error codes
Returned as `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#1#`/`#2#` are substituted at
runtime.
> **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message
> carrier whose entire content is substituted. `Notification::isError()`
> (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on
> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers
> real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*.
> `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO.
**Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp ·
103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler
**Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt ·
301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu
IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt ·
306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden ·
402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden
**Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und
EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine
Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen ·
870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail,
PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#)
**Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status
fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden ·
908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise ·
911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen
fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen
fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise
fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt ·
**923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise
zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen ·
931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei
Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt ·
951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben ·
960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann
nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist
fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht
zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar ·
985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto ·
995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum
fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich
**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername ·
605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) ·
610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht
ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID ·
621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630 Aktionsgutschein fehlt ·
631 Aktionsgutschein mit Code #1# nicht gefunden · 632 Aktionsgutschein für geprüfte Reise ungültig ·
**633 Aktionsgutschein vom Buchungszeitraum ungültig** · **634 Aktionsgutschein vom Reisezeitraum
ungültig**
633 and 634 are not in the PDF; both were measured on `AKTIONSGUTSCHEIN` on 2026-10-06. 633 means
the request falls outside the promo's booking period (`GutscheinAktion.BuchungDtVon`/`BuchungDtBis`):
a test promo whose booking period was being edited answered 633 and was accepted once the period
covered the request date. 634 refers to the travel period (`ReiseDtVon`/`ReiseDtBis`). It was
returned on prod for a promo whose stored travel period did contain the travel's `TerminVon`/
`TerminBis`. What it actually compares has not been established.
## Applying this: the teamer placeholder booking
**Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking
(travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand,
pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns
`916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate
and moves on to `650`, which proves accommodation was the only structural objection.
Two blockers that were *not* the problem:
- **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982`
refusal seen earlier is per travel, not a global setting.
- **`NurFahrt` never came up** — the request never got far enough for it to matter.
What the real booking shows about how the office builds one, worth reproducing if the work moves
to a direct write:
- `<idpartner>` **is** present despite there being no accommodation — 222925 is the ordinary
hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen",
the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation
partner. Whether the booking stores it or the response derives it is still open.
- Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003,
with an empty `<anschrift/>`.
- `<wünsche><beförderungswunsch>` carries the role label — "Reiseleitung + Busbegleitung".
- Per participant, `<optionsdatum>` sets the option expiry.
- The total is driven to zero with a **manual `<zuschlag>`**: Ortstaxe 44,00 + Keycard-Pfand 10,80
+ Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `<zuschlaege>` is part of
the documented `BUCHUNG` payload, so this part *is* expressible over XML.
See `.ddev/plans/teamer-placeholder-booking-plan.md`.
BIN
View File
Binary file not shown.
+7
View File
@@ -225,6 +225,13 @@ JOIN dbo.ZustiegPreis zp ON zp.IDArtZustiegPreis_FS=lb.IDArtZustiegPreis_FS
WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId); WHERE l.IDLeistung IN (@OutboundServiceId,@ReturnServiceId);
``` ```
**V 2026-10-02 — an unpriced return leg is free.** The export can state a `preis` on the
outbound `<zustieg>` but none on the matching `<zustieg_rueck>` (seen on products 701 and
2435). The booking engine bills a return `Abfahrtsortzuschlag` only where the return entry
states a price, so the export is read verbatim, per leg. A missing return price is a catalog
omission for the office to fix, not something to compensate locally: pricing the return leg
from the outbound one makes the booking inquiry fail with a price mismatch.
**N — the departure surcharge is computed.** Booking responses carry a position **N — the departure surcharge is computed.** Booking responses carry a position
`art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag` `art="SON" unterart="TRA"` (*Abfahrtsortzuschlag*) with no matching row type: `ArtZuschlag`
— the lookup behind `BuchungTNZuschlag` — holds only discounts and fees — the lookup behind `BuchungTNZuschlag` — holds only discounts and fees
+41 -9
View File
@@ -9,7 +9,7 @@ Companion to `travel-queries.md` (static catalog) and `live-api-queries.md` (liv
socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response — socket endpoints). This one covers the `KUNDENKONTO` / `art=SelektionCRM` response —
the document that drives the portal's entire authorization model. the document that drives the portal's entire authorization model.
Request side: `ApiClient::getCrmAttributes()` (`src/BusProNet/ApiClient.php`) sends Request side: `AccountClient::getCrmAttributes()` (`src/BusProNet/Client/AccountClient.php`) sends
`satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password. `satz typ="KUNDENKONTO"` + `art=SelektionCRM` + the customer's email/password.
Response side: `CrmAttributesResponseParser` Response side: `CrmAttributesResponseParser`
(`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into (`src/BusProNet/XmlParser/CrmAttributesResponseParser.php`) turns selection ids into
@@ -18,16 +18,48 @@ Symfony roles:
| `IDSelektionsstamm` | Role | | `IDSelektionsstamm` | Role |
| --- | --- | | --- | --- |
| `1070` | `ROLE_TEAMER` | | `1070` | `ROLE_TEAMER` |
| `1292` | `ROLE_ADMIN` (+ `ROLE_HOUSE_MANAGER`, + hotel code `SSL`) | | `1292` | `ROLE_ADMIN` |
| `1293` | `ROLE_MANAGER` | | `1293` | `ROLE_MANAGER` |
| `1477` | `ROLE_GROUPS_MANAGER` | | `1477` | `ROLE_GROUPS_MANAGER` |
| `1478` | `ROLE_GROUPS_ADMIN` | | `1478` | `ROLE_GROUPS_ADMIN` |
| `1483` | `ROLE_CUSTOMER_EXPERT` |
| `1484` | `ROLE_TEAM_ADMIN` (not consumed here — exported through `/api/userinfo` for myep-team) |
| `1487` | `ROLE_SUPERVISOR` (not consumed here — exported through `/api/userinfo` for ephub) |
| label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` | | label `Hausleitung {CODE}` | `ROLE_HOUSE_MANAGER` + hotel code `{CODE}` |
| nothing matched | `ROLE_CUSTOMER` | | nothing matched | `ROLE_CUSTOMER` |
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
one of these roles. one of these roles.
These are *claims*, not grants. What an account ends up holding is decided by
`Role::sync()` (`src/Security/Role.php`) on every login — see
[From claim to role](#from-claim-to-role).
---
## From claim to role
BusPro backend users can edit their own CRM selections, so the claims above are
filtered before anything is stored:
| Role | Claimed without a staff address | Claimed with a staff address |
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT`, `ROLE_SUPERVISOR` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
administrator approves it in `/admin/user`. Nominations are announced to
`role_nomination_notification_emails`.
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
and a role approved earlier is **revoked** on the next login. A leftover marker from
before the rule cannot be approved and disappears on that login too.
- Anything the CRM stops claiming is revoked, approved or not.
- An account left without any effective role gets `ROLE_CUSTOMER`.
--- ---
## The four tables ## The four tables
@@ -198,7 +230,7 @@ Three conclusions:
`dbo.AgenturSelektionenKunde` is a bare two-column link table, `dbo.AgenturSelektionenKunde` is a bare two-column link table,
`(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The `(IDAdresse_FS, IDSelektionsstamm_FS)` — literally *"agency's customer selections"*. The
portal authenticates against BusPro as an **agency** account (`bpn_username` in portal authenticates against BusPro as an **agency** account (`bpn_username` in
`ApiClient`), so a per-agency whitelist of which customer selections that agency may see `XmlChannel`), so a per-agency whitelist of which customer selections that agency may see
and/or edit is exactly the right shape. It is the only candidate found so far that could and/or edit is exactly the right shape. It is the only candidate found so far that could
explain the `1478` omission **and** `aenderbar` through one mechanism. explain the `1478` omission **and** `aenderbar` through one mechanism.
@@ -397,11 +429,11 @@ BusProNet host is the fix, and no schema change is needed.
## Portal side: a correct XML is not enough ## Portal side: a correct XML is not enough
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User` `BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
entity at login (`setRoles($crmAttributes->roles)` / through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
after fixing BusPro the affected user must **log out and log in again** before must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set. the account has a staff address and an administrator has approved the nomination.
--- ---
@@ -430,7 +462,7 @@ and they are long shots. Prefer the escalation path below.
Escalation path, cheapest first: Escalation path, cheapest first:
1. **Re-fetch and confirm staleness.** Our side does not cache this call — the 1. **Re-fetch and confirm staleness.** Our side does not cache this call — the
`xml-sync` cache tag covers the XML *export* sync, and `ApiClient::getCrmAttributes()` `xml-sync` cache tag covers the XML *export* sync, and `AccountClient::getCrmAttributes()`
has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent has no cache layer — so a fresh login hits BusPro directly. If `1478` is still absent
from a response captured after the insert, the staleness is on the BusProNet host and from a response captured after the insert, the staleness is on the BusProNet host and
an application-pool/service restart there is the test. an application-pool/service restart there is the test.
+2 -2
View File
@@ -5,8 +5,8 @@
> per-endpoint migration verdicts and the `frei` discovery SQL. > per-endpoint migration verdicts and the `frei` discovery SQL.
This complements `travel-queries.md`. That doc replaced the **XML export** (static This complements `travel-queries.md`. That doc replaced the **XML export** (static
catalog). This one covers the **live socket API** methods on catalog). This one covers the **live socket API** methods of the clients in
`src/BusProNet/ApiClient.php` and classifies how replicable each is by direct MSSQL `src/BusProNet/Client/` and classifies how replicable each is by direct MSSQL
query in the Go proxy. query in the Go proxy.
**Verdict in one line:** the *structural* parts replicate cleanly; the *live "free" **Verdict in one line:** the *structural* parts replicate cleanly; the *live "free"
+191
View File
@@ -0,0 +1,191 @@
# Chatbot MCP server
The website chatbot runs in n8n. Its AI Agent answers from a read-only MCP server in this app instead
of scraping the websites. The server combines two sources:
- **Editorial content from TYPO3**: the content feed of all public sites (ep-reisen, ser,
ski-boarderweek, ski-wochenenden, snowzone), mirrored into `content_document` by `app:content:sync`.
- **Trip data of our own**: dates, rooms, prices, availability and pickups from the product export
(`ProductExportBuilder`, the data behind `GET /api/exports/products`).
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. `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
`name:token` pairs separated by commas, e.g. `APP_MCP_TOKENS="n8n:3f9c…,claude-desktop:a71e…"`. Give every
system its own token, so one can be revoked by removing its entry without touching the others. Use tokens
without commas, e.g. `openssl rand -hex 32`. A malformed list (no name, a duplicate name, a token shared by
two clients) fails loudly with a 500 on `/mcp` rather than locking a client out silently. If the list is
empty, every request is refused. It is not the OAuth2 client-credentials flow of `/api`: n8n's MCP Client Tool node sends a fixed
header and cannot renew a token every 10 minutes.
The client name becomes the authenticated user. Tool calls are rate-limited per client (`chatbot_mcp`
limiter, 60 per minute), so testing from Claude Desktop does not eat into n8n's budget. The limit is there to stop
runaway agent loops. Each call is logged on the `chatbot` channel (`var/log/<env>.chatbot.log`, file
only) with the client, tool, arguments, result count and duration. Nothing identifies the person chatting.
### n8n
MCP Client Tool node: endpoint `https://<host>/mcp`, transport *HTTP Streamable*, authentication *Bearer*
with n8n's own token from the stage's `APP_MCP_TOKENS`. The agent prompt should tell the bot to cite each answer's `url`, and
that prices and availability are a snapshot (`dataAsOf`) and only the booking page is binding.
## Tools
| 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 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
and inclusions.
Every result carries the public `url`. Output is compact JSON with Markdown in the text fields.
An empty result is `{"results": []}`. Invalid input produces a tool error the model can read.
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`.
- `search_trips` only offers products that TYPO3 lists in its own website search (`searchable`) and does
not mark as unbookable. Closed group trips and staff trainings are `searchable: false`. `get_trip` still
answers when someone names their code.
- Pickup surcharges are per person and per leg, so each pickup carries `surchargeOutbound`, `surchargeReturn` and
their sum `surchargeRoundTrip`. A single figure was read by the model as the whole price. A leg listed without a
price is `0`, because BusPro bills only priced legs; `surchargeReturn` is absent only where the point is not
served on the way back.
- The export's `guide` (name, phone) and all BusPro ids are never output.
`search_trips` mirrors the search page (`/skireisen/suche/`), whose data is TYPO3's import of our own
product export. Its rules follow TYPO3's `DateImportService` and search repository, derived per date and
hotel in `App\Model\Chatbot\TravelDateFacts`:
- **"ab" price:** the cheapest bookable room with places left, otherwise the cheapest bookable room.
`priceMin`/`priceMax` apply to it. The site's price bands are presets of this.
- **Travel period:** `dateFrom`/`dateTo` match by containment (start on or after, end on or before), as on
the site, not by overlap. `period` names one of the site's presets, from the `chatbot_travel_periods`
parameter in `config/services.yaml`. It's duplicated from the TYPO3 search configuration on purpose, so
update both each season. Expired periods are no longer offered.
- **`persons`:** the site's condition (a room takes groups this small: smallest `minPax` ≤ persons) **and**
enough places left. The site checks only the first.
- **Room type:** capacity, as TYPO3's `RoomTypeResolver` (`App\Enum\RoomType`): `maxPax` 1–8, 9 or more
is `9er+`. A label containing "bett im" or "mit ander" is `mit anderen` (a shared bed, sold per bed).
- **Accommodation:** the TYPO3 hotel `type` (1 Ferienwohnung, 2 Sportclub, 3 Apartment, 4 Gästehaus,
5 Pension), `App\Enum\HotelType`.
- **Board:** the items of selection group 71 ("4x Abendessen", "Frühstück inkl. … Lunchpaket", "Vollpension",
…), matched by word against any item. The site keys on the first item only. "Halbpension" occurs in no
label, so dinner is asked as "Abendessen".
- **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`).
Search (`App\Chatbot\Search\ContentSearch`) is MariaDB full text in boolean mode over
`title, breadcrumb, body_md`. Each term becomes a prefix match, because InnoDB has no German stemming,
and German stopwords are dropped in PHP. Relevance is weighted by type: products and hotels, then
destinations and concepts, then pages, then blog posts.
## Content sync
```bash
php bin/console app:content:sync # delta: what the manifest says changed
php bin/console app:content:sync --refresh # also refetch items older than 7 days, oldest first
php bin/console app:content:sync --ignore-window # deliberate bulk run outside 01:00–05:00
php bin/console app:content:check-freshness # fails if the last good manifest is > 60 min old
```
Scheduled on prod (`config/packages/zenstruck_schedule.yaml`): an hourly delta sync,
`--refresh` on Sunday at 01:05, and the freshness check hourly at :10 with `--max-age 150`, so that
`email_on_failure` reports a stalled sync but not a single missed run. Website content changes
rarely, and an hour's delay is acceptable for the chatbot; trip data and availability do not come
from this mirror. The refresh is off the quarter hour because a run that
finds another one holding the lock exits successfully without doing anything.
The feed is read with `X-Api-Key: <APP_CMS_API_KEY>` from `APP_CMS_API_BASE_URL`. The manifest lists
every site whichever host serves it. The key is only sent to hosts of the sites the manifest names.
**Load rules.** These are agreed with the TYPO3 side, so do not tune them:
- One request at a time, and at most one page fetch per second.
- A response slower than 2 s makes the next request wait 5 s. Three slow pages in a row end the page
phase for the run.
- A `503` is TYPO3's kill switch. The run stops, and `Retry-After` pauses later runs (`content_sync_state.paused_until`).
Three consecutive 5xx responses or timeouts (10 s) also stop the run. Nothing is retried in a loop.
- Outside 01:00–05:00 Europe/Berlin a run fetches at most 50 pages and leaves the rest for later.
**Robustness.**
- A failed, invalid or empty manifest changes nothing.
- A record that is requested but absent from the records response is deleted, and so is an item that
is missing from the manifest.
- A page answering 404 or 5xx is remembered as `failed`, and one that converts to no text as `empty`.
Each is stored with its `modified` and not refetched until that changes or a `--refresh` reaches it.
- A timeout is retried on the next run.
**Document model.** One `content_document` row per `(type, uid, language)`. A record's structured
payload is kept in `data`, and HTML is converted to Markdown (`HtmlToMarkdownConverter`). A page whose
`detailOf` names a record is appended to that record's `body_md` and kept out of the index
(`merged_into`), so a product is never found twice. Merges are recomputed on every run. A record without
a reachable page (`site`/`url` null) is not shown in search results, unless a detail page lends it its URL.
After a complete run that changed something, the codes the trip join cannot place are logged on `core`
(`Content feed: unmatched codes`).
## Configuration
| Variable | Meaning |
|---|---|
| `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` and `/mcp/trips`, one per client: `name:token,name:token` |
+15 -3
View File
@@ -41,7 +41,11 @@
"Scope": "api" "Scope": "api"
} }
} }
} },
"clickup_baseurl": "https://api.clickup.com/api/v2",
"clickup_api_token": "abcdef123456789",
"clickup_list_id": "12345",
"clickup_template_id": "t-12345"
}, },
"staging": { "staging": {
"Security": { "Security": {
@@ -85,7 +89,11 @@
"Scope": "api" "Scope": "api"
} }
} }
} },
"clickup_baseurl": "https://api.clickup.com/api/v2",
"clickup_api_token": "abcdef123456789",
"clickup_list_id": "12345",
"clickup_template_id": "t-12345"
}, },
"prod": { "prod": {
"Security": { "Security": {
@@ -129,6 +137,10 @@
"Scope": "api" "Scope": "api"
} }
} }
} },
"clickup_baseurl": "https://api.clickup.com/api/v2",
"clickup_api_token": "abcdef123456789",
"clickup_list_id": "12345",
"clickup_template_id": "t-12345"
} }
} }
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260903153641 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation ADD product_code VARCHAR(16) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE accommodation DROP product_code');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260908145156 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add a generic labelled discount on the accommodation booking total';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking ADD total_discount INT DEFAULT NULL, ADD total_discount_label VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, DROP total_discount_label');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The percentage column is dropped rather than converted: it shipped with Version20260908145156
* but no booking was ever given a value, so there is nothing to carry over.
*/
final class Version20260908161500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store the accommodation booking total discount as an absolute amount in minor units instead of a percentage';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount, ADD total_discount_amount INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP total_discount_amount, ADD total_discount INT DEFAULT NULL');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260911170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drops contingent_sync_state.content_hash: the rolling sync window makes a snapshot digest unusable as a change signal.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE contingent_sync_state DROP content_hash');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE contingent_sync_state ADD content_hash VARCHAR(64) NOT NULL');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Adds the per-client role requirement that App\EventListener\AuthorizationCodeListener
* gates the authorization code grant on.
*
* The column defaults to an empty list and an empty list denies, so every client is
* refused until its roles are set. The two clients in actual use are therefore set here
* rather than by hand after the deploy: it closes the window in which both staff tools
* would be live but unable to authorize anyone, and it keeps the role strings somewhere
* review can see them.
*
* Matched on name, not identifier: client identifiers are generated per environment and
* differ between local, staging and production, while the names do not.
*
* Deliberately left empty, and so denied: myepapp (the mobile app) and myep (the Keycloak
* broker), both R&D only and not in use. The client_credentials clients never reach the
* authorization endpoint and are unaffected either way.
*/
final class Version20260923120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds oauth2_client.required_roles and sets it for the two staff clients.';
}
public function up(Schema $schema): void
{
// Added nullable and backfilled before being tightened: a NOT NULL column with no
// default leaves existing rows holding an empty string, which is not valid JSON and
// fails to hydrate -- the row cannot even reach the listener that would deny it.
$this->addSql("ALTER TABLE oauth2_client ADD required_roles JSON DEFAULT NULL COMMENT '(DC2Type:json)'");
$this->addSql("UPDATE oauth2_client SET required_roles = '[]'");
$this->addSql("ALTER TABLE oauth2_client MODIFY required_roles JSON NOT NULL COMMENT '(DC2Type:json)'");
// mirrors ephub's App\Security\Role::ELIGIBLE
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_EMPLOYEE', 'ROLE_ADMIN'], JSON_THROW_ON_ERROR),
'name' => 'EPHub',
]);
// mirrors myep-team's App\Security\MyEpAuthenticator::ELIGIBLE_ROLES
$this->addSql('UPDATE oauth2_client SET required_roles = :roles WHERE name = :name', [
'roles' => json_encode(['ROLE_TEAM_ADMIN', 'ROLE_TEAMER', 'ROLE_MANAGER', 'ROLE_HOUSE_MANAGER'], JSON_THROW_ON_ERROR),
'name' => 'MyEPTeam',
]);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE oauth2_client DROP required_roles');
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Symfony\Component\Uid\Uuid;
/**
* Gives every account a uuid, which replaces the primary key as the OAuth2 `sub` claim.
*
* The UUIDs are generated here in PHP rather than with MariaDB's UUID(): that function
* returns v1, which encodes a timestamp and the server's node id, so a whole backfill
* would come out time-ordered and share a suffix. Guessing one account's identifier from
* another's is the property this column exists to remove, so the backfill has to be as
* random as the ones App\Entity\User generates from now on.
*
* One statement per row, which is why it is emitted rather than written out: the values
* cannot exist before the migration runs. Added nullable and tightened afterwards, since
* there is no single default that could satisfy a unique column.
*/
final class Version20260923150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds user.uuid and backfills it; the OAuth2 sub claim moves off the primary key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE user ADD uuid VARCHAR(36) DEFAULT NULL');
foreach ($this->connection->fetchFirstColumn('SELECT id FROM user') as $id) {
$this->addSql('UPDATE user SET uuid = :uuid WHERE id = :id', [
'uuid' => Uuid::v4()->toRfc4122(),
'id' => $id,
]);
}
$this->addSql('ALTER TABLE user MODIFY uuid VARCHAR(36) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649D17F50A6 ON user (uuid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX UNIQ_8D93D649D17F50A6 ON user');
$this->addSql('ALTER TABLE user DROP uuid');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Split-off periods of an accommodation booking. No backfill: a booking without periods is
* priced exactly as before.
*/
final class Version20260930150445 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds accommodation_booking_period for nights split off the start or end of a stay.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE accommodation_booking_period (id INT AUTO_INCREMENT NOT NULL, booking_id INT NOT NULL, position VARCHAR(10) NOT NULL, date_from DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', date_to DATE NOT NULL COMMENT \'(DC2Type:date_immutable)\', label VARCHAR(255) DEFAULT NULL, price_lines JSON NOT NULL COMMENT \'(DC2Type:json)\', INDEX IDX_ABFCF6B33301C60 (booking_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('ALTER TABLE accommodation_booking_period ADD CONSTRAINT FK_ABFCF6B33301C60 FOREIGN KEY (booking_id) REFERENCES accommodation_booking (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking_period DROP FOREIGN KEY FK_ABFCF6B33301C60');
$this->addSql('DROP TABLE accommodation_booking_period');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* The mirror of the TYPO3 content feed behind the chatbot MCP server. No backfill: the
* first app:content:sync fills it.
*/
final class Version20261008081401 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds content_document and content_sync_state for the chatbot content mirror.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE content_document (id INT AUTO_INCREMENT NOT NULL, type VARCHAR(16) NOT NULL, uid INT NOT NULL, language VARCHAR(8) NOT NULL, site VARCHAR(32) DEFAULT NULL, url VARCHAR(768) DEFAULT NULL, title VARCHAR(255) NOT NULL, breadcrumb VARCHAR(512) DEFAULT NULL, body_md MEDIUMTEXT NOT NULL, data JSON DEFAULT NULL COMMENT \'(DC2Type:json)\', modified DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetched_modified DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', fetch_state VARCHAR(16) NOT NULL, synced_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', merged_into VARCHAR(32) DEFAULT NULL, indexed TINYINT(1) NOT NULL, INDEX content_document_indexed_site (indexed, site), INDEX content_document_url (url), FULLTEXT INDEX content_document_fulltext (title, breadcrumb, body_md), UNIQUE INDEX content_document_identity (type, uid, language), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
$this->addSql('CREATE TABLE content_sync_state (id INT AUTO_INCREMENT NOT NULL, manifest_generated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_run_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', last_error LONGTEXT DEFAULT NULL, paused_until DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE content_document');
$this->addSql('DROP TABLE content_sync_state');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261029120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Keys the contingent snapshot by hotel code instead of accommodation, and adds calendar_hotel for TYPO3\'s calendar hotel list.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE calendar_hotel (id INT AUTO_INCREMENT NOT NULL, code VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_6F075DB777153098 (code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
// Backfill from the linked accommodation. accommodation_id stays, nullable and no longer
// written, until a later migration drops it; that keeps this step reversible.
$this->addSql('ALTER TABLE contingent_day ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.id = cd.accommodation_id SET cd.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_day WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_day CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('DROP INDEX uniq_contingent_day_accommodation_date ON contingent_day');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_hotel_code_date ON contingent_day (hotel_code, date)');
$this->addSql('ALTER TABLE contingent_sync_state ADD hotel_code VARCHAR(16) DEFAULT NULL, CHANGE accommodation_id accommodation_id INT DEFAULT NULL');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.id = s.accommodation_id SET s.hotel_code = a.calendar_code');
$this->addSql('DELETE FROM contingent_sync_state WHERE hotel_code IS NULL OR hotel_code = \'\'');
$this->addSql('ALTER TABLE contingent_sync_state CHANGE hotel_code hotel_code VARCHAR(16) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state (hotel_code)');
}
public function down(Schema $schema): void
{
// Rows of hotel codes without an accommodation cannot be expressed in the old schema.
// The snapshot is a cache, so the next sync of the old code rebuilds whatever is lost.
$this->addSql('UPDATE contingent_day cd INNER JOIN accommodation a ON a.calendar_code = cd.hotel_code SET cd.accommodation_id = a.id');
$this->addSql('DELETE cd FROM contingent_day cd LEFT JOIN accommodation a ON a.calendar_code = cd.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX uniq_contingent_day_hotel_code_date ON contingent_day');
$this->addSql('ALTER TABLE contingent_day DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('CREATE UNIQUE INDEX uniq_contingent_day_accommodation_date ON contingent_day (accommodation_id, date)');
$this->addSql('UPDATE contingent_sync_state s INNER JOIN accommodation a ON a.calendar_code = s.hotel_code SET s.accommodation_id = a.id');
$this->addSql('DELETE s FROM contingent_sync_state s LEFT JOIN accommodation a ON a.calendar_code = s.hotel_code WHERE a.id IS NULL');
$this->addSql('DROP INDEX UNIQ_737F167CC0D0E610 ON contingent_sync_state');
$this->addSql('ALTER TABLE contingent_sync_state DROP hotel_code, CHANGE accommodation_id accommodation_id INT NOT NULL');
$this->addSql('DROP TABLE calendar_hotel');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261104142531 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds travel_mailing_list to track the Mailjet contact lists created per travel until their deletion.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE travel_mailing_list');
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261109074654 extends AbstractMigration
{
public function getDescription(): string
{
return 'Allows a bus-only travel mailing list next to the full one: adds bus_leg and the list name, which replaces travel_code as the unique key.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE travel_mailing_list ADD bus_leg VARCHAR(20) DEFAULT NULL, ADD name VARCHAR(64) DEFAULT NULL');
// Every existing list holds all participants and is named by its travel code alone.
$this->addSql('UPDATE travel_mailing_list SET name = travel_code');
$this->addSql('ALTER TABLE travel_mailing_list MODIFY name VARCHAR(64) NOT NULL');
$this->addSql('CREATE UNIQUE INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list (name)');
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX UNIQ_68DC6E28BC4DD335, ADD INDEX IDX_68DC6E28BC4DD335 (travel_code)');
}
public function down(Schema $schema): void
{
// Fails while a travel has two lists; delete the bus-only one first.
$this->addSql('ALTER TABLE travel_mailing_list DROP INDEX IDX_68DC6E28BC4DD335, ADD UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code)');
$this->addSql('DROP INDEX UNIQ_68DC6E285E237E06 ON travel_mailing_list');
$this->addSql('ALTER TABLE travel_mailing_list DROP bus_leg, DROP name');
}
}
+1810 -3097
View File
File diff suppressed because it is too large. Load diff
+14 -12
View File
@@ -1,31 +1,29 @@
{ {
"name": "myep", "name": "myep",
"devDependencies": { "devDependencies": {
"@babel/core": "^7.17.0", "@babel/core": "^8.0.7",
"@babel/preset-env": "^7.16.0", "@babel/preset-env": "^8.0.7",
"@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus": "^3.0.0",
"@symfony/stimulus-bridge": "^3.2.0", "@symfony/stimulus-bridge": "^3.2.0",
"@symfony/webpack-encore": "^6.0.0", "@symfony/webpack-encore": "^7.2.0",
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/postcss": "^4.3.3",
"autoprefixer": "^10.4.20", "babel-plugin-polyfill-corejs3": "^1.0.0",
"core-js": "^3.38.0", "core-js": "^3.51.0",
"cypress": "^15.5.0", "cypress": "^15.5.0",
"file-loader": "^6.2.0", "htmx.org": "^2.0.11",
"htmx.org": "^1.9.12", "lightningcss": "^1.33.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"regenerator-runtime": "^0.13.9", "tailwindcss": "^4.3.3",
"tailwindcss": "^3.4.15",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"toastify-js": "^1.12.0", "toastify-js": "^1.12.0",
"webpack": "^5.74.0", "webpack": "^5.74.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^7.2.3"
}, },
"license": "WTFPL", "license": "WTFPL",
"private": true, "private": true,
"scripts": { "scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev", "dev": "encore dev",
"watch": "encore dev --watch", "watch": "encore dev --watch",
"build": "encore production --progress", "build": "encore production --progress",
@@ -37,5 +35,9 @@
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"stimulus-use": "^0.52.3", "stimulus-use": "^0.52.3",
"vanilla-calendar-pro": "^3.1.0" "vanilla-calendar-pro": "^3.1.0"
},
"allowScripts": {
"cypress": true,
"core-js": false
} }
} }
+7 -10
View File
@@ -1,20 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html --> <!-- https://docs.phpunit.de/en/12.5/configuration.html -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false" backupGlobals="false"
colors="true" colors="true"
bootstrap="tests/bootstrap.php" bootstrap="tests/bootstrap.php"
convertDeprecationsToExceptions="false" cacheDirectory=".phpunit.cache"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnPhpunitDeprecations="true"
> >
<php> <php>
<ini name="display_errors" value="1" /> <ini name="display_errors" value="1" />
<ini name="error_reporting" value="-1" /> <ini name="error_reporting" value="-1" />
<server name="APP_ENV" value="test" force="true" /> <server name="APP_ENV" value="test" force="true" />
<server name="SHELL_VERBOSITY" value="-1" /> <server name="SHELL_VERBOSITY" value="-1" />
<server name="SYMFONY_PHPUNIT_REMOVE" value="" />
<server name="SYMFONY_PHPUNIT_VERSION" value="9.5" />
</php> </php>
<testsuites> <testsuites>
@@ -23,16 +23,13 @@
</testsuite> </testsuite>
</testsuites> </testsuites>
<coverage processUncoveredFiles="true"> <source>
<include> <include>
<directory suffix=".php">src</directory> <directory suffix=".php">src</directory>
</include> </include>
</coverage> </source>
<listeners>
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
</listeners>
<extensions> <extensions>
<bootstrap class="Symfony\Bridge\PhpUnit\SymfonyExtension" />
</extensions> </extensions>
</phpunit> </phpunit>
+1 -3
View File
@@ -1,7 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
'postcss-import': {}, '@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
} }
} }
@@ -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,20 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect\Model;
use App\Enum\BusLeg;
readonly class TravelMailingResponse
{
/**
* @param TravelMailingRecipient[] $data
*/
public function __construct(
public string $travelCode,
public array $data,
public ?BusLeg $busLeg = null,
) {
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\BpnConnect;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\TravelMailingRecipient;
use App\BpnConnect\Model\TravelMailingResponse;
use App\Enum\BusLeg;
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.
*
* @param ?BusLeg $busLeg only participants who booked this bus leg, plus their bookings' applicants
*/
public function getMailingRecipients(string $travelCode, ?BusLeg $busLeg = null): TravelMailingResponse
{
$query = ['travelCode' => $travelCode];
if (null !== $busLeg) {
$query['busLeg'] = $busLeg->value;
}
$payload = $this->request(self::MAILING_PATH, $query);
// A bpn-connect that predates the filter ignores the parameter and returns everyone, which
// must not end up on a list meant for bus passengers only.
$appliedBusLeg = $payload['meta']['bus_leg'] ?? null;
if ($appliedBusLeg !== $busLeg?->value) {
$this->logger->error('BpnConnect ignored the travel mailing bus filter', [
'travel_code' => $travelCode,
'requested_bus_leg' => $busLeg?->value,
'applied_bus_leg' => $appliedBusLeg,
]);
throw new BpnConnectException('BpnConnect did not apply the requested bus leg filter.');
}
$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, $busLeg);
}
}
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\PersonalDataUpdateResponse;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\RegistrationDto;
/**
* A customer's BusPro account: login, address data, newsletter, CRM selections.
*/
class AccountClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getPersonalData(string $email, string $password): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function register(RegistrationDto $registrationData): Notification|RegistrationResponse
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $registrationData->toPayload(),
]);
}
/**
* @throws ApiClientException
*/
public function resetPassword(string $email): Notification
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Passwort_Anfrage',
'email' => $email,
]);
}
/**
* @throws ApiClientException
*/
public function updatePersonalData(
string $email,
string $password,
PersonalData $personalData,
bool $debug = false,
): Notification|PersonalDataUpdateResponse {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adressdaten_Ändern',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'adressdaten' => $personalData->toPayload(),
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function createAddress(
PersonalData $personalData,
bool $debug = false,
): RegistrationResponse|Notification {
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Adresse_Neu',
'adressdaten' => $personalData->toPayload(false),
'ohnemailversand' => 'True',
], [], $debug);
}
/**
* @throws ApiClientException
*/
public function updateNewsletterRegistration(string $email, string $password, PersonalData $personalData): Notification|PersonalData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Newsletter',
'email' => $email,
'passwort' => $password,
'idadresse' => $personalData->addressId,
'newsletter' => [
'email' => $email,
'anmeldung' => $personalData->communication->newsletter ? 'True' : 'False',
],
]);
}
/**
* @throws ApiClientException
*/
public function getCrmAttributes(string $email, string $password): Notification|CrmAttributes
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'SelektionCRM',
'email' => $email,
'passwort' => $password,
]);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\BookingResponse;
use App\BusProNet\Model\BookingUpdate;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\BookingDto;
/**
* A customer's bookings: listing, details and documents, creating and changing them.
*/
class BookingClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly BookingDataProcessor $bookingDataProcessor,
) {
}
/**
* @throws ApiClientException
*/
public function getBookings(string $email, string $password): Notification|BaseData
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgänge',
'email' => $email,
'passwort' => $password,
]);
}
/**
* @throws ApiClientException
*/
public function getBooking(string $email, string $password, int $id): Notification|Booking
{
return $this->channel->request(MessageType::CustomerData, [
'art' => 'Vorgang_Details',
'email' => $email,
'passwort' => $password,
'idbuchung' => $id,
]);
}
/**
* @throws ApiClientException
*/
public function getDocuments(string $email, string $password, int $bookingId, string $type): mixed
{
return $this->channel->request(MessageType::CustomerData, [
'art' => $type,
'email' => $email,
'passwort' => $password,
'idbuchung' => $bookingId,
]);
}
/**
* @throws ApiClientException
*/
public function updateBooking(BookingDto $formData, bool $debug = false): Notification|BookingUpdate
{
return $this->channel->request(MessageType::BookingUpdate, [
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
...$this->bookingDataProcessor->createUpdateRequestPayload($formData),
], [], $debug);
}
/**
* First phase of the two-phase booking process: validates the booking and returns the
* prices without creating it.
*
* @throws ApiClientException
*/
public function createBookingInquiry(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_INQUIRY, $debug);
}
/**
* Second phase of the two-phase booking process: creates the booking after a successful
* inquiry.
*
* @throws ApiClientException
*/
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
return $this->sendBooking($bookingDto, Constants::BOOKING_TYPE_BOOKING, $debug);
}
/**
* @throws ApiClientException
*/
private function sendBooking(BookingDto $bookingDto, string $bookingType, bool $debug): Notification|BookingResponse
{
return $this->channel->request(MessageType::Booking, $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, $bookingType), [], $debug);
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Agency;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\Notification;
use App\BusProNet\Transport\XmlChannel;
/**
* Lists that change rarely and that callers cache for long periods.
*/
class ReferenceDataClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @return Agency[]|Notification
*
* @throws ApiClientException
*/
public function getAgencies(): array|Notification
{
return $this->channel->request(MessageType::Agencies);
}
/**
* @throws ApiClientException
*/
public function getBaseData(MessageType $type): Notification|BaseData
{
return $this->channel->request($type);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\BaseData;
use App\BusProNet\Model\ExtendedServiceAvailabilityResponse;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\Transport\XmlChannel;
/**
* Travels from the live API. Most reads belong in TravelDataProvider, which arbitrates
* between this, the XML export and the snapshots.
*/
class TravelClient
{
public function __construct(
private readonly XmlChannel $channel,
) {
}
/**
* @throws ApiClientException
*/
public function getTravelData(int $travelId, ?int $hotelId = null): Notification|Travel
{
return $this->channel->request(MessageType::ProductData, ['idprodukt' => $travelId], ['hotelId' => $hotelId]);
}
/**
* Returns the unparsed response, which has the structure of BusPro's XML export and is
* stored in the export directory as is.
*
* @throws ApiClientException
*/
public function getTravelDataXml(int $travelId): string
{
return $this->channel->requestRaw(MessageType::ProductData, ['idprodukt' => $travelId]);
}
/**
* @throws ApiClientException
*/
public function getProducts(): Notification|BaseData
{
return $this->channel->request(MessageType::Products);
}
/**
* @throws ApiClientException
*/
public function getMutableData(int $dateId): Notification|BaseData
{
return $this->channel->request(MessageType::MutableData, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilities(int $dateId): Notification|ServiceAvailabilityResponse
{
return $this->channel->request(MessageType::Availability, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getAvailabilitiesExtended(int $dateId): Notification|ExtendedServiceAvailabilityResponse
{
return $this->channel->request(MessageType::AvailabilityExtended, ['idreise' => $dateId]);
}
/**
* @throws ApiClientException
*/
public function getHotelAvailability(int $dateId, int $hotelId, \DateTimeInterface $dateTo): Notification|BaseData
{
return $this->channel->request(MessageType::AvailabilityHotel, [
'idreise' => $dateId,
'idpartner' => $hotelId,
'terminbis' => $dateTo->format('d.m.Y'),
]);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace App\BusProNet\Client;
use App\BusProNet\Constants;
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherPreview;
use App\BusProNet\Model\VoucherSaleResponse;
use App\BusProNet\Model\VoucherTemplates;
use App\BusProNet\Transport\XmlChannel;
use App\Form\Model\VoucherPurchaseDto;
/**
* Redeeming vouchers in a booking, and selling purchase vouchers (Kaufgutscheine).
*/
class VoucherClient
{
public function __construct(
private readonly XmlChannel $channel,
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
) {
}
/**
* Looks up a purchase voucher (Gutschein) by its redemption code. Purchase vouchers, regular
* or goodwill (Kulanz), carry a balance that each redemption reduces.
*
* @throws ApiClientException
*/
public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher
{
return $this->channel->request(MessageType::PurchaseVoucher, ['einloesecode' => $redemptionCode]);
}
/**
* Validates a promo voucher (Aktionsgutschein) for a travel and participant price. Promo
* vouchers are absolute discounts, per person ("P") or per booking ("B") as pro_buchung_person
* says.
*
* @throws ApiClientException
*/
public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher
{
return $this->channel->request(MessageType::PromoVoucher, [
'aktionsgutschein' => $promoCode,
'idreise' => $travelId,
'preis' => number_format($participantPrice, 2, ',', ''),
]);
}
/**
* Fetches the voucher designs on sale and the amount bounds.
*
* @throws ApiClientException
*/
public function getVoucherTemplates(): Notification|VoucherTemplates
{
return $this->channel->request(MessageType::VoucherTemplates);
}
/**
* Fetches the payment methods offered for voucher purchases.
*
* @return list<VoucherPaymentMethod>|Notification
*
* @throws ApiClientException
*/
public function getVoucherPaymentMethods(): array|Notification
{
return $this->channel->request(MessageType::VoucherPaymentMethods);
}
/**
* Renders the PDF preview of a voucher design with the entered data.
*
* @throws ApiClientException
*/
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
{
return $this->channel->request(MessageType::VoucherPreview, $this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto));
}
/**
* Dry run of a voucher purchase: full validation, nothing is issued.
*
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
* result must not be judged by Notification::isError().
*
* @throws ApiClientException
*/
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
}
/**
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
*
* Every HINWEIS is a rejection on this path, including 650.
*
* @throws ApiClientException
*/
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
}
/**
* @throws ApiClientException
*/
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
{
return $this->channel->request(MessageType::VoucherSale, $this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType));
}
}
@@ -49,8 +49,9 @@ class BookingDataProcessor
* - ParticipantDto::fromPersonalData() clones address objects to prevent shared references * - ParticipantDto::fromPersonalData() clones address objects to prevent shared references
* - For normal bookings, participant[0]'s missing address/dimensions are filled from the * - For normal bookings, participant[0]'s missing address/dimensions are filled from the
* applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>) * applicant (same person in normal bookings, but BPN may omit fields in <teilnehmer>)
* - For internal agency bookings, this copy is skipped: the applicant is a customer, not * - The copy is skipped wherever participant[0] is NOT the applicant: internal agency
* the first participant, so copying applicant data into participant[0] would be incorrect * bookings (the applicant is a customer) and split bookings (the applicant does not
* travel), where it would write one person's address onto another
*/ */
public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto public function createBookingDtoFromBooking(Booking $booking, Travel $travel, bool $isInternalAgencyBooking = false): BookingDto
{ {
@@ -74,32 +75,6 @@ class BookingDataProcessor
$participantData = ParticipantDto::fromPersonalData($participant); $participantData = ParticipantDto::fromPersonalData($participant);
$participantData->index = $index; $participantData->index = $index;
// For normal bookings, applicant === participant[0]: copy address and body dimensions
// from applicant when BPN returns full data only in <anmelder> and minimal data in
// <teilnehmer id="1">. Skipped for internal agency bookings where the applicant is
// a customer (a different entity from the first traveler).
if (0 === $index && false === $isInternalAgencyBooking) {
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
// Extract service selections from booking and assign to participant DTO // Extract service selections from booking and assign to participant DTO
$participantData->courses = $booking $participantData->courses = $booking
->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES);
@@ -178,14 +153,57 @@ class BookingDataProcessor
$roomSelection->price = $room->price; $roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl) $roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$roomSelection->capacity = $room->minPax ?? 1; $roomSelection->capacity = $room->minPax ?? 1;
$roomSelection->maxQuantity = $bookingRoom->totalCount + ($room->available ?? 0);
$dto->roomSelections[] = $roomSelection; $dto->roomSelections[] = $roomSelection;
} }
} }
// Runs once the participants are in place, because the decision needs the assembled DTO.
// The explicit flag covers internal agency bookings, whose applicant is a customer; the
// DTO predicate covers a split booking, whose participant[0] is a different person.
if (false === $isInternalAgencyBooking && $dto->isFirstParticipantApplicant()) {
$this->fillFirstParticipantFromApplicant($dto, $booking);
}
return $dto; return $dto;
} }
/**
* Fills gaps in participant[0] from the applicant, who is the same person.
*
* BPN routinely returns the full record in <anmelder> and only a minimal one in
* <teilnehmer id="1">. Only ever called where participant[0] IS the applicant: on a
* split booking or an internal agency booking this would write somebody else's address
* onto a traveller at the next edit submit.
*/
private function fillFirstParticipantFromApplicant(BookingDto $dto, Booking $booking): void
{
$participantData = $dto->getParticipant(0);
if (null === $participantData) {
return;
}
// Copy address if first participant has no street (indicating empty/incomplete address)
$isEmpty = null === $participantData->address
|| null === $participantData->address->street
|| u($participantData->address->street)->trim()->isEmpty();
if ($isEmpty) {
$participantData->address = clone $booking->applicant->address;
}
// Copy body dimensions from applicant if not present in participant
if (null === $participantData->height && null !== $booking->applicant->height) {
$participantData->height = $booking->applicant->height;
}
if (null === $participantData->weight && null !== $booking->applicant->weight) {
$participantData->weight = $booking->applicant->weight;
}
if (null === $participantData->shoeSize && null !== $booking->applicant->shoeSize) {
$participantData->shoeSize = $booking->applicant->shoeSize;
}
}
/** /**
* Enriches participant service selections with data from travel model. * Enriches participant service selections with data from travel model.
* *
@@ -322,6 +340,7 @@ class BookingDataProcessor
$this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData); $this->serviceProcessor->processParticipantServices($participant, $bookingData, $travelData);
} }
$this->serviceProcessor->removeCanceledParticipantsFromRooms($bookingData);
$this->serviceProcessor->removeUnusedServices($bookingData); $this->serviceProcessor->removeUnusedServices($bookingData);
$this->personalDataSynchronizer->updateParticipantPersonalData( $this->personalDataSynchronizer->updateParticipantPersonalData(
$formData->participants, $formData->participants,
@@ -96,7 +96,7 @@ class BookingPayloadBuilder
foreach ($bookingData->participants as $index => $participant) { foreach ($bookingData->participants as $index => $participant) {
$participantPayload = [ $participantPayload = [
'@id' => $index + 1, '@id' => $index + 1,
'status' => $bookingData->participantsStatus[$index], 'status' => $bookingData->participantsStatus[$index] ?? null,
...$participant->toPayload(), ...$participant->toPayload(),
]; ];
@@ -254,7 +254,11 @@ class BookingPayloadBuilder
*/ */
public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array public function buildCreatePayload(BookingDto $bookingDto, string $bookingType): array
{ {
$firstParticipant = $bookingDto->participants[0]; // The applicant is participant[0] on an ordinary booking, and a person of their own
// when they do not travel along.
$applicantSource = $bookingDto->applicantTravels
? $bookingDto->participants[0]
: ($bookingDto->applicant ?? $bookingDto->participants[0]);
$payload = [ $payload = [
'buchungsart' => $bookingType, 'buchungsart' => $bookingType,
@@ -264,36 +268,10 @@ class BookingPayloadBuilder
'idagentur' => $bookingDto->agencyId, 'idagentur' => $bookingDto->agencyId,
]; ];
// Add applicant (first participant data)
$payload['anmelder'] = [
'name' => $firstParticipant->lastName,
'vorname' => $firstParticipant->firstName,
'geschlecht' => $firstParticipant->gender ?? '',
'nationalitaet' => $firstParticipant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$payload['anmelder'] = $this->buildPersonalDataPayload($applicantSource);
if (null !== $firstParticipant->dateOfBirth) {
$payload['anmelder']['geburtsdatum'] = $firstParticipant->dateOfBirth->format('d.m.Y');
}
// Add address for applicant
if (null !== $firstParticipant->address) {
$payload['anmelder']['anschrift'] = $firstParticipant->address->toPayload();
}
if (null !== $firstParticipant->email || null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation'] = [];
if (null !== $firstParticipant->email) {
$payload['anmelder']['kommunikation']['email'] = $firstParticipant->email;
}
if (null !== $firstParticipant->mobile) {
$payload['anmelder']['kommunikation']['telefonmobil'] = $firstParticipant->mobile;
}
}
// Determine if this is an inquiry booking for voucher handling // Determine if this is an inquiry booking for voucher handling
$isInquiryBooking = 'A' === $bookingDto->bookingStatus; $isInquiryBooking = 'A' === $bookingDto->bookingStatus;
@@ -301,42 +279,13 @@ class BookingPayloadBuilder
// Add participants // Add participants
$payload['teilnehmerliste']['teilnehmer'] = []; $payload['teilnehmerliste']['teilnehmer'] = [];
foreach ($bookingDto->participants as $index => $participant) { foreach ($bookingDto->participants as $index => $participant) {
$participantData = [
'@id' => $index + 1,
'status' => self::DEFAULT_PARTICIPANT_STATUS,
'name' => $participant->lastName,
'vorname' => $participant->firstName,
'geschlecht' => $participant->gender ?? '',
'nationalitaet' => $participant->nationality ?? '',
];
// DO NOT include personId or addressId in create mode // DO NOT include personId or addressId in create mode
// BPN will automatically match existing customers by exact personal data (name, DOB, address) // BPN will automatically match existing customers by exact personal data (name, DOB, address)
// Including IDs would prevent automatic matching and could cause data inconsistencies // Including IDs would prevent automatic matching and could cause data inconsistencies
$participantData = [
if (null !== $participant->dateOfBirth) { '@id' => $index + 1,
$participantData['geburtsdatum'] = $participant->dateOfBirth->format('d.m.Y'); 'status' => self::DEFAULT_PARTICIPANT_STATUS,
} ] + $this->buildPersonalDataPayload($participant, true);
// Add address (always include structure, even if empty)
$participantData['anschrift'] = $participant->address?->toPayload() ?? [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
// Add contact info for all participants
if (null !== $participant->email || null !== $participant->mobile) {
$participantData['kommunikation'] = [];
if (null !== $participant->email) {
$participantData['kommunikation']['email'] = $participant->email;
}
if (null !== $participant->mobile) {
$participantData['kommunikation']['telefonmobil'] = $participant->mobile;
}
}
// Add body dimensions // Add body dimensions
if (null !== $participant->height) { if (null !== $participant->height) {
@@ -434,6 +383,57 @@ class BookingPayloadBuilder
return $payload; return $payload;
} }
/**
* Maps one person's personal data onto the payload shape shared by anmelder and teilnehmer.
*
* @param ParticipantDto $person The applicant or participant to map
* @param bool $alwaysIncludeAddress Participants always carry the anschrift structure
* even when empty; the anmelder only when set
*
* @return array<string, mixed>
*/
private function buildPersonalDataPayload(ParticipantDto $person, bool $alwaysIncludeAddress = false): array
{
$data = [
'name' => $person->lastName,
'vorname' => $person->firstName,
'geschlecht' => $person->gender ?? '',
'nationalitaet' => $person->nationality ?? '',
];
if (null !== $person->dateOfBirth) {
$data['geburtsdatum'] = $person->dateOfBirth->format('d.m.Y');
}
if (null !== $person->address) {
$data['anschrift'] = $person->address->toPayload();
} elseif ($alwaysIncludeAddress) {
$data['anschrift'] = [
'strasse' => null,
'plz' => null,
'ort' => null,
'ortsteil' => null,
'land' => null,
];
}
// A participant may come without an email (under-16s, see ParticipantDto::$email), and
// BusPro accepts that, so a blank is sent as a blank rather than borrowing the applicant's.
$email = null !== $person->email && '' !== trim($person->email) ? $person->email : null;
if (null !== $email || null !== $person->mobile) {
$data['kommunikation'] = [];
if (null !== $email) {
$data['kommunikation']['email'] = $email;
}
if (null !== $person->mobile) {
$data['kommunikation']['telefonmobil'] = $person->mobile;
}
}
return $data;
}
/** /**
* Adds services from a mapping to the payload. * Adds services from a mapping to the payload.
* *
@@ -14,8 +14,9 @@ use Psr\Log\LoggerInterface;
* Processes participant service selections for booking updates. * Processes participant service selections for booking updates.
* *
* Handles the assignment and mapping of services (additional, transportation, * Handles the assignment and mapping of services (additional, transportation,
* pickup, room, insurance) from participant DTOs to the booking model during * pickup, insurance) from participant DTOs to the booking model during the
* the update request workflow. * update request workflow. Rooms are not taken from the DTOs: they cannot be
* changed in edit mode, so the booking's own room assignments are sent back.
*/ */
class ParticipantServiceProcessor class ParticipantServiceProcessor
{ {
@@ -28,7 +29,7 @@ class ParticipantServiceProcessor
* Resets all existing participant-to-service mappings to start with a clean slate. * Resets all existing participant-to-service mappings to start with a clean slate.
* *
* This ensures that service assignments are rebuilt from scratch based on current form selections. * This ensures that service assignments are rebuilt from scratch based on current form selections.
* Includes resetting room mappings to allow room reassignments during edit. * Room mappings are left alone, see removeCanceledParticipantsFromRooms().
* *
* @param Booking $bookingData The booking data object containing services to reset * @param Booking $bookingData The booking data object containing services to reset
*/ */
@@ -39,7 +40,6 @@ class ParticipantServiceProcessor
...$bookingData->transportationServices, ...$bookingData->transportationServices,
...$bookingData->pickups, ...$bookingData->pickups,
...$bookingData->dropOffs, ...$bookingData->dropOffs,
...$bookingData->rooms,
...$bookingData->insurances, ...$bookingData->insurances,
]; ];
@@ -52,7 +52,7 @@ class ParticipantServiceProcessor
* Processes all services for a single participant. * Processes all services for a single participant.
* *
* This orchestrator method handles the complete service assignment workflow for one participant, * This orchestrator method handles the complete service assignment workflow for one participant,
* including additional services, transportation services, pickup locations, and room assignments. * including additional services, transportation services, pickup locations, and insurance.
* *
* @param ParticipantDto $participant The participant data from the form * @param ParticipantDto $participant The participant data from the form
* @param Booking $bookingData The booking data object to update * @param Booking $bookingData The booking data object to update
@@ -68,7 +68,6 @@ class ParticipantServiceProcessor
$this->processTransportationServices($participant, $bookingData, $travelData); $this->processTransportationServices($participant, $bookingData, $travelData);
$this->processPickupLocations($participant, $bookingData); $this->processPickupLocations($participant, $bookingData);
$this->processDropOffLocations($participant, $bookingData); $this->processDropOffLocations($participant, $bookingData);
$this->processRoomAssignment($participant, $bookingData);
$this->processInsurance($participant, $bookingData, $travelData); $this->processInsurance($participant, $bookingData, $travelData);
} }
@@ -271,27 +270,23 @@ class ParticipantServiceProcessor
} }
/** /**
* Processes room assignment for a participant. * Removes canceled participants from the booking's room assignments.
* *
* Maps the participant to their assigned room. Allows room reassignment during * Room assignments cannot be changed in edit mode, so they are sent back as BusPro reports
* booking edits while maintaining the constraint that participants can only be * them on the booking reloaded at submit, together with its anzahl. Rebuilding them from the
* assigned to room types that have already been booked. * session would undo any reassignment made in BusPro while the edit was open, and send a
* zuordnung that no longer matches anzahl. Canceled participants are left out, as for every
* other service.
* *
* @param ParticipantDto $participant The participant data from the form * @param Booking $bookingData The booking data object to update
* @param Booking $bookingData The booking data object to update
*/ */
private function processRoomAssignment(ParticipantDto $participant, Booking $bookingData): void public function removeCanceledParticipantsFromRooms(Booking $bookingData): void
{ {
if (null === $participant->assignedRoomId) {
return;
}
// Find the room in the existing booking by ID
foreach ($bookingData->rooms as $room) { foreach ($bookingData->rooms as $room) {
if ($room->id === $participant->assignedRoomId) { $room->mapping = array_values(array_filter(
$room->mapping[] = $participant->index; $room->mapping,
break; fn (int $index) => 'S' !== ($bookingData->participantsStatus[$index] ?? null)
} ));
} }
} }
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\Form\Model\VoucherPurchaseDto;
/**
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
*
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
*/
class VoucherSalePayloadBuilder
{
/**
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
*
* @return array<string, mixed>
*/
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
{
if (null === $dto->template || null === $dto->paymentMethod) {
throw new \LogicException('A voucher sale needs a template and a payment method.');
}
$communication = [
'telefonprivat' => $dto->phone,
'email' => $dto->email,
];
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
$communication['telefonmobil'] = $dto->mobile;
}
return [
'buchungsart' => $saleType,
'gutscheinart' => $dto->template->type,
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
'rechnungsempfänger' => [
'name' => $dto->lastName,
'vorname' => $dto->firstName,
'anrede' => $dto->salutation,
'titel' => $dto->title,
'geschlecht' => '',
'geburtsdatum' => '',
'anschrift' => $dto->address->toPayload(),
'kommunikation' => $communication,
],
'zahlung' => [
'@idzahlungsart' => $dto->paymentMethod->id,
'@bezeichnung' => $dto->paymentMethod->label,
'@art' => $dto->paymentMethod->type,
],
];
}
/**
* @return array<string, mixed>
*/
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
{
if (null === $dto->template) {
throw new \LogicException('A voucher preview needs a template.');
}
return [
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
];
}
}
@@ -2,7 +2,8 @@
namespace App\BusProNet\DataProvider; namespace App\BusProNet\DataProvider;
use App\BusProNet\ApiClient; use App\BusProNet\Client\ReferenceDataClient;
use App\BusProNet\MessageType;
use App\BusProNet\Model\Country; use App\BusProNet\Model\Country;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -10,7 +11,7 @@ use Symfony\Contracts\Cache\ItemInterface;
class CountryDataProvider class CountryDataProvider
{ {
public function __construct(private readonly ApiClient $apiClient, private readonly CacheInterface $cache) public function __construct(private readonly ReferenceDataClient $referenceDataClient, private readonly CacheInterface $cache)
{ {
} }
@@ -22,8 +23,8 @@ class CountryDataProvider
$item->expiresAfter(6 * 60 * 60); $item->expiresAfter(6 * 60 * 60);
return $this return $this
->apiClient ->referenceDataClient
->getBaseData(ApiClient::TYPE_BASE_DATA_COUNTRIES) ->getBaseData(MessageType::Countries)
->getItems() ->getItems()
; ;
}); });
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\BusProNet;
/**
* The `typ` of BusPro's `<satz>`: the command on a request, the kind of answer on a response.
*/
enum MessageType: string
{
// Only ever a response: BusPro's refusal or remark, whatever was asked.
case Notification = 'HINWEIS';
case CustomerData = 'KUNDENKONTO';
case Countries = 'STAMMLAENDER';
case MutableData = 'MOEGLICHEAENDERUNGEN';
case Availability = 'VERFUEGBARKEIT';
case AvailabilityExtended = 'VERFUEGBARKEIT2';
case AvailabilityHotel = 'VERFUEGBARKEITHOTEL';
case BookingUpdate = 'BUCHUNGAENDERUNG';
case Booking = 'BUCHUNG';
case Products = 'PRODUKTE';
case ProductData = 'PRODUKTDATEN';
case Agencies = 'AGENTUREN';
case PurchaseVoucher = 'GUTSCHEINPRUEFUNGEINLOESUNG';
case PromoVoucher = 'AKTIONSGUTSCHEIN';
// Selling Kaufgutscheine. Not to be confused with PurchaseVoucher, which redeems one in a booking.
case VoucherTemplates = 'GUTSCHEINVORLAGEN';
case VoucherPaymentMethods = 'GUTSCHEINZAHLUNGSARTEN';
case VoucherPreview = 'GUTSCHEINVORSCHAU';
case VoucherSale = 'GUTSCHEINBUCHUNG';
}
+1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use function Symfony\Component\String\u; use function Symfony\Component\String\u;
/** /**
+25
View File
@@ -143,6 +143,31 @@ class Booking
}); });
} }
/**
* Checks whether a participant already holds a service in this booking.
*
* This is the BusPro-side baseline, not the working edit selection: it answers
* "did the participant have this service when the booking was last read back".
* Services already held may be kept regardless of their current status or
* contingent - withdrawing one produces "Anzahl Leistung stimmt nicht mit
* Teilnehmerzuordnung überein" on the next update.
*
* @param int $participantIndex The participant index to search for
* @param int $serviceId The service ID to look for
*
* @return bool True if the participant already holds the service
*/
public function hasServiceForParticipant(int $participantIndex, int $serviceId): bool
{
foreach ([...$this->additionalServices, ...$this->transportationServices] as $service) {
if ($service->id === $serviceId && in_array($participantIndex, $service->mapping)) {
return true;
}
}
return false;
}
/** /**
* Retrieves transportation service for a specific participant and direction. * Retrieves transportation service for a specific participant and direction.
* *
+33 -2
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** /**
@@ -15,17 +16,47 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
class Communication class Communication
{ {
#[Assert\When(
expression: 'this.isPhoneNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $phone = null; public ?string $phone = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])]
#[Assert\When(
expression: 'this.isMobileNewlyEntered()',
constraints: [new AppAssert\PhoneNumber()],
groups: ['personal_data'],
)]
public ?string $mobile = null; public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_data'])] // The numbers as loaded from BusPro, which go back unchanged and are exempt from PhoneNumber.
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_data'])] public ?string $loadedPhone = null;
public ?string $loadedMobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['personal_identity'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['personal_identity'])]
public ?string $email = null; public ?string $email = null;
public bool $newsletter = false; public bool $newsletter = false;
public function rememberLoadedNumbers(): void
{
$this->loadedPhone = $this->phone;
$this->loadedMobile = $this->mobile;
}
public function isPhoneNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->phone, $this->loadedPhone);
}
public function isMobileNewlyEntered(): bool
{
return AppAssert\PhoneNumber::isNewlyEntered($this->mobile, $this->loadedMobile);
}
/** /**
* Converts the communication data to API payload format. * Converts the communication data to API payload format.
* *
+11
View File
@@ -35,4 +35,15 @@ class Hotel
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $country = null; public ?string $country = null;
/**
* Raw `kostenstelle`, deliberately a string: coverage is thin and some hotels carry a
* space-separated list such as "84 85", so it is an opaque label, not a number.
*/
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $costUnit = null;
/** {@see \App\BusProNet\Utility\CountryCodeMapper} — lowercase ISO-2, null when unmappable. */
#[Groups(['api:list', 'api:single', 'snapshot'])]
public ?string $countryCode = null;
} }
+20 -4
View File
@@ -23,10 +23,12 @@ class PersonalData
public bool $mutable = false; public bool $mutable = false;
public ?string $status = null; public ?string $status = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] // personal_identity holds what the user cannot edit in MyE&P (name and the login e-mail), so the
// personal-data form can validate everything else while the completeness check still sees it.
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
public ?string $name = null; public ?string $name = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_data'])] #[Assert\NotBlank(message: 'Bitte angeben', groups: ['Default', 'personal_identity'])]
public ?string $firstName = ''; public ?string $firstName = '';
public ?string $salutation = null; public ?string $salutation = null;
@@ -53,7 +55,7 @@ class PersonalData
#[Assert\Valid(groups: ['personal_data'])] #[Assert\Valid(groups: ['personal_data'])]
public Address $address; public Address $address;
#[Assert\Valid(groups: ['personal_data'])] #[Assert\Valid(groups: ['personal_data', 'personal_identity'])]
public Communication $communication; public Communication $communication;
/** @var list<string> */ /** @var list<string> */
@@ -61,6 +63,13 @@ class PersonalData
/** @var list<string> */ /** @var list<string> */
public array $hotelCodes = []; public array $hotelCodes = [];
// The authenticated account, not BusPro's idea of this person. One BusPro person may sign in
// under any of the addresses on its record, and each of those is a separate local account with
// its own roles, so neither the BusPro ids nor the communication email identifies one. Patched
// on by the caller in the same way as the roles and hotel codes above.
public ?string $subject = null;
public ?string $loginEmail = null;
public function __construct() public function __construct()
{ {
$this->address = new Address(); $this->address = new Address();
@@ -132,14 +141,21 @@ class PersonalData
* Creates a structured array containing user profile information * Creates a structured array containing user profile information
* suitable for JWT claims or user session data. * suitable for JWT claims or user session data.
* *
* `sub` and `email` describe the account that authenticated and are read from the patched-on
* fields, never from BusPro: `person_id`/`address_id` are shared by every account of the same
* person, and `profile.communication.email` is the first contact address on the BusPro record,
* which is not necessarily the one signed in with. Deliberately without a fallback to that
* address — a caller that forgets to patch them gets null rather than a wrong identity.
*
* @return array<string, mixed> The claims array with user profile data * @return array<string, mixed> The claims array with user profile data
*/ */
public function getClaims(): array public function getClaims(): array
{ {
return [ return [
'sub' => $this->subject,
'person_id' => $this->personId, 'person_id' => $this->personId,
'address_id' => $this->addressId, 'address_id' => $this->addressId,
'email' => $this->communication->email, 'email' => $this->loginEmail,
'roles' => $this->roles, 'roles' => $this->roles,
'profile' => [ 'profile' => [
'first_name' => $this->firstName, 'first_name' => $this->firstName,
+15
View File
@@ -20,6 +20,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
class Travel class Travel
{ {
use SortByPriceTrait; use SortByPriceTrait;
/**
* CRM selection that offers the "I am not travelling myself" choice on a travel date.
*/
private const NON_TRAVELLING_APPLICANT_SELECTION_ID = 1488;
#[Groups(['api:list', 'api:single', 'snapshot'])] #[Groups(['api:list', 'api:single', 'snapshot'])]
public ?int $id = null; public ?int $id = null;
@@ -240,6 +246,15 @@ class Travel
return false; return false;
} }
/**
* Whether a booking for this travel date may be placed by an applicant who does not travel
* along, such as a parent booking for their children.
*/
public function allowsNonTravellingApplicant(): bool
{
return $this->hasSelectionId(self::NON_TRAVELLING_APPLICANT_SELECTION_ID);
}
/** /**
* Retrieves rooms filtered by their IDs. * Retrieves rooms filtered by their IDs.
* *
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
*
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
*/
final readonly class VoucherPaymentMethod
{
public const string TYPE_TRANSFER = 'UEBERW';
public function __construct(
public int $id,
public string $label,
public string $type,
) {
}
public function isTransfer(): bool
{
return self::TYPE_TRANSFER === $this->type;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
*/
final readonly class VoucherPreview
{
public function __construct(
public string $pdf,
) {
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
*
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
*
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
* voucher itself once the transfer has arrived.
*/
final readonly class VoucherSaleResponse
{
public const string SALE_POSSIBLE = 'möglich';
public const string SALE_PROCESSED = 'erfolgt';
public function __construct(
public string $status,
public ?int $voucherId = null,
public ?string $voucherNumber = null,
public ?string $redemptionCode = null,
public ?float $price = null,
public ?string $invoiceNumber = null,
public ?string $email = null,
) {
}
public function isInquiryValid(): bool
{
return self::SALE_POSSIBLE === $this->status;
}
public function isSaleSuccessful(): bool
{
return self::SALE_PROCESSED === $this->status;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
*
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
*/
final readonly class VoucherTemplate
{
public function __construct(
public int $id,
public string $type,
public string $label,
public ?string $note = null,
) {
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
*
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
* they were measured on the test system (10 and 5,000 €, both inclusive).
*/
final readonly class VoucherTemplates
{
/**
* @param list<VoucherTemplate> $templates
*/
public function __construct(
public int $minAmount,
public int $maxAmount,
public array $templates,
) {
}
public function find(int $id): ?VoucherTemplate
{
foreach ($this->templates as $template) {
if ($id === $template->id) {
return $template;
}
}
return null;
}
}
+23
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\BusProNet\Model; namespace App\BusProNet\Model;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
final readonly class XmlExportInfo final readonly class XmlExportInfo
{ {
private const INFO_FILENAME = 'uebertragung.info'; private const INFO_FILENAME = 'uebertragung.info';
@@ -20,6 +23,26 @@ final readonly class XmlExportInfo
return self::INFO_FILENAME; return self::INFO_FILENAME;
} }
/**
* Reads the marker from a storage, or null when it is missing or unreadable.
*
* A marker caught mid-sync or truncated is treated as absent rather than as an error:
* callers only ever want to know which export they are looking at, and "unknown" is
* an answer they can act on.
*/
public static function fromStorage(FilesystemOperator $storage): ?self
{
try {
if (false === $storage->fileExists(self::INFO_FILENAME)) {
return null;
}
return self::fromString($storage->read(self::INFO_FILENAME));
} catch (FilesystemException|\InvalidArgumentException) {
return null;
}
}
public static function fromString(string $content): self public static function fromString(string $content): self
{ {
$content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252'); $content = mb_convert_encoding($content, 'UTF-8', 'Windows-1252');
+5 -2
View File
@@ -79,6 +79,9 @@ trait TypeConversionTrait
* This method is used primarily to parse pickup-times since data is * This method is used primarily to parse pickup-times since data is
* either provided with date and time or time only. In this case a * either provided with date and time or time only. In this case a
* date has to be provided which defaults to a travel's start date. * date has to be provided which defaults to a travel's start date.
*
* Both shapes are read in the default date's zone, so a caller that needs plain clock
* readings can pass a UTC date and no daylight saving gap shifts the time.
*/ */
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{ {
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try { try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) { if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided // time only provided
$dateTime = Carbon::createFromFormat('H:i', $string); $dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date // assign default date
$dateTime->setDateFrom($defaultDate); $dateTime->setDateFrom($defaultDate);
} else { } else {
// date and time provided // date and time provided
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16)); $dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
} }
return $dateTime->toDateTimeImmutable(); return $dateTime->toDateTimeImmutable();
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Transport;
use App\Service\RequestIdGenerator;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Ids for BPN requests: the id of the current HTTP request, or a fresh one on the CLI, with a
* running number, so the log entries and dumps of one page load can be found together.
*/
class RequestIdSequence
{
private int $counter = 0;
public function __construct(
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
) {
}
public function next(): string
{
$baseId = $this->requestStack->getMainRequest()?->attributes->get('request_id')
?? $this->requestIdGenerator->generateBaseId();
return $baseId.'_'.++$this->counter;
}
}
+265
View File
@@ -0,0 +1,265 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
use Psr\Log\LoggerInterface;
/**
* BusPro's socket framing: one connection per request, each message prefixed with its length
* as a 10 byte header. Knows nothing about what the messages contain.
*/
class SocketTransport
{
private float $operationStartTime;
private int $selectedPort;
/** @param list<int|string> $ports */
public function __construct(
private readonly LoggerInterface $logger,
private readonly string $host,
private readonly array $ports,
private readonly int $connectionTimeout = 5,
private readonly int $streamTimeout = 30,
private readonly int $totalTimeout = 45,
private readonly int $maxRetries = 25,
) {
}
/**
* Sends $body over a fresh connection and returns the response payload. $logMessage is
* logged with $context and the chosen port once the connection is open.
*
* @param array<string, mixed> $context
*
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
public function exchange(string $body, string $logMessage, array $context = []): string
{
$socket = $this->connect();
$this->logger->info($logMessage, [...$context, 'port' => $this->selectedPort]);
$this->send($socket, $body);
try {
// the length header is consumed and verified by receive(), this is the payload
return $this->receive($socket);
} finally {
$this->disconnect($socket);
}
}
/**
* @return resource
*
* @throws ApiClientException
* @throws TimeoutException
*/
private function connect()
{
// Randomly select a port from the available ports for load balancing
$this->selectedPort = $this->ports[array_rand($this->ports)];
$this->operationStartTime = microtime(true);
$tries = 1;
$errNo = $errStr = '';
$errorCodesForRetry = [
SOCKET_ECONNREFUSED,
SOCKET_EBADF,
];
$openSocket = function (&$errNo, &$errStr) {
return @fsockopen(
$this->host,
$this->selectedPort,
$errNo,
$errStr,
$this->connectionTimeout
);
};
$socket = $openSocket($errNo, $errStr);
while (false === $socket && true === in_array($errNo, $errorCodesForRetry) && $this->maxRetries > $tries) {
// Check if we've exceeded total timeout during retries
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Connection retry timeout exceeded', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'total_timeout' => $this->totalTimeout,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Connection timeout exceeded during retries');
}
$this->logger->warning('Could not connect to socket, retrying', [
'error_message' => $errStr,
'error_number' => $errNo,
'attempt' => $tries,
'port' => $this->selectedPort,
]);
++$tries;
sleep(1);
$socket = $openSocket($errNo, $errStr);
}
if (false !== $socket) {
stream_set_timeout($socket, $this->streamTimeout);
} else {
$this->logger->error('Unable to open socket', [
'error_message' => $errStr,
'error_number' => $errNo,
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
throw new ApiClientException('Unable to open socket');
}
return $socket;
}
/**
* @param resource $socket
*
* @throws TimeoutException
*/
private function send($socket, string $data): void
{
// Check total timeout before sending
if (microtime(true) - $this->operationStartTime > $this->totalTimeout) {
$this->logger->error('Total timeout exceeded before send', [
'elapsed_time' => microtime(true) - $this->operationStartTime,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Total operation timeout exceeded before send');
}
// message length is prepended to actual message
$send = sprintf('%010s', strlen($data)).$data;
fwrite($socket, $send);
}
/**
* Reads a single response message and returns its payload. The protocol prepends the
* payload length as a 10 byte header, so the announced length is compared against what
* actually arrived: a peer closing mid-stream would otherwise yield a silently
* truncated body that only surfaces later as an unexplained parse failure.
*
* @param resource $socket
*
* @throws ApiClientException
* @throws TimeoutException
* @throws ImmediateConnectionCloseException
*/
private function receive($socket): string
{
$response = '';
$readAttempts = 0;
$totalTimeout = $this->totalTimeout;
while (false === feof($socket)) {
// Check total timeout before each read
$elapsedTime = microtime(true) - $this->operationStartTime;
if ($elapsedTime > $totalTimeout) {
$this->logger->error('Total timeout exceeded during receive', [
'elapsed_time' => $elapsedTime,
'total_timeout' => $totalTimeout,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Total operation timeout exceeded while receiving data');
}
$chunk = fread($socket, 4096);
++$readAttempts;
// Check if stream timed out on this specific read
$metadata = stream_get_meta_data($socket);
if (true === $metadata['timed_out']) {
// Check if this is an immediate rejection (0 bytes, < 1 second)
// This indicates the server is busy rather than a true timeout
if (0 === strlen($response) && $elapsedTime < 1.0) {
$this->logger->warning('Server closed connection immediately', [
'elapsed_time' => $elapsedTime,
'bytes_received' => 0,
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new ImmediateConnectionCloseException('Server closed connection immediately - server may be busy');
}
$this->logger->error('Stream read timeout detected', [
'elapsed_time' => $elapsedTime,
'bytes_received' => strlen($response),
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new TimeoutException('Stream timeout while reading from socket');
}
$response .= $chunk;
}
if (0 === strlen($response)) {
$elapsedTime = microtime(true) - $this->operationStartTime;
$this->logger->warning('Server closed connection without sending data', [
'elapsed_time' => $elapsedTime,
'read_attempts' => $readAttempts,
'port' => $this->selectedPort,
]);
throw new ImmediateConnectionCloseException('Server closed connection without sending data');
}
return $this->extractPayload($response);
}
/**
* @throws ApiClientException
*/
private function extractPayload(string $response): string
{
if (10 > strlen($response)) {
$this->logger->error('Incomplete response header received from API', [
'bytes_received' => strlen($response),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Incomplete response header, got %d of 10 bytes', strlen($response)));
}
$header = substr($response, 0, 10);
$announcedLength = (int) trim($header);
$payload = substr($response, 10);
if (1 > $announcedLength) {
$this->logger->error('Empty response body announced by API', [
'header' => trim($header),
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Response announced an empty body (header "%s")', trim($header)));
}
if (strlen($payload) !== $announcedLength) {
$this->logger->error('Truncated response received from API', [
'bytes_received' => strlen($payload),
'announced_length' => $announcedLength,
'port' => $this->selectedPort,
]);
throw new ApiClientException(sprintf('Truncated response, got %d of %d announced bytes', strlen($payload), $announcedLength));
}
return $payload;
}
/** @param resource $socket */
private function disconnect($socket): void
{
@fclose($socket);
}
}
+271
View File
@@ -0,0 +1,271 @@
<?php
namespace App\BusProNet\Transport;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
use App\BusProNet\MessageType;
use App\BusProNet\Utility\XmlCharacterFilter;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Service\XmlDumpWriter;
use Psr\Log\LoggerInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\SerializerInterface;
/**
* The XML protocol around one BusPro command: authentication, serialization, dumps, the
* busy-retry and parsing. What a command contains is up to the caller.
*/
class XmlChannel
{
/** @var array<string, mixed> */
private array $config;
/** @param array<string, mixed> $options */
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ApiResponseParser $responseParser,
private readonly XmlDumpWriter $dumpWriter,
private readonly LoggerInterface $logger,
private readonly RequestIdSequence $requestIds,
private readonly SocketTransport $transport,
array $options,
) {
$this->config = $this->resolveOptions($options);
}
/**
* Sends command $type with $fields and returns the parsed response.
*
* @param array<string, mixed> $fields
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
public function request(MessageType $type, array $fields = [], array $parserArgs = [], bool $debug = false): mixed
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data, $parserArgs, $debug) {
$requestId = $this->requestIds->next();
$dump = $debug || true === $this->config['debug'];
$xml = $this->exchange($requestId, $this->serializeXml($data), $type->value, 'Sending request to BPN API', $dump);
return $this->parseResponse($type, $xml, $parserArgs, $requestId);
}, $type->value);
}
/**
* Like request(), but returns the unparsed response.
*
* @param array<string, mixed> $fields
*
* @throws ApiClientException
*/
public function requestRaw(MessageType $type, array $fields = []): string
{
$data = $this->envelope($type, $fields);
return $this->executeWithRetry(function () use ($type, $data) {
$requestId = $this->requestIds->next();
$body = $this->serializeXml($data);
return $this->exchange($requestId, $body, $type->value, 'Sending raw request to BPN API', true === $this->config['debug']);
});
}
/**
* Sends a recorded request as is, except for the key, which is regenerated for the current
* date. Returns the unparsed response.
*
* @throws ApiClientException
*/
public function replay(string $xml, bool $debug = false): string
{
return $this->executeWithRetry(fn () => $this->doReplay($xml, $debug));
}
/**
* The authentication every request starts with, followed by the command's own fields.
*
* @param array<string, mixed> $fields
*
* @return array<string, mixed>
*/
private function envelope(MessageType $type, array $fields): array
{
return [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($type->value),
'satz' => ['@typ' => $type->value],
...$fields,
];
}
private function createKey(string $type): string
{
$date = (new \DateTimeImmutable())->format('Ymd');
return md5($this->config['bpn_username'].$this->config['bpn_password'].$date.$type);
}
/**
* Executes an operation with automatic retry on immediate connection close.
*
* When the BPN server is busy, it may close connections immediately without responding.
* This method detects such conditions and automatically retries after a short delay.
*
* @throws ImmediateConnectionCloseException If all retry attempts fail
*/
private function executeWithRetry(callable $operation, ?string $type = null): mixed
{
$maxAttempts = $this->config['busy_retry_attempts'];
$retryDelay = $this->config['busy_retry_delay'];
$typeContext = null !== $type ? ['type' => $type] : [];
$lastException = null;
for ($attempt = 1; $attempt <= $maxAttempts; ++$attempt) {
try {
return $operation();
} catch (ImmediateConnectionCloseException $e) {
$lastException = $e;
if ($attempt < $maxAttempts) {
$this->logger->warning('BPN server busy, retrying request', [
'attempt' => $attempt,
'maxAttempts' => $maxAttempts,
'retryDelay' => $retryDelay,
...$typeContext,
]);
sleep($retryDelay);
}
}
}
$this->logger->error('BPN server busy after all retry attempts', ['attempts' => $maxAttempts, ...$typeContext]);
throw $lastException;
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function doReplay(string $xml, bool $debug): string
{
$requestId = $this->requestIds->next();
$doc = new \DOMDocument();
if (false === @$doc->loadXML($xml)) {
throw new ApiClientException('Invalid XML provided');
}
$satzNode = $doc->getElementsByTagName('satz')->item(0);
if (null === $satzNode) {
throw new ApiClientException('Missing <satz> element in XML');
}
$type = $satzNode->getAttribute('typ');
if ('' === $type) {
throw new ApiClientException('Missing typ attribute on <satz> element');
}
$keyNode = $doc->getElementsByTagName('key')->item(0);
if (null === $keyNode) {
throw new ApiClientException('Missing <key> element in XML');
}
$keyNode->nodeValue = $this->createKey($type);
return $this->exchange($requestId, $doc->saveXML(), $type, 'Sending raw XML request to BPN API', $debug || true === $this->config['debug']);
}
/**
* @throws ApiClientException
* @throws ImmediateConnectionCloseException
*/
private function exchange(string $requestId, string $body, string $type, string $logMessage, bool $dump): string
{
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::REQUEST, $body);
}
$response = $this->transport->exchange($body, $logMessage, ['requestId' => $requestId, 'type' => $type]);
if ($dump) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $response);
}
return $response;
}
/**
* @param array<string, mixed> $parserArgs
*
* @throws ApiClientException
*/
private function parseResponse(MessageType $type, string $xml, array $parserArgs, string $requestId, bool $dumpOnFailure = true): mixed
{
// The dump keeps the raw response; only the parser sees the filtered one.
$filtered = XmlCharacterFilter::strip($xml, $removed);
if ($removed > 0) {
$this->logger->warning('Removed characters not allowed in XML from API response', [
'request_id' => $requestId,
'type' => $type->value,
'removed' => $removed,
]);
}
try {
return $this->responseParser->parseXmlString($type, $filtered, $parserArgs);
} catch (ResponseParserException $e) {
// Keep the evidence even outside debug mode: without the raw response a parse
// failure is not diagnosable after the fact. app:cleanup:xml-dumps prunes it.
if ($dumpOnFailure) {
$this->dumpWriter->write($requestId, XmlDumpWriter::RESPONSE, $xml);
}
$this->logger->error('Unable to parse response received from API', [
'request_id' => $requestId,
'type' => $type->value,
'response_length' => strlen($xml),
'error_message' => $e->getMessage(),
]);
throw new ApiClientException('Unable to parse response received from API: '.$e->getMessage(), 0, $e);
}
}
/** @param array<string, mixed> $data */
private function serializeXml(array $data): string
{
return $this->serializer->serialize($data, 'xml', [
XmlEncoder::ROOT_NODE_NAME => 'anfrage',
XmlEncoder::ENCODING => 'UTF-8',
]);
}
/**
* @param array<string, mixed> $options
*
* @return array<string, mixed>
*/
private function resolveOptions(array $options): array
{
$optionsResolver = new OptionsResolver();
$optionsResolver->setRequired([
'bpn_username',
'bpn_password',
]);
$optionsResolver->setDefaults([
'debug' => false,
'busy_retry_attempts' => 3,
'busy_retry_delay' => 1,
]);
return $optionsResolver->resolve($options);
}
}
Loaded 100 of 636 files, more files were not shown because too many files have changed in this diff. Show more