381 lines
17 KiB
Markdown
381 lines
17 KiB
Markdown
# 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.
|