docs: CMS API and calendars

docs/cms-api.md describes the endpoints TYPO3 serves to MyE&P and the
chatbot (/api/hotel, /api/product, /api/calendar-hotels,
/api/content/*): key, parameters, responses, errors. It is the other
side of MyE&P's API consumer guide.

docs/calendars.md covers the price calendar, the Belegungskalender and
the iCal feed: which product gets which, the MyE&P endpoints behind
them, caching, troubleshooting, and the rule that a product's calendar
hotel must carry the BusPro code, not a group house's CMS code (which
broke three example trips).

The README replaces the TYPO3 base distribution boilerplate and links
both. The plugin renaming SQL in ep_products' README is obsolete since
the migration. The cmsApiKey description names /api/calendar-hotels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5.5 committed 2026-10-29 15:37:36 +01:00
1 parent bf9afd143d
commit 2bd663a9bf
5 files changed
+234 -73

No files matched your search

+16 -55
View File
@@ -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="[email protected]" \
--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
+116
View File
@@ -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/<hotel>.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_<code>`. 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`).
+101
View File
@@ -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: <key>` (preferred, query strings end up in access logs), or
- query parameter `?key=<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=<code>`
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=<code>[&hotel=<code>]`
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=<type>&uids=<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`.
-17
View File
@@ -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';
```
+1 -1
View File
@@ -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)