docs: include previously ignored documentation files
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
# EP Reisen Mobile App — React Native Plan
|
||||
|
||||
**Copy this file into the React Native repository** (suggested: `docs/plan.md`). It is
|
||||
written to be self-contained: you do not need access to the backend codebase to work from
|
||||
it. Where it says "the contract", it means the API guide and fixtures shipped by the
|
||||
backend team — see §3.
|
||||
|
||||
**Companion (backend side, not needed here):** `docs/mobile-app/backend-plan.md` in the
|
||||
`myep` Symfony repository.
|
||||
|
||||
---
|
||||
|
||||
## 1. What this app is
|
||||
|
||||
EP Reisen sells group travel — primarily ski and coach trips. A booking is not "one person
|
||||
buys one thing": it is **one applicant booking a set of rooms for a group of participants**,
|
||||
where each participant independently selects ski passes, equipment rental, board, courses,
|
||||
transport, insurance and more, with prices and eligibility varying per person (notably by
|
||||
age).
|
||||
|
||||
The existing web product is a server-rendered Symfony application. This app covers its
|
||||
**public** surface. The admin area is explicitly out of scope and will remain web-only.
|
||||
|
||||
The app is being built for App Store presence and a native-feeling booking flow. Push
|
||||
notifications are **not** currently a requirement — do not build infrastructure for them
|
||||
without asking.
|
||||
|
||||
### The two systems behind the API
|
||||
|
||||
You will see this distinction leak into behaviour, so it is worth knowing:
|
||||
|
||||
- **BusProNet (BPN)** — an external, third-party XML system that is the system of record
|
||||
for individual travel bookings. It is slow, occasionally unavailable, and its rules are
|
||||
not ours to change. Endpoints backed by it can fail in ways local data cannot.
|
||||
- **Local database** — system of record for group/accommodation bookings, user accounts,
|
||||
newsletter consent, and booking drafts. Fast and reliable.
|
||||
|
||||
Increments are deliberately ordered local-data-first so you are not fighting BPN latency
|
||||
while also debugging your own foundations.
|
||||
|
||||
---
|
||||
|
||||
## 2. The one architectural rule
|
||||
|
||||
> **Business rules live on the server. The app renders what it is told.**
|
||||
|
||||
This is not a stylistic preference; it is the reason the project is viable. The backend
|
||||
already contains, in one place, 27 condition classes and 25 field handlers that decide —
|
||||
per participant, per field — whether a field is visible, editable, required, or shown as
|
||||
read-only text, and which choices it offers. Reimplementing any of that in TypeScript
|
||||
creates two sources of truth that will silently diverge.
|
||||
|
||||
**Concretely, never write code in this repo that:**
|
||||
|
||||
- decides whether a field is shown, e.g. inferring that a licence-plate input appears
|
||||
because transport is by car;
|
||||
- computes or re-computes a price, total, discount or voucher deduction from line items;
|
||||
- decides whether a participant is eligible for a service, especially by age;
|
||||
- validates a business constraint (as opposed to a formatting constraint like "this looks
|
||||
like an email").
|
||||
|
||||
**If the app appears to need one of those, the schema is missing information. That is a
|
||||
backend bug.** Report it; do not patch around it. This single discipline is what the whole
|
||||
architecture buys, and it is lost the first time it is violated.
|
||||
|
||||
Client-side validation is limited to immediate input affordances — keyboard type, max
|
||||
length, obviously-malformed email — purely for responsiveness. The server's verdict is
|
||||
always authoritative and always re-checked on submit.
|
||||
|
||||
---
|
||||
|
||||
## 3. The contract
|
||||
|
||||
The backend ships two artifacts. Together they are your ground truth.
|
||||
|
||||
1. **The API guide** — a self-contained markdown document describing every endpoint:
|
||||
method, path, auth scope, request shape, response shape, error shapes. The backend
|
||||
team updates it in the same commit as the endpoint.
|
||||
2. **Fixtures** — real JSON responses, written out by the backend's own tests. Because
|
||||
they are test *output*, they cannot silently go stale.
|
||||
|
||||
### How to work with them
|
||||
|
||||
- **Build against fixtures first**, then point at a live backend. This means app work
|
||||
never blocks on a deployed endpoint.
|
||||
- **Generate your TypeScript types from the contract** rather than hand-writing them, so
|
||||
a schema change surfaces as a compile error instead of a runtime surprise.
|
||||
- **When the guide is ambiguous, ask — do not guess.** A guess that happens to work
|
||||
becomes an undocumented dependency on an accident.
|
||||
|
||||
### Error shapes to expect
|
||||
|
||||
The existing API does not have one universal error envelope. Handle all of these:
|
||||
|
||||
- `{"message": "..."}` — typically with 404 or 400.
|
||||
- `{"error": "...", "type": "api_error"}` — upstream (BPN) failure. **Some endpoints
|
||||
return this with HTTP 200.** Check for the presence of an `error` key, not only the
|
||||
status code.
|
||||
- `{"success": false, "message": "..."}` — some write endpoints.
|
||||
- **400 with a constraint-violation body** — validation failures. These are the ones you
|
||||
map back onto form fields; see §5.
|
||||
|
||||
`401` means the token is missing or invalid (refresh, then retry once). `403` means
|
||||
insufficient scope — a bug, not a user-recoverable state.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication
|
||||
|
||||
OAuth2 **authorization_code with PKCE**, against the backend's `/token` endpoint. The
|
||||
password and implicit grants are disabled server-side and are not available to you.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Use the system browser / `ASWebAuthenticationSession` / Custom Tabs — **not** a
|
||||
`WebView`. App Store review and OAuth best practice both require this.
|
||||
- Store tokens in the platform secure store (Keychain / Keystore), never in
|
||||
`AsyncStorage`.
|
||||
- **Access tokens are short-lived** — 10 minutes today, likely 1 hour by the time you
|
||||
build. Confirm with the backend team in Increment 0. Either way the refresh-token grant
|
||||
is enabled and you must use it.
|
||||
- **Refresh-token rotation is ON.** Each refresh returns a *new* refresh token and revokes
|
||||
the one you just used. This has two consequences you must design for, not discover:
|
||||
- **Single-flight refresh is mandatory.** If two requests 401 at the same time and both
|
||||
try to refresh, one wins and the other presents an already-revoked token — logging the
|
||||
user out mid-booking. Put a mutex around refresh and queue every other 401 behind it,
|
||||
then replay them with the new token. This is the most common OAuth bug in mobile apps
|
||||
and the booking flow's per-field schema refreshes will find it immediately.
|
||||
- **Persist the rotated token atomically before using it.** A crash between receiving a
|
||||
new refresh token and storing it logs the user out permanently.
|
||||
- The refresh TTL is a sliding window (currently ~1 month, likely 3), so an active user
|
||||
should never be forced to re-authenticate. If yours are, the refresh path is broken —
|
||||
treat it as a bug, not as expected behaviour.
|
||||
- The redirect URI must be registered server-side against the exact host the app calls.
|
||||
This bites during local development — see §8.
|
||||
|
||||
**You will never handle credentials for the upstream BPN system.** The server resolves
|
||||
those internally. If any task seems to require the app to send a BPN password, stop and
|
||||
ask; the answer is no.
|
||||
|
||||
---
|
||||
|
||||
## 5. Server-driven UI
|
||||
|
||||
The dynamic parts of the booking flow are rendered from a schema the server computes. You
|
||||
build **one generic renderer**, not per-screen field logic.
|
||||
|
||||
### Expected schema shape
|
||||
|
||||
Confirm against the fixtures — they are authoritative — but the agreed shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{
|
||||
"name": "skiPass",
|
||||
"type": "choice",
|
||||
"label": "Skipass",
|
||||
"required": true,
|
||||
"value": 881,
|
||||
"choices": [
|
||||
{"id": 881, "label": "6 Tage", "price": 249.0}
|
||||
]
|
||||
},
|
||||
{"name": "licensePlate", "type": "text", "hidden": true},
|
||||
{"name": "pickup", "type": "choice", "readonly": true, "value": 12, "choices": []},
|
||||
{"name": "firstName", "type": "text", "staticText": true, "value": "Anna"}
|
||||
],
|
||||
"dependsOn": {
|
||||
"licensePlate": ["transportationOutbound"],
|
||||
"pickup": ["transportationOutbound"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Field states — all four are distinct
|
||||
|
||||
| State | Meaning | Render as |
|
||||
|---|---|---|
|
||||
| `hidden` | Not applicable at all | Nothing |
|
||||
| `staticText` | Value matters, user may not change it | Read-only text, no input affordance |
|
||||
| `readonly` | Input shown but not editable | Disabled-looking input |
|
||||
| `disabled` | Temporarily not interactive | Greyed input |
|
||||
|
||||
`hidden` and `staticText` are genuinely different and the backend treats them
|
||||
differently — `staticText` is used, for example, when personal data is locked because
|
||||
changing it would create a duplicate customer record upstream. Collapsing the two will
|
||||
produce wrong screens.
|
||||
|
||||
### `dependsOn`
|
||||
|
||||
Maps a field to the fields whose changes invalidate it. When the user changes field *X*,
|
||||
any field listing *X* in its `dependsOn` may now have different visibility, state, or
|
||||
choices. **Re-fetch the schema from the server** — do not attempt to recompute locally.
|
||||
This is the mechanism that keeps rule evaluation server-side.
|
||||
|
||||
Debounce these refreshes and keep the UI responsive during them, but never predict the
|
||||
result optimistically for anything that affects price or eligibility.
|
||||
|
||||
### Field types you must support
|
||||
|
||||
`text`, `email`, `tel`, `date`, `choice` (single), `multichoice`, `checkbox`, `address`
|
||||
(composite), plus a `static` presentation mode. Confirm the final list against the
|
||||
contract before building.
|
||||
|
||||
### Mapping validation errors back
|
||||
|
||||
The server returns violations keyed by a property path (e.g.
|
||||
`participants[2].dateOfBirth`). Your renderer must resolve those onto the right field on
|
||||
the right participant. Design for this from the start — retrofitting it is painful.
|
||||
|
||||
---
|
||||
|
||||
## 6. Domain glossary
|
||||
|
||||
Enough to read the API without guessing.
|
||||
|
||||
- **Travel** — a dated departure of a product, at a specific hotel. Identified by a
|
||||
numeric `dateId` + `hotelId`, or by string codes. Carries the available rooms, services,
|
||||
pickups, insurances.
|
||||
- **Applicant** — participant index 0. The person who books and is billed. Subject to
|
||||
extra rules the others are not; their personal data is frequently locked (`staticText`).
|
||||
- **Participant** — one traveller. Most selections live per participant, not per booking.
|
||||
- **Room selection** — how many of which room type. This determines the participant count,
|
||||
which is why step 1 precedes step 2.
|
||||
- **Service** — anything bookable alongside: ski pass, equipment rental, board, courses,
|
||||
transport, parking, additional services. Some are mandatory, some auto-booked with
|
||||
opt-out, some age-restricted.
|
||||
- **Booking status** — `F` firm booking, `O` option (held), `A` inquiry. Determined by
|
||||
server rules, not user choice.
|
||||
- **Payment method** — `transfer` or `debit`. Debit requires bank account details.
|
||||
- **Voucher** — purchase vouchers (a balance), goodwill vouchers, and promo vouchers
|
||||
(fixed or percentage, per-participant or per-booking). **Always** server-validated;
|
||||
never compute a discount locally.
|
||||
- **Baby** — participants aged 0–2 are a real special case in eligibility and pricing.
|
||||
- **Mutability** — for existing bookings, BPN decides per field whether a change is still
|
||||
permitted. Surfaces as `staticText`/`readonly`. Never override it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Increments
|
||||
|
||||
Each increment is a working loop with the backend session; see §9. Do not start an
|
||||
increment before its backend endpoints and fixtures exist.
|
||||
|
||||
### Increment 0 — Foundations
|
||||
|
||||
- RN app skeleton, navigation, theming, error boundary.
|
||||
- OAuth2 authorization_code + PKCE, secure token storage, silent refresh with request
|
||||
queuing.
|
||||
- API client: auth injection, 401-refresh-retry, the error shapes in §3, and a fixture
|
||||
mock mode toggled by env.
|
||||
- **The generic SDUI renderer** — consumes `{fields, dependsOn}`, renders every field type,
|
||||
emits changes, maps server violations onto fields.
|
||||
- Type generation from the contract wired into the build.
|
||||
- **Done when:** Increment 1 renders entirely through the generic renderer.
|
||||
|
||||
### Increment 1 — Accommodation & group offers (pilot)
|
||||
|
||||
Local data, fast, low risk. Deliberately first so the foundations are exercised end-to-end
|
||||
before anything hard.
|
||||
|
||||
- Offer list for the signed-in user, offer detail, accept an offer.
|
||||
- **Validates:** auth, error handling, navigation, the fixture pipeline.
|
||||
|
||||
### Increment 2 — Account, personal data, newsletter, registration
|
||||
|
||||
- Sign-up, password reset, profile view/edit, newsletter preferences.
|
||||
- **First real use of SDUI** on a small form, before betting the booking flow on it. Expect
|
||||
to find gaps in the renderer here — that is the point of doing it now.
|
||||
|
||||
### Increment 3 — Booking read
|
||||
|
||||
- "My travels": booking list, booking detail with participants, services and price
|
||||
breakdown, document/invoice download.
|
||||
- First BPN-backed screens. Expect slower responses and genuine upstream failures — build
|
||||
proper loading and error states here, and reuse them everywhere after.
|
||||
|
||||
### Increment 4 — *(backend only)*
|
||||
|
||||
An internal backend refactor that produces the participant field schema. No app work.
|
||||
Good moment to pay down debt or firm up the renderer.
|
||||
|
||||
### Increment 5 — Booking edit
|
||||
|
||||
- Edit an existing booking through a server-persisted draft: load draft, edit participants
|
||||
via SDUI, review changes, submit.
|
||||
- Honour mutability strictly — many fields will be `staticText`, and that is correct.
|
||||
- Drafts are server-side, so edits survive app restarts. Design the UX around that.
|
||||
|
||||
### Increment 6 — Booking create
|
||||
|
||||
The full four-step flow, last, once everything above is proven.
|
||||
|
||||
1. **Rooms** — select room types and quantities. Determines participant count.
|
||||
2. **Participants** — one card per participant, each opening an SDUI form. Groups can be
|
||||
large (50+); the web version lazy-loads per participant and you should too.
|
||||
3. **Payment** — method, and bank details when debit.
|
||||
4. **Confirmation** — full summary, terms, submit.
|
||||
|
||||
Notes:
|
||||
|
||||
- State lives in a **server-side draft keyed by UUID**, not on the device. The app holds
|
||||
the draft id and renders server state.
|
||||
- Price refreshes are server calls. Never compute totals locally, not even for a
|
||||
"preview".
|
||||
- Submission goes to BPN and can fail or partially succeed. Treat the submit step as
|
||||
genuinely fallible and make retry safe.
|
||||
|
||||
### Increment 7 — Discovery *(not yet scoped)*
|
||||
|
||||
Browsing travels and hotels currently lives in a separate CMS website, not in the backend
|
||||
this app talks to. If the app must be a standalone entry point rather than something users
|
||||
deep-link into, that is a second integration project. Do not assume it is in scope.
|
||||
|
||||
---
|
||||
|
||||
## 8. Local development
|
||||
|
||||
- The backend runs in DDEV. A **simulator** can reach it via the host; a **physical
|
||||
device** cannot resolve `*.ddev.site`. The backend team will expose a shareable URL
|
||||
(`ddev share` / ngrok).
|
||||
- OAuth2 redirect URIs are registered per host, so switching between simulator and device
|
||||
URLs needs a backend-side change. Agree the setup in Increment 0 rather than discovering
|
||||
it mid-increment.
|
||||
- Keep fixture-mock mode working permanently, not just as scaffolding — it is how you stay
|
||||
unblocked and how you write deterministic tests.
|
||||
|
||||
---
|
||||
|
||||
## 9. Working agreement with the backend session
|
||||
|
||||
The backend lives in a separate repository and a separate sandboxed session. Neither side
|
||||
can see the other's code. The contract is the only coupling point, and keeping it that way
|
||||
is a shared discipline.
|
||||
|
||||
**Per-increment loop:**
|
||||
|
||||
1. Backend ships: endpoints + tests + fixtures + updated guide.
|
||||
2. Handoff brief: what is live, which fixtures to mock, what changed in the guide.
|
||||
3. App builds against fixtures, then against a live backend.
|
||||
4. Joint end-to-end verification.
|
||||
|
||||
The two sessions do not work the same increment concurrently — they overlap by one stage,
|
||||
so the backend starts *N+1* while the app builds *N*.
|
||||
|
||||
**Escalate to the backend rather than solving locally when:**
|
||||
|
||||
- a rule seems missing from the schema;
|
||||
- a response shape is undocumented or contradicts the guide;
|
||||
- you need a field the API does not expose;
|
||||
- you are tempted to hardcode a business constant.
|
||||
|
||||
Each of those is a contract gap. Fixing it in the app is the failure mode this entire
|
||||
architecture exists to prevent.
|
||||
|
||||
**Suggested `AGENTS.md` for this repo**, so future sessions inherit the rule:
|
||||
|
||||
> Business rules live server-side. This app renders a server-provided schema and never
|
||||
> decides field visibility, eligibility, or pricing. If a rule appears to be missing,
|
||||
> that is a backend bug — report it, do not implement it here. Never recompute prices
|
||||
> from line items or infer field visibility from other field values.
|
||||
|
||||
---
|
||||
|
||||
## 10. Verification
|
||||
|
||||
- **Unit:** the SDUI renderer against every field type and state combination, including
|
||||
`hidden` vs `staticText`, and violation-path mapping onto nested participant fields.
|
||||
- **Auth:** an explicit test that fires several concurrent requests against an expired
|
||||
access token and asserts *exactly one* refresh call is made, all requests succeed, and
|
||||
the rotated refresh token is persisted. Rotation makes this a correctness test, not a
|
||||
performance one.
|
||||
- **Integration:** screens against committed fixtures — deterministic, no backend needed.
|
||||
- **Contract:** types regenerate cleanly and compile against current fixtures. A failure
|
||||
here is an early warning of drift; treat it as urgent.
|
||||
- **End-to-end:** at the close of Increment 6, complete one booking through the app
|
||||
against a real backend. The backend team verifies the resulting upstream payload is
|
||||
**identical** to the same booking made through the web flow. That equivalence is the
|
||||
project's actual acceptance criterion — everything else is a proxy for it.
|
||||
@@ -0,0 +1,354 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user