# Mobile App — Backend Plan (myep / Symfony) **Audience:** sessions working in *this* repository (`myep`, Symfony 6.4). **Companion:** `docs/mobile-app/app-plan.md` — the React Native side. That document is written to be self-contained and is meant to be **copied into the RN repository**, whose sessions have no access to this codebase. --- ## 1. Context `myep` is the booking engine behind the separate CMS marketing site (`ep-reisen.de`; see `BookingSessionManager::DEFAULT_RETURN_URL` and `CmsDataProvider`). It serves: - **Individual travel bookings** — system of record is **BusProNet** (BPN), an external XML service reached through `src/BusProNet/ApiClient.php`. - **Groups / accommodation bookings** — system of record is local Doctrine entities under `src/Entity/Groups/`. The public UI is Twig + HTMX + Tailwind. The admin UI under `/admin` is **out of scope** for the mobile app and stays web-only. ### What we are building and why A React Native app covering the **full public surface, including the booking create flow**. Drivers: App Store presence/brand and a native-feeling booking experience. A PWA was explicitly ruled out. Push notifications were *not* named as a driver — worth remembering, since push is normally what makes native unarguable; the payoff here rests on brand and UX. ### The decision that shapes everything **The sibling project is the React Native app only. The API stays in this repository.** A second backend would recreate exactly the duplication of validation and business rules this plan exists to prevent. The API is a new `src/Controller/Api/App/` namespace reusing the existing services and DTOs. **Client strategy: server-driven UI (SDUI).** The server emits a JSON field schema per participant/step; RN renders it generically. Business rules never leave PHP. ### Why this codebase can actually support that The rules are already headless: - **Validation lives on DTOs, not form types.** `src/Form/Model/ParticipantDto.php` (21 constraints), `BookingDto.php`, `BankAccountDto.php`, `RegistrationDto.php` carry `Assert\*` plus custom `App\Validator\Constraints\Participant`, with validation groups (`booking_create`, `booking_edit`, `strict_required`, `applicant_address`). An API endpoint can denormalize → `validate($dto, groups: [...])` and get identical results. - **Dynamic field logic is a rules engine.** `src/Form/Service/` holds `Contract/FieldStateProviderInterface` (hidden / readonly / disabled / required / static_text), `Contract/FieldOptionsProviderInterface` (labels + choices), `ParticipantFieldHandlerRegistry` (25 handlers) and 27 reusable `Condition/` classes. - **Orchestration is in services, not controllers.** `BookingConfigurator` (652 lines), `RoomAssigner`, `BookingPricingAssembler`, `BookingSummaryAssembler`, `VoucherValidator`, `BookingEditSubmitGuard`, `BookingEditSubmitter`. Controllers are thin — `Step2Controller` is 147 lines, mostly session handling and redirects. --- ## 2. The four real obstacles Everything else is mechanical. These need design work before the increments that depend on them. ### Obstacle 1 — The create flow is session-bound `src/Service/BookingSessionManager.php` keys the entire create flow off `$request->getSession()` (`booking_create`, `booking_create_baseline_snapshot`, `booking_return_url`). Stateless OAuth2 clients have no session. **Fix:** a server-persisted draft addressed by UUID. The pattern already exists for edit — `src/Entity/BookingEditDraft.php` plus `BookingEditDraftManager` / `BookingEditDraftMerger`. Generalise into a `BookingDraft` store and put `BookingStateStoreInterface` in front, with two backends: session (web, unchanged) and draft (app). Do **not** fork the flow logic. ### Obstacle 2 — `FieldStateProviderInterface` leaks Symfony Form Two leaks: - `getBookingDtoFromForm(FormInterface $form)` is pure Form coupling and belongs in a form-side adapter, not the interface. - `getFieldState()` returns **Symfony form options** (`disabled`, `required`, `attr => ['readonly' => true]`), and `ParticipantFieldOptionsProvider` returns `choices` as domain objects (`Service`, `Pickup`, `Insurance`) plus occasional `choice_loader`. **Fix:** a neutral `FieldDescriptor` value object — name, type, label, required, readonly, disabled, hidden, staticText, choices, value. Both `BookingParticipantType` and the new JSON serializer consume it. Conditions and handlers are untouched. Note the `hidden` vs `static_text` distinction documented in `AbstractFieldStateProvider`: `hidden` means excluded entirely; `static_text` means "show the value, don't let them edit it". The app must honour both, so `FieldDescriptor` has to carry them separately. ### Obstacle 3 — Token lifetimes and refresh-token rotation `config/packages/league_oauth2_server.yaml` sets `access_token_ttl: PT10M` — deliberately, since the bundle's own default is `PT1H`. Find out why before changing it. **TTLs are global.** They live under `authorization_server`; the bundle has no per-client TTL. Any change affects the CMS and M2M clients too. "Raise it just for the app" is not available without a custom grant or response type. Two values are unset and defaulted, and both matter: - `refresh_token_ttl` → **`P1M`** (1 month) - `revoke_refresh_tokens` → **`true`**, so **refresh-token rotation is on** Rotation is correct security, and it is also the main source of mobile auth bugs: two requests 401 concurrently, both refresh, one rotates, the second presents a revoked refresh token and the user is logged out mid-booking. At a 10-minute TTL with step 2 firing schema refreshes per field change, this *will* happen. The mitigation is client-side — single-flight refresh — and it is a hard requirement in `app-plan.md` §4. A longer access-token TTL does **not** create an unrevocable window here: `persist_access_token` defaults to true and `BearerTokenValidator` checks `isAccessTokenRevoked()` on every request, so tokens can be killed immediately. The usual argument for a very short TTL does not apply. **Recommendation:** `access_token_ttl: PT1H`, `refresh_token_ttl: P3M` set explicitly, rotation left on. Rotation issues a fresh refresh token each time, so the refresh TTL is a sliding window — active users never re-auth, only ~3 months of true inactivity forces it. M2M clients get fewer token round-trips. Extra token rows are already handled by `league:oauth2-server:clear-expired-tokens`, scheduled in `config/packages/zenstruck_schedule.yaml`. **Settle this before the RN auth layer is written, not after.** Otherwise auth is ready: `enable_auth_code_grant: true` with `require_code_challenge_for_public_clients: true` (PKCE), and `enable_password_grant: false` / `enable_implicit_grant: false` — correct for a native app. ### Obstacle 4 — BPN per-user calls need the user's plaintext password `ApiClient::getBookings()`, `getBooking()`, `getPersonalData()`, `updatePersonalData()`, `updateNewsletterRegistration()` all take `(string $email, string $password)`. `src/Controller/Booking/IndexController.php` supplies them via `$this->crypt->decrypt($user->getPassword())` — the local `User` stores the BPN password RSA-encrypted (`src/Security/Crypt.php`). Consequences: - **The app must never see BPN credentials.** The API layer resolves them server-side from the authenticated `User`, exactly as the web controllers do. This is not a change, but it must be stated so nobody "simplifies" it by proxying credentials. - **BPN-backed user data only works for users with a local `User` record** carrying the encrypted password. Guest flows and app-only accounts cannot read BPN bookings. Confirm how registration-via-app populates this before Increment 3. - The OAuth2 token identifies the user; it does **not** carry BPN authority. Every BPN-backed endpoint needs an explicit ownership check, not just a valid token. --- ## 3. Increments Ordered local-data-first, BPN-last, so each stage ships something usable and the riskiest integration lands after the SDUI machinery is proven. ### Increment 0 — Foundations No user-visible feature. Backend side: - Register an OAuth2 client for the app (authorization_code + PKCE, native redirect URI). - Resolve Obstacle 3 (TTL). - Resolve device→DDEV access: `.ddev/config.yaml` has `ngrok_args`; `ddev share` is the path. Redirect URIs must be registered against whatever host the app actually calls. - Create `docs/app-api/` with the contract skeleton and the fixtures directory. ### Increment 1 — Accommodation & group offers (pilot) Local Doctrine data, UUID-addressed, and a write endpoint already exists. Zero BPN risk. - Reuse `GET /api/accommodation-bookings/{uuid}` and `POST /api/accommodation-bookings/{uuid}/accept` (`src/Controller/Api/AccommodationBookingController.php`). - Add a list endpoint scoped to the authenticated user. - Mirrors `src/Controller/Groups/Offer/IndexController.php`; reuse `AccommodationBookingService`, `AccommodationBookingBreakdownCalculator`, `AccommodationBookingApiResponse`. - **Validates:** auth, serialization conventions, RN navigation, the fixture pipeline. ### Increment 2 — Account, personal data, newsletter, registration - Mirror `src/Controller/Account/`, `RegistrationController`, `ResetPasswordController`, `src/Controller/Newsletter/`. - Reuse `RegistrationDto`, the `PersonalDataType` DTO, `NewsletterManager`, `ProfileCompletenessChecker`, and existing `/api/newsletter-subscriptions`, `/api/userinfo`, `/api/crm-attributes`. - **First real SDUI use**, on a small low-risk form, before betting the booking flow on it. - Registration here is what makes Obstacle 4 tractable — verify the encrypted-password path is populated for app-registered users. ### Increment 3 — Booking read - `GET /api/app/bookings` and `/api/app/bookings/{id}`, mirroring `src/Controller/Booking/IndexController.php` (note its `TravelLoader::patchBookings()` enrichment step). - Documents/invoice, mirroring `src/Controller/Booking/DownloadController.php`. - Reuse `BookingSummaryAssembler`, `BookingPricingAssembler`, `BookingEditDataLoader`. - BPN **reads only, no writes** — the safe way to shake out `BusProNet\Model` normalization (`Travel`, `Booking`, `Service`, `Room`, `Pickup`, `Insurance`), which every later increment depends on. ### Increment 4 — `FieldDescriptor` refactor Pure internal refactor. No new endpoints. Covered by the existing 155 test files. - Introduce `FieldDescriptor`; split `getBookingDtoFromForm` out of `FieldStateProviderInterface`. - Adapt `BookingParticipantType::mergeFieldState()` / `addDynamicFields()` to consume it. - Add `ParticipantFieldSchemaSerializer` producing the JSON schema, including the `dependsOn` map derived from `FieldConditionInterface::getDependentFields()` and `ParticipantFieldHandlerInterface::getDependencies()`. - **Ship criterion:** web behaviour byte-identical, Cypress specs green, **no test file changed**. A required test change means a behaviour regression. ### Increment 5 — Booking edit Before create, because drafts already exist here. - Endpoints over `BookingEditDraftManager`, `BookingEditPreFlightChecker`, `BookingEditSubmitGuard`, `BookingEditSubmitter`, `BookingChangeTracker`. - Participant schema endpoint from Increment 4. - Honour `BookingMutabilityDto` and BPN's `aenderungmoeglich` mutability rules — these already drive `static_text`, so SDUI inherits them. - **Caution:** the participant[0]/applicant block is on hold pending BPN vendor confirmation about the shared customer id. Do not try to resolve it here. ### Increment 6 — Booking create The full flow, last. Web routes being mirrored: | Step | Web route | Controller | |---|---|---| | init | `/bookings/create`, `/bookings/create/init` | `Create/IndexController.php` | | 1 — rooms | `/bookings/create/rooms` (+ `/refresh`) | `Create/Step1Controller.php` | | 2 — participants | `/bookings/create/participants` (+ `/{index}`, `/{index}/refresh`) | `Create/Step2Controller.php`, `Step2ParticipantController.php` | | 3 — payment | `/bookings/create/payment` (+ `/refresh`) | `Create/Step3Controller.php` | | 4 — confirmation | `/bookings/create/confirmation` | `Create/Step4Controller.php` | | success | `/bookings/create/success` | `Create/SuccessController.php` | - Generalise draft storage behind `BookingStateStoreInterface` (Obstacle 1). - Reuse `BookingConfigurator` (`startFreshBooking`, `preselectDefaultServices`, `applyCreateBookingStatusRules`, `updateBookingStatusFromRoomSelection`), `RoomAssigner`, `ParticipantDataPrefiller`, `TravelDataProvider`, `VoucherValidator`, `FamilyInsuranceAvailabilityChecker`. - Submit via `BusProNet\ApiClient::createBooking()` / `createBookingInquiry()` with `BookingPayloadBuilder`, exactly as `Step4Controller` does. - Every HTMX refresh route gets a JSON sibling returning recomputed schema + pricing. ### Increment 7 — Discovery (scoping decision, not yet planned) Travel/hotel browsing lives in the **CMS**, not here — this repo only proxies it via `CmsDataProvider` and `/api/hotels`, `/api/products`, `/api/travels`. If the app is to be a standalone entry point rather than a deep-link target, that is a second integration project and should be scoped separately. --- ## 4. Files **New (representative):** - `src/Controller/Api/App/` — one controller per resource, mirroring the public tree - `src/Form/Service/Model/FieldDescriptor.php` - `src/Form/Service/ParticipantFieldSchemaSerializer.php` - `src/Service/BookingStateStoreInterface.php` + draft-backed implementation - `src/Entity/BookingDraft.php` (generalised from `BookingEditDraft`) + migration - Normalizers for `BusProNet\Model\*` value objects - `tests/Controller/Api/App/` **Modified:** - `src/Form/Service/Contract/FieldStateProviderInterface.php` — drop the Form-coupled method - `src/Form/Service/Abstract/AbstractFieldStateProvider.php`, `CreateFieldStateProvider.php`, `EditFieldStateProvider.php` - `src/Form/BookingParticipantType.php` — consume `FieldDescriptor` - `src/Service/BookingSessionManager.php` — implement the store interface - `config/packages/league_oauth2_server.yaml` — TTL, app client, scopes - `docs/api-consumer-guide.md` / `docs/app-api/` — the contract **Unchanged by design:** every `src/Form/Service/Condition/*`, every `src/Form/Service/Participant*FieldHandler.php`, every `src/Validator/Constraints/*`, and all pricing services. If an increment starts wanting to edit these, the design has drifted — stop and reconsider. --- ## 5. Working across two repos and two sessions The RN work happens in a separate repo and a separate sandboxed session. The risk that creates is **contract drift**: the app session guessing at response shapes, or worse, re-deriving business rules in TypeScript because the JSON was ambiguous. That is the original duplication problem arriving through a side door. ### The contract is an artifact in this repo `docs/api-consumer-guide.md` already opens with *"It is self-contained: no access to this codebase is required to use it."* That is the format. Extend it — or split out `docs/app-api/guide.md` once it outgrows one file — **in the same commit as the endpoint**, every time. Rule: **the RN session never reads this PHP repo.** Its ground truth is the guide plus fixtures. If it has to ask "what does the server send here?", the guide has a gap — fix the guide, not the app. ### Fixtures decouple the sessions Parity tests in `tests/Controller/Api/App/` write their JSON responses to `docs/app-api/fixtures/*.json` (mirroring the existing `tests/Resources/*.xml` convention). Commit them. The RN session mocks against those files, so app work never waits on a deployed endpoint — and because they are test *output*, they cannot silently go stale. ### Per-increment rhythm 1. **Backend session (here):** endpoint + parity test + fixtures + guide update. 2. **Handoff:** a short brief — endpoints now live, fixtures to mock, what changed in the guide since last time. 3. **App session (RN repo):** build against fixtures first, then against DDEV. 4. **Joint verification:** run the increment end-to-end against a real backend. Do not run both sessions on the same increment concurrently. Overlap by one stage: while the app session builds increment *N*, start *N+1* here. --- ## 6. Verification Per increment: 1. `vendor/bin/phpunit` — 155 existing test files, notably `tests/Form/`, `tests/Service/`, `tests/BusProNet/`. **Increment 4 must pass with no test changes.** 2. `vendor/bin/phpstan analyse` and `vendor/bin/php-cs-fixer fix --dry-run`. 3. `npx cypress run` — existing specs cover the web booking flow and guard against SDUI refactors leaking into Twig rendering. 4. **Parity tests** under `tests/Controller/Api/App/`, asserting the JSON schema matches what `BookingParticipantType` builds for the same `BookingDto`. This is the single most valuable artifact in the project — it mechanically proves web and app share one rule set. 5. Manual: extend the existing `api.http` / `http-client.env.json` collection per increment. 6. End-to-end: run one booking through the RN client against a DDEV backend and confirm the BPN payload is identical to a web-flow booking with the same inputs — compare via `src/Controller/Admin/Log/XmlDumpController.php`. --- ## 7. Open questions before Increment 6 - App-client access token TTL (Obstacle 3). - How app registration populates the encrypted BPN password (Obstacle 4), and whether guest/unauthenticated create is supported in the app as it is on the web via `/bookings/create/init`. - Draft retention and expiry policy. - Whether the app deep-links from the CMS site or must own discovery (Increment 7).