Sperren: Betriebsurlaub aus dem Kalender

Der einzige Schreibweg der Stammdaten und die einzige Löschoperation der ganzen API — mit der Zahl, die verhindert, dass ein Abgleich still den Verkauf abschaltet.

Für Entwickler

Voraussetzungen

  • Recht `tables:read` zum Lesen, `tables:write` zum Anlegen und Löschen
  • Plan-Merkmal „Tischplan"

DIESER SCHREIBWEG EXISTIERT WEGEN EINES EINZIGEN ANWENDUNGSFALLS: der Wirt führt seinen Urlaub in einem Kalender oder in der Personalplanung, vergisst ihn in TacticTable — und die Buchungsstrecke nimmt Reservierungen für einen geschlossenen Tag an. Alle anderen Konfigurationsschreibwege (Öffnungszeiten, Bereiche, Tische, Speisekarte) bleiben bewusst draussen: sie verdoppeln die Schreibfläche, und ihre Automatisierung ist kein dringender Bedarf.

VIER ARTEN, EINE LISTE. `day` sperrt einen Tag ganz oder von einer Uhrzeit bis zu einer anderen. `timeSlot` sperrt ein Zeitfenster. `partySize` sperrt einen Personenzahlbereich (etwa Gruppen ab acht am Silvesterabend). `area` sperrt einen Bereich für einen Tag. Technisch sind das vier Tabellen — für den Aufrufer ist es EIN Begriff, und deshalb eine Liste mit einem Zeiger.

DIE KENNUNG TRÄGT DIE ART ALS PRÄFIX: `day_…`, `slot_…`, `party_…`, `area_…`. So weiss `DELETE /api/v1/blocks/{blockId}`, welche Tabelle gemeint ist, ohne dass Sie die Art als zweiten Parameter mitführen müssen. Ein unbekanntes Präfix, eine fremde und eine erfundene Kennung ergeben ALLE 404 — aus dem Unterschied liesse sich sonst ablesen, welche Zeilen es gibt.

`date` IST EIN KALENDERTAG UND WIRD HART GEPRÜFT. `"2026-12-24T00:00:00.000Z"` wird ABGELEHNT und nicht auf zehn Zeichen gekürzt. Genau diese Nachsicht hat die Sperren-Seite im Dashboard schon einmal gekostet: die Oberfläche schickte einen Zeitstempel, jemand schnitt ihn ab, und östlich von Greenwich war der gesperrte Tag der falsche. Auch `2026-02-30` wird abgewiesen — den Tag gibt es nicht, und `new Date()` rollte ihn still auf den 2. März weiter.

DIE ANTWORT NENNT `affectedReservations`. Das ist die Zahl der lebenden Reservierungen (`PENDING`, `CONFIRMED`, `OPTION`), die an diesem Tag bereits stehen. Ein Kalenderabgleich, der aus einem falsch benannten Termin den 24. Dezember sperrt, bekommt so „47 betroffen" zurück und kann Alarm schlagen — statt still den Online-Verkauf abzuschalten. Es ist NUR die Zahl, nie eine Zeile: der Schlüssel steht hier auf `tables:write`, nicht auf `reservations:read`.

`Idempotency-Key` IST BEIM ANLEGEN PFLICHT. Ohne ihn erzeugt ein Netzwiederholversuch eine zweite Sperre; die ist fachlich harmlos, aber der Abgleich sieht beim nächsten Lauf eine Zeile, die er nicht kennt, und legt eine dritte an.

DAS LÖSCHEN IST DIE EINZIGE LÖSCHOPERATION DER GANZEN API. Alles andere wird umgestuft (Reservierungen über den Status) oder anonymisiert (Gäste). Eine Sperre ist klein, eindeutig und jederzeit neu anlegbar — und der Kalenderabgleich braucht sie, weil ein gelöschter Kalendereintrag sonst eine Sperre hinterliesse, die nie wieder verschwindet. Beim Löschen ist der Idempotenzschlüssel freiwillig, aber nützlich: ohne ihn beantwortet die zweite Zustellung ein 404, und ein Abgleich protokolliert eine Störung, die keine war.

ES GIBT KEIN `updatedSince`. Keines der vier Modelle hat eine Änderungsspalte, und eine der vier nicht einmal eine Anlagespalte — ein Zeitfilter würde die Bereichssperren stillschweigend unterschlagen. Sperren sind unveränderlich (anlegen und löschen, kein Ändern); der Abgleich liest sein Zeitfenster über `from`/`to` schlicht neu.

Die Routen

GET/api/v1/blocks

Alle Sperren in einem Zeitfenster, über alle vier Arten hinweg.

Rechte

tables:read

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Abfrageparameter

NameTypBedeutung
fromYYYY-MM-DDErster Tag, einschliesslich. Ein Zeitstempel wird abgewiesen.
toYYYY-MM-DDLetzter Tag, einschliesslich. Darf nicht vor `from` liegen.
kindKommaliste aus day, timeSlot, partySize, areaNur diese Arten. Ohne Angabe: alle vier.
limitinteger 1–200Vorgabe: 50Zeilen je Seite.
cursorundurchsichtiger Zeiger`nextCursor` der vorigen Antwort.
includeTotaltrue | falseVorgabe: falseErgänzt `total` — hier vier Zählungen statt einer.

Mögliche Fehler

  • validation`to` liegt vor `from`, ein Datum ist ein Zeitstempel oder gibt es nicht, ein Parameter ist unbekannt oder steht doppelt.
  • forbiddenDem Schlüssel fehlt `tables:read`.
  • plan_upgrade_requiredDer Plan enthält den Tischplan nicht.
  • Sortiert nach `date`, dann Art, dann `id`. `createdAt` ist bei `kind: "area"` immer `null` — dieses Modell hat keine Anlagespalte, und ein erfundener Zeitpunkt wäre eine Lüge.

POST/api/v1/blocks

Eine Sperre anlegen — der Weg, auf dem ein Kalenderabgleich den Betriebsurlaub in TacticTable bringt.

Rechte

tables:write

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Kopfzeilen

NameTypBedeutung
Idempotency-KeyPflichtstring bis 255 ZeichenPFLICHT. Ohne ihn legt ein Netzwiederholversuch die Sperre ein zweites Mal an.
X-TacticTable-Dry-Run1Meldet `affectedReservations`, ohne zu sperren — und verbraucht dabei keinen Idempotenzschlüssel.

Felder im Rumpf

NameTypBedeutung
kindPflichtday | timeSlot | partySize | areaDie Art der Sperre. Sie bestimmt, welche weiteren Felder erlaubt und nötig sind.
datePflichtYYYY-MM-DDDer gesperrte Kalendertag. Ein Zeitstempel wird abgewiesen, nicht gekürzt.
reasonstring bis 500 Zeichen oder nullGrund, der im Dashboard neben der Sperre steht („Betriebsurlaub", „Hochzeit").
isFullDayboolean (nur bei kind=day)Vorgabe: true`true` sperrt den ganzen Tag. Bei `false` sind `blockedFrom` und `blockedTo` PFLICHT — sonst prüft die Verfügbarkeitsrechnung beide Werte und lässt alles durch.
blockedFromHH:mm (nur bei kind=day)Beginn der Teilsperre.
blockedToHH:mm (nur bei kind=day)Ende der Teilsperre. Gleich `blockedFrom` ist 400 — ein leeres Intervall sperrt nichts und stünde trotzdem in der Liste.
timeFromPflichtHH:mm (nur bei kind=timeSlot)Beginn des gesperrten Fensters.
timeToPflichtHH:mm (nur bei kind=timeSlot)Ende des gesperrten Fensters. Gleich `timeFrom` ist 400.
minPartySizePflichtinteger 1–999 (nur bei kind=partySize)Kleinste gesperrte Personenzahl.
maxPartySizePflichtinteger 1–999 (nur bei kind=partySize)Grösste gesperrte Personenzahl. Kleiner als `minPartySize` ist 400.
areaIdPflichtUUID (nur bei kind=area)Der gesperrte Bereich. Eine fremde Kennung ergibt 404 — nicht 400, damit „fremd" und „gibt es nicht" ununterscheidbar bleiben.

Mögliche Fehler

  • validation`Idempotency-Key` fehlt, ein Feld passt nicht zur gewählten Art, ein Intervall ist leer, `date` ist ein Zeitstempel, oder der Rumpf enthält ein unbekanntes Feld.
  • nothing_to_writeEs wurde kein Rumpf mitgeschickt.
  • not_foundDer genannte `areaId` gehört nicht zu diesem Betrieb.
  • forbiddenDem Schlüssel fehlt `tables:write`.
  • idempotency_key_reuseDerselbe Schlüssel wurde bereits für eine andere Anfrage benutzt.
  • Antwortet mit 201 und `{ "data": { … }, "affectedReservations": <Zahl> }`.
  • Der Vorgang wird im Aktivitätsprotokoll des Betriebs festgehalten — samt der Kennung des Schlüssels, nie des Tokens.

DELETE/api/v1/blocks/{blockId}

Eine Sperre aufheben — etwa weil der Kalendereintrag gelöscht wurde.

Rechte

tables:write

Plan-Merkmal reservations_tableplan — fehlt es dem Betrieb, antwortet die Route mit 402 plan_upgrade_required.

Pfad

NameTypBedeutung
{blockId}Pflichtstring mit ArtpräfixDie Kennung, wie die Liste sie ausliefert: `day_…`, `slot_…`, `party_…` oder `area_…`. Unbekanntes Präfix, fremde und erfundene Kennung ergeben alle 404.

Kopfzeilen

NameTypBedeutung
Idempotency-Keystring bis 255 ZeichenFreiwillig. Mit ihm bekommt die zweite Zustellung dieselbe 200 statt eines 404, den ein Abgleich als Störung protokollieren würde.
X-TacticTable-Dry-Run1Prüft nur, ob es die Sperre gibt. Antwortet mit `{ "data": { "id": …, "deleted": false }, "dryRun": true }`.

Mögliche Fehler

  • not_foundDiese Sperre gibt es für diesen Betrieb nicht — oder das Präfix ist unbekannt.
  • forbiddenDem Schlüssel fehlt `tables:write`.
  • idempotency_key_reuseDerselbe Schlüssel wurde bereits für eine andere Anfrage benutzt.
  • Antwortet mit 200 und `{ "data": { "id": …, "deleted": true } }`.

Codebeispiele

curl — Sperren im Zeitraum lesen
curl
curl -sS -G https://tactictable.com/api/v1/blocks \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  --data-urlencode "from=2026-12-01" \  --data-urlencode "to=2026-12-31" \  --data-urlencode "kind=day,area"
Ohne `kind` kommen alle vier Arten. Ein Kalenderabgleich, der nur ganze Tage verwaltet, filtert auf `day` und lässt die übrigen in Ruhe.
Antwort 200 — GET /api/v1/blocks
JSON
{  "data": [    {      "id": "day_7c0f4a19-2f6b-4f10-a1d8-8e5c3b90f412",      "kind": "day",      "date": "2026-12-24",      "reason": "Betriebsurlaub",      "isFullDay": true,      "blockedFrom": null,      "blockedTo": null,      "timeFrom": null,      "timeTo": null,      "minPartySize": null,      "maxPartySize": null,      "areaId": null,      "timezone": "Europe/Vienna",      "createdAt": "2026-09-14T08:02:19.000Z"    },    {      "id": "area_2b5d9e70-7a31-4cb2-9d64-0f1a8c34e5d7",      "kind": "area",      "date": "2026-12-31",      "reason": "Terrasse gesperrt (Silvester)",      "isFullDay": null,      "blockedFrom": null,      "blockedTo": null,      "timeFrom": null,      "timeTo": null,      "minPartySize": null,      "maxPartySize": null,      "areaId": "100baca4-f01d-5c3c-8947-65e5a5061c6b",      "timezone": "Europe/Vienna",      "createdAt": null    }  ],  "nextCursor": null,  "hasMore": false,  "limit": 50}
Alle vier Arten teilen sich eine Form; die Felder der jeweils anderen Arten sind `null`. `createdAt: null` bei `kind: "area"` ist ehrlich und kein Fehler — dieses Modell hat keine Anlagespalte.
curl — erst prüfen, dann sperren
curl
# 1. Pruefmodus: wie viele Reservierungen traefe die Sperre?curl -sS -X POST https://tactictable.com/api/v1/blocks \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "X-TacticTable-Dry-Run: 1" \  -d '{"kind": "day", "date": "2026-12-24", "reason": "Betriebsurlaub"}' # 2. Wenn die Zahl stimmt: wirklich sperren.curl -sS -X POST https://tactictable.com/api/v1/blocks \  -H "Authorization: Bearer tt_live_if3u5vp6maf67xmw_PT1xP8EQF7reKUtLcZ7v9z5qaRKmoxeHmd27JpaDvfM" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: 4d8c1b62-5f97-4a03-9e21-7b6d0f3a8c14" \  -d '{"kind": "day", "date": "2026-12-24", "reason": "Betriebsurlaub"}'
Der Prüfmodus verbraucht keinen Idempotenzschlüssel — Sie dürfen denselben Wert danach für den echten Aufruf nehmen.
Antwort 201 — Sperre angelegt
JSON
{  "data": {    "id": "day_7c0f4a19-2f6b-4f10-a1d8-8e5c3b90f412",    "kind": "day",    "date": "2026-12-24",    "reason": "Betriebsurlaub",    "isFullDay": true,    "blockedFrom": null,    "blockedTo": null,    "timeFrom": null,    "timeTo": null,    "minPartySize": null,    "maxPartySize": null,    "areaId": null,    "timezone": "Europe/Vienna",    "createdAt": "2026-09-14T08:02:19.000Z"  },  "affectedReservations": 47}
SIEBENUNDVIERZIG bestehende Reservierungen an diesem Tag. Ein Abgleich, der diese Zahl nicht prüft, schaltet den Online-Verkauf ab und lässt siebenundvierzig Gäste im Glauben, ihr Tisch stehe.
TypeScript — Kalenderabgleich, der nicht blind sperrt
TypeScript
import { randomUUID } from 'node:crypto' interface Sperre {    id: string    kind: 'day' | 'timeSlot' | 'partySize' | 'area'    date: string    reason: string | null} const SCHWELLE = 5 /** Prueft erst, sperrt dann — und meldet, statt blind abzuschalten. */export async function urlaubSperren(    token: string,    tag: string,    grund: string,): Promise<{ gesperrt: boolean; betroffen: number }> {    const rumpf = JSON.stringify({ kind: 'day', date: tag, reason: grund })    const kopf = {        Authorization: 'Bearer ' + token,        'Content-Type': 'application/json',    }     // 1. Probelauf. Er schreibt nichts und verbraucht keinen Schluessel.    const probe = await fetch('https://tactictable.com/api/v1/blocks', {        method: 'POST',        headers: { ...kopf, 'X-TacticTable-Dry-Run': '1' },        body: rumpf,    })    const probeDaten = await probe.json()    if (!probe.ok) throw new Error(probeDaten.error + ': ' + probeDaten.message)     const betroffen: number = probeDaten.affectedReservations ?? 0    if (betroffen > SCHWELLE) {        // Nicht sperren, sondern melden. Ein falsch benannter Kalendereintrag        // darf nicht den Online-Verkauf eines vollen Tages abschalten.        return { gesperrt: false, betroffen }    }     // 2. Wirklich sperren. Idempotency-Key ist hier PFLICHT.    const echt = await fetch('https://tactictable.com/api/v1/blocks', {        method: 'POST',        headers: { ...kopf, 'Idempotency-Key': randomUUID() },        body: rumpf,    })    const daten = await echt.json()    if (!echt.ok) throw new Error(daten.error + ': ' + daten.message)     return { gesperrt: true, betroffen: daten.affectedReservations ?? 0 }} /** Eine Sperre aufheben. Die Kennung traegt ihr Artpraefix bereits. */export async function sperreAufheben(token: string, blockId: string): Promise<boolean> {    const antwort = await fetch('https://tactictable.com/api/v1/blocks/' + encodeURIComponent(blockId), {        method: 'DELETE',        headers: { Authorization: 'Bearer ' + token, 'Idempotency-Key': randomUUID() },    })    if (antwort.status === 404) return false    if (!antwort.ok) throw new Error('HTTP ' + antwort.status)    return true} export function istTagessperre(s: Sperre): boolean {    return s.kind === 'day'}
Die Schwelle ist der ganze Punkt dieses Beispiels. Ein Abgleich, der ohne Prüfung sperrt, ist genau der Fehler, gegen den `affectedReservations` gebaut wurde.
Python — Kalendertage abgleichen
Python
import jsonimport uuid import requests  def bestehende_tagessperren(sitzung: requests.Session, von: str, bis: str) -> dict:    """Kalendertag -> Sperr-Kennung, nur fuer ganze Tage."""    antwort = sitzung.get(        "https://tactictable.com/api/v1/blocks",        params={"from": von, "to": bis, "kind": "day", "limit": 200},        timeout=30,    )    antwort.raise_for_status()     return {        s["date"]: s["id"]        for s in antwort.json()["data"]        if s["kind"] == "day" and s["isFullDay"]    }  def sperre_anlegen(sitzung: requests.Session, tag: str, grund: str) -> dict:    antwort = sitzung.post(        "https://tactictable.com/api/v1/blocks",        data=json.dumps({"kind": "day", "date": tag, "reason": grund}),        headers={            "Content-Type": "application/json",            # PFLICHT bei dieser Route.            "Idempotency-Key": str(uuid.uuid4()),        },        timeout=30,    )    if antwort.status_code != 201:        rumpf = antwort.json()        raise RuntimeError("{}: {}".format(rumpf["error"], rumpf["message"]))    return antwort.json()  def sperre_loeschen(sitzung: requests.Session, block_id: str) -> bool:    antwort = sitzung.delete(        "https://tactictable.com/api/v1/blocks/" + block_id,        headers={"Idempotency-Key": str(uuid.uuid4())},        timeout=30,    )    # 404 heisst: gibt es nicht mehr. Fuer einen Abgleich ist das das Ziel.    return antwort.status_code in (200, 404)  # ACHTUNG: "2026-12-24" — NIE einen Zeitstempel schicken. Er wird abgelehnt# und nicht gekuerzt, weil er eine Zeitzone mittruege und den Tag verschoebe.
Der 404 beim Löschen wird als Erfolg gewertet: der Abgleich will den Zustand „diese Sperre gibt es nicht", und den hat er dann erreicht.
Antwort 400 — ein Zeitstempel statt eines Kalendertags
JSON
{  "error": "validation",  "message": "Die Anfrage wurde nicht angenommen.",  "issues": [    {      "path": "date",      "code": "invalid_string",      "message": "Kalendertag als JJJJ-MM-TT angeben (z. B. \"2026-12-24\"). Ein Zeitstempel wird nicht angenommen: er truege eine Zeitzone mit und verschoebe den Tag."    }  ],  "docs": "https://tactictable.com/dokumentation/api/fehler/validation",  "requestId": "req_8f31c0a94d2b47e6ba05"}
Die Meldung sagt nicht nur WAS falsch ist, sondern WARUM die Nachsicht fehlt. Wer einen Zeitstempel schickt, hat eine falsche Vorstellung vom Kalendertag — und die kostet später einen falsch gesperrten Tag.