Files
myep/docs/mobile-app/app-plan.md
T

381 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 02 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.