From e6e6e041a910b6c10a85383c6a3a32fcdeac509c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Mon, 24 Aug 2026 09:34:33 +0200 Subject: [PATCH] docs: include previously ignored documentation files --- docs/PLAN-cancellation-statistics.md | 187 -------------- docs/api-consumer-guide.md | 354 +++++++++++++++++++++++++++ 2 files changed, 354 insertions(+), 187 deletions(-) delete mode 100644 docs/PLAN-cancellation-statistics.md create mode 100644 docs/api-consumer-guide.md diff --git a/docs/PLAN-cancellation-statistics.md b/docs/PLAN-cancellation-statistics.md deleted file mode 100644 index eacf0eb..0000000 --- a/docs/PLAN-cancellation-statistics.md +++ /dev/null @@ -1,187 +0,0 @@ -# Plan: Cancellation Statistics Feature - -## Objective - -Enable stakeholders to see how often dispositions and applications have been canceled by either the teamer or the office, per hotel and for a given date range. - ---- - -## Current State Analysis - -### Dispositions - Already Tracked - -The `Disposition` entity has comprehensive cancellation tracking: - -| Field | Type | Purpose | -|-------|------|---------| -| `status` | string | `'called_off'` when cancelled | -| `calledOffBy` | string (nullable) | `'teamer'` or `'office'` | -| `calledOffReason` | text (nullable) | Reason for cancellation | -| `updatedAt` | datetime | Timestamp of last change | - -**Constants:** -```php -public const STATUS_CALLED_OFF = 'called_off'; -public const CALLED_OFF_BY_TEAMER = 'teamer'; -public const CALLED_OFF_BY_OFFICE = 'office'; -``` - -**Cancellation entry points:** -1. Individual disposition cancellation via `Administrative/Disposition/CallOffController` - - Form requires `calledOffBy` and `calledOffReason` - - Dispatches `DispositionCalledOffEvent` - -2. Full assignment cancellation via `Administrative/Assignment/CallOffController` - - Sets all dispositions to `calledOffBy = 'office'` - - Dispatches `AssignmentCalledOffEvent` - -### Applications - Missing Tracking - -The `Application` entity lacks cancellation tracking: - -| Current Field | Issue | -|---------------|-------| -| `status` | Only `'rejected'` - no distinction between teamer withdrawal and office rejection | -| - | No `cancelledBy` field | -| - | No `cancellationReason` field | - -**Current rejection mechanisms:** -1. Manual rejection by office (no tracking of who) -2. Automatic rejection by `RejectApplicationListener` when disposition slots fill -3. Automatic removal by `InvalidateApplicationsListener` when teamer has overlapping confirmed dispositions - ---- - -## Pending Decision - -**Question for stakeholders:** Should automatic rejections be included in statistics? - -| Option | Description | Impact | -|--------|-------------|--------| -| **Only user-initiated** | Count only manual cancellations by teamer or office | Simpler model, clearer accountability | -| **Include automatic** | Also track system-initiated rejections | Full picture of lost applications, requires `'system'` category | - -**Status:** Awaiting stakeholder response - ---- - -## Proposed Changes - -### Application Entity - -**Option A: If only user-initiated cancellations count** - -Add new status and fields: -```php -public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated -public const STATUS_REJECTED = 'rejected'; // office-initiated (existing) - -private ?string $cancellationReason = null; -``` - -**Option B: If automatic rejections should be tracked** - -Add new status, fields, and constant: -```php -public const STATUS_WITHDRAWN = 'withdrawn'; // teamer-initiated -public const STATUS_REJECTED = 'rejected'; // office or system initiated - -public const CANCELLED_BY_TEAMER = 'teamer'; -public const CANCELLED_BY_OFFICE = 'office'; -public const CANCELLED_BY_SYSTEM = 'system'; - -private ?string $cancelledBy = null; -private ?string $cancellationReason = null; -``` - -### Database Migration - -Add columns to `application` table: -- `cancellation_reason` (LONGTEXT, nullable) -- Possibly `cancelled_by` (VARCHAR(64), nullable) if Option B - -### Repository Methods - -Add to `ApplicationRepository`: -```php -/** - * @return array - */ -public function getCancellationStatsByHotel( - \DateTimeImmutable $startDate, - \DateTimeImmutable $endDate -): array; -``` - -Add to `DispositionRepository`: -```php -/** - * @return array - */ -public function getCancellationStatsByHotel( - \DateTimeImmutable $startDate, - \DateTimeImmutable $endDate -): array; -``` - -**Date filtering:** Based on assignment travel dates (confirmed in discussion). - -### Update Listeners (if Option B) - -Modify `RejectApplicationListener` and `InvalidateApplicationsListener` to set `cancelledBy = 'system'`. - -### UI Changes - -Add withdrawal functionality for teamers (if not already present) that sets `STATUS_WITHDRAWN`. - ---- - -## Implementation Steps - -1. [ ] Finalize decision on automatic rejection tracking -2. [ ] Add new constants and fields to `Application` entity -3. [ ] Create database migration -4. [ ] Update `ApplicationRepository` with statistics methods -5. [ ] Update `DispositionRepository` with statistics methods -6. [ ] Update listeners if tracking automatic rejections -7. [ ] Add/update controllers for teamer withdrawal flow -8. [ ] Write tests for new repository methods -9. [ ] Run php-cs-fixer - ---- - -## Related Files - -### Entities -- `src/Entity/Application.php` -- `src/Entity/Disposition.php` -- `src/Entity/Assignment.php` - -### Repositories -- `src/Repository/ApplicationRepository.php` -- `src/Repository/DispositionRepository.php` - -### Controllers -- `src/Controller/Administrative/Disposition/CallOffController.php` -- `src/Controller/Administrative/Assignment/CallOffController.php` -- `src/Controller/Teamer/Disposition/DetailController.php` - -### Listeners -- `src/EventListener/InvalidateApplicationsListener.php` -- `src/EventListener/RejectApplicationListener.php` - -### Events -- `src/Event/DispositionCalledOffEvent.php` -- `src/Event/AssignmentCalledOffEvent.php` - -### Forms -- `src/Form/DispositionCallOffType.php` - ---- - -## Notes - -- Filter statistics by **assignment travel dates**, not cancellation timestamp -- Disposition tracking already works - only need repository query methods -- Application tracking requires entity changes and migration -- Consider whether teamer self-service withdrawal exists or needs to be added \ No newline at end of file diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md new file mode 100644 index 0000000..6edf1cf --- /dev/null +++ b/docs/api-consumer-guide.md @@ -0,0 +1,354 @@ +# API Consumer Guide + +This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it. + +## Overview + +A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes. + +## Authentication + +- Token endpoint: `POST /token` (unauthenticated). +- Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. +- Access token TTL: **10 minutes** — clients must refresh/re-request frequently. +- Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. +- Each granted scope maps to a role `ROLE_OAUTH2_` (e.g. scope `api` → `ROLE_OAUTH2_API`). +- Send the token as `Authorization: Bearer `. + +Scope required per endpoint: + +| Scope needed | Endpoints | +|---|---| +| `api` | everything except the two below | +| `profile` | `GET /api/crm-attributes` | +| `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) | + +`/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials. + +### Machine-to-machine example + +``` +POST /token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id=...&client_secret=...&scope=api +``` + +``` +GET /api/hotels +Authorization: Bearer eyJ0eXAi... +``` + +## Error conventions + +There is no single error envelope; three shapes occur: + +- `{"message": "..."}` — commonly with 404 / 400. +- `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code. +- `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter). + +Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. + +The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. + +--- + +## Endpoints + +### Reference data (BusProNet master data) + +#### `GET /api/hotels` — scope `api` + +List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`. + +#### `GET /api/hotels/{id}` — scope `api` + +Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown. + +#### `GET /api/countries` — scope `api` + +Array of `{id, name, token, nationality}`. + +#### `GET /api/products` — scope `api` + +Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**. + +#### `GET /api/last-update` — scope `api` + +Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown". + +--- + +### Pickups (bus boarding points) + +#### `GET /api/pickups` — scope `api` + +Array of `{id, code, city, postalCode, street}`. + +#### `GET /api/pickups/{id}` — scope `api` + +Single pickup by numeric id, same fields as the list. + +#### `GET /api/pickups-planning/{travelCode}` — scope `api` + +Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted: + +```json +[{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}] +``` + +`404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors. + +#### `POST /api/pickups-planning` — scope `api` + +Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys: + +```json +[ + { + "PlanungsArt": "HIN", + "Reisecode": "ABC123", + "Zustieg/Ausstieg Ort": "Köln", + "Zustieg/Ausstieg Strasse": "Hauptbahnhof", + "Zeit": "24.12.2026 07:30", + "BusNummer": "1" + } +] +``` + +Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`. + +This is a full overwrite, not a merge. + +--- + +### Travels + +#### `GET /api/travels` — scope `api` + +Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`. + +#### `GET /api/travels/{dateId}/{hotelId}` — scope `api` + +Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits. + +#### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api` + +Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists. + +Both variants accept optional query parameters: + +- `source=local` — force the local XML/snapshot data. +- `source=remote` — force a live upstream call. +- `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback. + +Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`. + +#### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api` + +Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**. + +--- + +### Contingents & accommodation prices + +#### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api` + +Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`. + +- `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code). +- `year`: required int, 1000–9999. + +Each row: + +```json +{ + "dateFrom": "2026-06-01", + "dateTo": "2026-06-14", + "season": "high|low|...|null", + "includedPax": 2, + "pricePerNight": 89.5, + "priceAdditionalPerson": 25.0, + "defaultPricePerNight": 99.0, + "defaultPriceAdditionalPerson": 30.0, + "currency": "EUR", + "type": "discount|null" +} +``` + +Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters. + +#### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api` + +Per-day availability enriched with prices. Both dates are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo). + +Each entry: + +```json +{ + "date": "2026-06-01", + "status": "Free|Blocked|...", + "type": "discount|null", + "pricePerNight": 89.5, + "defaultPricePerNight": 99.0, + "priceAdditionalPerson": 25.0, + "defaultPriceAdditionalPerson": 30.0, + "currency": "EUR", + "includedPax": 2, + "minNights": 2 +} +``` + +Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. + +Errors: `400 {"error":"Hotel not found for hotelCode."}`, `502 {"error":"Failed to fetch contingent data."}` when the upstream contingent service fails, `400` with violations for invalid parameters. + +--- + +### Accommodation bookings (group bookings) + +#### `GET /api/accommodation-bookings/{uuid}` — scope `api` + +Single booking by UUID. `404 {"message":"Not found"}` if unknown. + +```json +{ + "uuid": "...", + "status": "...", + "type": "...", + "dateFrom": "2026-06-01", + "dateTo": "2026-06-08", + "nights": 7, + "paxCount": 40, + "minorsCount": 12, + "childrenCount": 3, + "groupName": "...", + "acceptedAt": "2026-05-01T10:00:00+00:00", + "personalData": {}, + "accommodation": {}, + "boardService": {}, + "additionalServices": [], + "accommodationDiscount": null, + "boardServiceDiscount": null, + "additionalServicesDiscount": null, + "totalPrice": 123456, + "pricingCurrency": "EUR", + "pricingVersion": 3, + "priceBreakdown": {} +} +``` + +`totalPrice` and the `*Discount` fields are integer **minor units** (cents) — unlike the contingent endpoints. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. + +#### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api` + +Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once. + +--- + +### Contact form + +#### `POST /api/contactform` — scope `api` + +Creates or matches an address record in the upstream CRM. + +Request body (all fields optional strings; the upstream system decides what is required): + +```json +{ + "lastName": "...", "firstName": "...", "gender": "...", + "street": "...", "zipCode": "...", "city": "...", + "email": "...", "phone": "..." +} +``` + +- `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}` +- `409` — `{"success": false, "message": ""}` (upstream rejected/conflicted) +- `400` — `{"success": false, "message": ""}` (upstream call failed) +- `422` — malformed payload + +--- + +### Newsletter + +#### `GET /api/newsletters` — scope `api` + +The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below. + +#### `POST /api/newsletter-subscriptions` — scope `api` + +Request: + +```json +{ + "email": "user@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "listIds": [1, 2] +} +``` + +- `email`: required, strict email validation. +- `firstName` / `lastName`: optional, max 255 chars. +- `listIds`: required, at least one positive integer; every id must be one of the configured lists. + +Responses: + +- `200` — subscription completed, `state: "subscribed"`. +- `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`. +- Body in both cases: + +```json +{ + "success": true, + "email": "user@example.com", + "lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}], + "state": "subscribed|pending_confirmation|confirmation_requested", + "confirmationRequested": true +} +``` + +- `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations. +- `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry. + +--- + +### End-user endpoints (require a user-bound token) + +#### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`) + +OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes: + +- always: `email` +- scope `id`: `person_id`, `address_id` +- scope `roles`: `roles` (array; the implicit baseline role is stripped, only explicitly assigned roles are exported) +- scope `profile`: `profile` object: + +```json +{ + "first_name": "...", "last_name": "...", "title": "...", "salutation": "...", + "gender": "...", "date_of_birth": "1990-01-01", "nationality": "...", + "address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."}, + "communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true}, + "hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..." +} +``` + +Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity. + +`400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials. + +#### `GET /api/crm-attributes` — scope `profile` + +CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure. + +--- + +## Client implementation notes + +1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401. +2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint. +3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary. +4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries. +5. **Server-side caching** (1 h on products and the contingent calendar) means upstream changes may not be visible immediately. +6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.