355 lines
18 KiB
Markdown
355 lines
18 KiB
Markdown
# 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).
|