diff --git a/README.md b/README.md index ad2c471..cea20d2 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,26 @@ -# TYPO3 CMS Base Distribution +# E&P Reisen websites (TYPO3 13) -Get going quickly with TYPO3 CMS. +TYPO3 13 LTS for www.ep-reisen.de and its subsites (ski-boarderweek, snowzone, semesterende-skireisen, +ski-wochenenden, …). Products, hotels, regions and dates live in `packages/ep_products`, templates +and assets in `packages/ep_theme`. Dates, prices and availability come from **MyE&P** (`EP/myep`), +which in turn reads bpn-connect. -## Prerequisites +## Local setup -* PHP 8.2 -* [Composer](https://getcomposer.org/download/) +DDEV project: `ddev start`, then `ddev composer install` and `ddev npm install && ddev npm run build`. +Rebuild the assets after template changes: Tailwind purges classes it does not find in the +templates. -## Quickstart +## Documentation -* `composer create-project typo3/cms-base-distribution project-name ^13` -* `cd project-name` +- [docs/cms-api.md](docs/cms-api.md): the API TYPO3 serves to MyE&P and the chatbot + (`/api/hotel`, `/api/product`, `/api/calendar-hotels`, `/api/content/*`). +- [docs/calendars.md](docs/calendars.md): price calendar, Belegungskalender and iCal feed: where + the data comes from, caching, and what to check when a calendar does not load. -Note that this distribution installs most, but not all of the TYPO3 CMS core extensions. -Depending on your need you might also want to install other TYPO3 extensions from -[packagist.org](https://packagist.org/?type=typo3-cms-framework). +## Deployment -### Setup - -To start an interactive installation, you can do so by executing the following -command and then follow the wizard: - -```bash -composer exec typo3 setup -``` - -### Setup unattended (optional) - -If you're a more advanced user, you might want to leverage the unattended installation. -To do this, you need to execute the following command and substitute the arguments -with your own environment configuration. - -```bash -export TYPO3_SETUP_ADMIN_PASSWORD=$(tr -dc "_A-Za-z0-9#=$()/" < /dev/urandom | head -c24) -composer exec -- typo3 setup \ - --no-interaction \ - --server-type=other \ - --driver=sqlite \ - --admin-username=admin \ - --admin-email="info@example.com" \ - --project-name="My TYPO3 Project" \ - --create-site="http://localhost:8000/" -echo "Admin password: ${TYPO3_SETUP_ADMIN_PASSWORD}" -``` - -### Development server - -While it's advised to use a more sophisticated web server such as -Apache 2 or Nginx, you can instantly run the project by using PHPs` built-in -[web server](https://secure.php.net/manual/en/features.commandline.webserver.php). - -* `TYPO3_CONTEXT=Development php -S localhost:8000 -t public` -* open your browser at "http://localhost:8000" - -Please be aware that the built-in web server is single threaded and only meant -to be used for development. - -## Next steps - -* [Getting Started with TYPO3](https://docs.typo3.org/permalink/t3start:start) -* [Create a Site Package](https://docs.typo3.org/permalink/t3sitepackage:start) +`deploy.php` (Deployer). A deploy runs `extension:setup`, resets OPcache and flushes all caches. ## License diff --git a/docs/calendars.md b/docs/calendars.md new file mode 100644 index 0000000..4e57f57 --- /dev/null +++ b/docs/calendars.md @@ -0,0 +1,116 @@ +# Availability and prices on product pages + +TYPO3 shows three things built from contingent data. None of them talks to bpn-connect directly: +all go through **MyE&P**, which keeps a local snapshot of bpn-connect's availability and holds the +group house prices. + +| What | Where | Shown for | MyE&P endpoint | +|---|---|---|---| +| **Price calendar** + price tables | product detail, tab "Termine/Preise" | products with a calendar hotel and season **summer** (`s`) | `contingents/calendar`, `contingents/prices` | +| **Belegungskalender** (availability calendar) | product detail, sidebar | products with a calendar hotel and season **not summer** | `contingents/availability` (+ `contingents/calendar` for the months to leave out) | +| **iCal feed** for booking portals | `https://www.ep-reisen.de/contingents/.ical` | every hotel | `contingents/availability` | + +So every product with a calendar hotel shows exactly one calendar. The MyE&P side is documented in +its `docs/api-consumer-guide.md` (`EP/myep`). + +## The calendar hotel field + +A product's **calendar hotel** (`calendar_hotel`) decides whose availability and prices it shows. +TYPO3 sends that hotel's **code** to MyE&P, which only understands **BusPro calendar codes** (the +codes bpn-connect knows, e.g. `DGS`, `LPJ`, `DKS`). + +Most hotel records carry the BusPro code. Group houses also have **content records** of their own +(`DKSGRP`, `LPJGRP`, `SERASCSO`, …) whose code is the **CMS code** MyE&P uses to fetch content via +`/api/hotel`. These must **not** be used as calendar hotel: MyE&P and bpn-connect do not know +them, and the calendar shows its error message. This broke three example trips until 2026-10-29. + +| Use as calendar hotel | Not | +|---|---| +| `DKS` Sportclub Schweizerhaus (uid 193) | `DKSGRP` Davos Klosters - Gruppenhaus Schweizerhaus | +| `LPJ` Sportclub Jenatsch (uid 37) | `LPJGRP` Lenzerheide - Gruppenhaus Jenatsch | + +`/api/calendar-hotels` lists all calendar hotels in use; every code in it should be a BusPro code. +A hidden hotel record counts as no calendar hotel: the product shows no calendar and the code is +not in the list (e.g. `ASB` Sportclub Bader). + +## Price calendar (summer) + +- `Product/Detail.html`, section `Dates`, partial `Product/PriceCalendar`; + `AjaxCalendarController::priceCalendarAction` → `contingents/calendar` (status and price per + day), `priceTableAction` → `contingents/prices` for the current and the next year (loaded when + scrolled into view). +- Only works for hotels that are an **accommodation with prices** in MyE&P (summer group houses). + +## Belegungskalender (winter) + +- `Product/Detail.html`, section `AvailabilityCalendar`, loaded by htmx when it scrolls into view + (`intersect once`, so the hidden copy of the sidebar on the other breakpoint does not load); + `AjaxCalendarController::availabilityAction`, template `AjaxCalendar/Availability.html`, + month select `calendar_controller.js`. +- All months in one response; the select only switches between them. 12 months from the current + one, extended to the last month MyE&P has data for. +- **Months with prices are left out**: they are summer, covered by the price calendar. Only summer + months have prices today; that is the whole distinction. If `contingents/calendar` fails, all + months are shown (not cached). +- Colours as on TYPO3 10: green available (`OK`, and days without data), yellow on request + (`ON_REQUEST`), red not available (`BLOCKED`), past days grey; a status change from the previous + day is drawn as a diagonal. The `availability-*` classes are built in PHP, so they are safelisted + in `tailwind.config.js`; without that Tailwind purges them. +- Works for every hotel MyE&P syncs, with or without an accommodation (see below). + +## iCal feed + +- `IcalController::contingentsAction`, `IcalService`; route `/contingents/{hotel}.ical` on the + `ep-reisen` site (`config/sites/ep-reisen/config.yaml`, page 9, type 1710), `{hotel}` is the + hotel's path segment. +- One `Unavailable` event per run of `BLOCKED` or `ON_REQUEST` days with the same status, as on + TYPO3 10. +- Never cached. When MyE&P has no availability it answers `503` with `Retry-After: 900` instead of + an empty calendar, which the portals would read as "every day free"; they keep their last import. + +## Which hotels MyE&P has availability for + +MyE&P syncs its snapshot from bpn-connect every 15 minutes during the day and hourly at night, for +the calendar codes of its accommodations **plus** every code in TYPO3's `/api/calendar-hotels` +(see [cms-api.md](cms-api.md)). So a hotel offered only in winter needs nothing in MyE&P: setting +it as calendar hotel of a visible product is enough; it is synced with the next run. + +`contingents/availability` answers: +- `400` for a code that is not synced (not an accommodation, not in TYPO3's list), +- `502` when the hotel's snapshot is missing or older than 6 hours (sync failing), +- `200` with one entry per known day otherwise. + +`contingents/calendar` and `contingents/prices` additionally need an accommodation with prices. + +MyE&P's `GET /api/contingents/status` lists every synced code with its source, prices, last sync +and last error: the first place to look when a calendar does not load. + +## Caching + +- The AJAX responses (price calendar, price tables, Belegungskalender) are page-cached on the AJAX + page for at most **1 hour** (`cache_period = 3600`) and never past midnight + (`cache_clearAtMidnight`, past days must turn grey). Browsers do not cache them + (`sendCacheHeaders = 0`). +- **Errors are not cached** (`disablePageCache()`): the next visitor triggers a new attempt. +- Every AJAX URL carries a cHash (required for `tx_epproducts_ajax`), so each hotel and year has its + own cache entry. +- Entries are tagged `ep_hotel_`. Nothing flushes them automatically; a deploy flushes all + caches. To refresh by hand: `vendor/bin/typo3 cache:flush --group pages`, or flush the tag via + the `CacheManager` for a single hotel. +- On top of that comes MyE&P's sync interval, so a calendar is at most about 1¼ hours behind + bpn-connect. + +## When a calendar does not load + +The message is "Der Kalender konnte aktuell nicht geladen werden."; the iCal feed answers `503`. + +| Cause | How to tell | Fix | +|---|---|---| +| Calendar hotel has a CMS code (`…GRP`, `…SO`) | code not BusPro; MyE&P `400`, bpn-connect `404` | set the hotel record with the BusPro code as calendar hotel | +| Code not synced by MyE&P | not in `contingents/status`; MyE&P `400` | check that the product and its calendar hotel are visible (then it is in `/api/calendar-hotels`) | +| Sync failing for the hotel | `contingents/status`: `available: false`, `lastError` | MyE&P / bpn-connect | +| MyE&P unreachable or its OAuth failing | all hotels fail; TYPO3 log `EP.EpProducts.MyEP.ApiClient` | MyE&P | +| Price calendar only: no accommodation or no prices | MyE&P `400` / empty | prices are maintained in MyE&P's Groups admin | + +TYPO3 logs every failed MyE&P call in `var/log/typo3_*.log` (component +`EP.EpProducts.MyEP.ApiClient`, for the iCal feed also `EP.EpProducts.Service.IcalService`). diff --git a/docs/cms-api.md b/docs/cms-api.md new file mode 100644 index 0000000..8426cf4 --- /dev/null +++ b/docs/cms-api.md @@ -0,0 +1,101 @@ +# CMS API + +Endpoints TYPO3 serves to other systems: **MyE&P** (hotel and product content, calendar hotels) +and the **chatbot** (content feed). They are answered by `EP\EpProducts\Middleware\ApiMiddleware` +before the page resolver, so they do not depend on a page and are never page-cached. + +The other side of these contracts is documented in MyE&P's `docs/api-consumer-guide.md` +(`EP/myep`). + +## Authentication + +All endpoints need the shared key `cmsApiKey` from the `ep_products` extension configuration +(Settings → Extension Configuration → ep_products, tab ContentFeed): + +- header `X-Api-Key: ` (preferred, query strings end up in access logs), or +- query parameter `?key=`. + +A missing or wrong key answers `403 {"message": "Invalid or missing api key"}`. An empty +`cmsApiKey` denies every request. MyE&P sends the key as `APP_CMS_API_KEY`; both sides must be +changed together. + +## `GET /api/hotel?hotel=` + +Content of one hotel, for MyE&P's group house pages and booking flow. + +- `hotel`: the TYPO3 hotel code or one of its matchcode aliases. In MyE&P this is the + accommodation's **CMS code** (e.g. `DGSGRP`), which can differ from its calendar code. +- `400` without `hotel`, `404 {"message": "Hotel not found"}` for an unknown code. + +```json +{ + "name": "…", "code": "…", + "description": "…", "features": "…", "room_types": "…", + "additional_information": "…", "address": "…", + "images": {"original": [{"url": "…", "alt": "…", "title": "…"}], "resized": […]}, + "icons": […], + "region": {"name": "…", "latitude": …, "longitude": …, "webcam": "…", "ski_area": "…", + "ski_area_extended": "…", "description": "…", "news": "…", "length": …, + "altitude": …, "lifts": …, "images": {…}, "region_maps": [{"url": "…", "alt": "…", "title": "…"}]} +} +``` + +## `GET /api/product?product=[&hotel=]` + +Content of one product, optionally with a hotel. + +- `product`: the product code. `400` without it, `404 {"message": "Product not found"}` for an + unknown code. +- `hotel` (optional): adds `hotel` with the same content as `/api/hotel`. + +```json +{ + "product": {"name": "…", "code": "…", "images": {…}}, + "country": {"name": "…"}, + "region": {…}, + "city": {"name": "…"}, + "hotel": {…} +} +``` + +## `GET /api/calendar-hotels` + +The calendar hotel of every visible product: the hotel codes whose availability TYPO3 shows +(iCal feed, availability calendar, price calendar). MyE&P syncs its contingent snapshot for +these codes in addition to its accommodations, so hotels offered only in winter, which have no +accommodation in MyE&P, have availability too. See [calendars.md](calendars.md). + +- Only products and hotels that are not hidden or deleted; hotels without a code are left out. +- Sorted by code; products by uid. Names have line breaks collapsed. +- `season`: `"s"` (summer), `"w"` (winter) or `null`. + +```json +[ + {"code": "DKI", "name": "Sportclub Schwendi", "products": [ + {"uid": 155, "name": "Sportclub Schwendi", "season": "w"}, + {"uid": 362, "name": "Gruppenhaus Schwendi", "season": "s"} + ]} +] +``` + +Every code in this list should be a **BusPro calendar code**. A group house's content record +(`…GRP`, `…SO`) set as a product's calendar hotel puts its CMS code in here, which bpn-connect does +not know (see [calendars.md](calendars.md#the-calendar-hotel-field)). + +## `GET /api/content/manifest` and `GET /api/content/records?type=&uids=` + +The content feed for the chatbot (`EP\EpProducts\ContentFeed\ContentFeedApi`). + +- `manifest`: everything the feed publishes (pages of the configured sites and records), with a + modification timestamp per item. Cached for 10 minutes. +- `records`: up to 100 records of one type per request. `type` is one of `product`, `hotel`, + `region`, `country`, `city`, `concept`, `news`; `uids` comma-separated. Uids that are no longer + eligible are left out, not answered with an error (the consumer reads them as deleted). +- `400` for an unknown type, no uids or more than 100 uids; `404` for other paths. +- `503` with `Retry-After: 3600` when the feed is switched off (`contentFeedEnabled = 0`, the kill + switch for when the host is struggling), `503` when `contentFeedSites` is empty. The consumer + keeps what it has on a 503; nothing is deleted on the chatbot side. + +Configuration (extension configuration `ep_products`, tab ContentFeed): `contentFeedEnabled`, +`contentFeedSites`, `contentFeedLanguages`, `contentFeedExcludedPages`, +`contentFeedNewsDetailPages`. diff --git a/packages/ep_products/README.md b/packages/ep_products/README.md deleted file mode 100644 index 714800b..0000000 --- a/packages/ep_products/README.md +++ /dev/null @@ -1,17 +0,0 @@ -Queries for plugin renaming -=========================== - -```mysql -update tt_content set list_type='epproducts_searchbar' where list_type='epproducts_pi1'; -update tt_content set list_type='epproducts_product_detail' where list_type='epproducts_pi3'; -update tt_content set list_type='epproducts_searchresult' where list_type='epproducts_pi2'; -update tt_content set list_type='epproducts_product_teasergroup' where list_type='epproducts_pi4'; -update tt_content set list_type='epproducts_country_detail' where list_type='epproducts_pi5'; -update tt_content set list_type='epproducts_region_teasergroup' where list_type='epproducts_pi6'; -update tt_content set list_type='epproducts_region_detail' where list_type='epproducts_pi10'; -update tt_content set list_type='epproducts_city_teasergroup' where list_type='epproducts_pi7'; -update tt_content set list_type='epproducts_city_detail' where list_type='epproducts_pi11'; -update tt_content set list_type='epproducts_hotel_teasergroup' where list_type='epproducts_pi8'; -update tt_content set list_type='epproducts_hotel_detail' where list_type='epproducts_pi12'; -update tt_content set list_type='epproducts_faq' where list_type='epproducts_pi9'; -``` diff --git a/packages/ep_products/ext_conf_template.txt b/packages/ep_products/ext_conf_template.txt index 353f846..f239949 100644 --- a/packages/ep_products/ext_conf_template.txt +++ b/packages/ep_products/ext_conf_template.txt @@ -40,7 +40,7 @@ bpnConnectApiTimeout = 5 # cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited) bpnConnectApiMaxDuration = 15 -# cat=ContentFeed; type=string; label=API key for the CMS backchannel endpoints used by MyE&P (/api/hotel, /api/product, /api/content/*); sent as X-Api-Key or ?key=. Empty denies every request. +# cat=ContentFeed; type=string; label=API key for the CMS backchannel endpoints (/api/hotel, /api/product, /api/calendar-hotels for MyE&P; /api/content/* for the chatbot; see docs/cms-api.md); sent as X-Api-Key or ?key=. Empty denies every request. cmsApiKey = # cat=ContentFeed; type=boolean; label=Serve the chatbot content feed (off: all feed endpoints answer 503)