Availability engine + booking API: create/cancel/reschedule (#16)

Adds the plumbing that makes "can a customer actually get booked" true
end to end at the API layer, on top of #15's schema/tenancy layer.

- resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes
  on resources -- config #15 didn't include but #16 depends on.
- availability.py: pure slot-generation function, correct across a
  Europe/Berlin DST transition (tested both directions).
- booking_api.py: JSON blueprint for slot listing, booking creation
  (auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule,
  registered into app.py.
- booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings
  query for buffer/slot validation, and a read-only client lookup.

A true concurrent-threads test (not just sequential requests) surfaced a
real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation
when two overlapping inserts race the exclusion constraint directly, which
went uncaught and would have 500'd instead of giving the clean 4xx the
ticket requires -- now caught alongside ExclusionViolation.

Also fixed: reschedule used the request's raw UTC offset to pick the
business day instead of the client's own timezone (could pick the wrong
day's hours/bookings near local midnight); the cancel/reschedule JWT no
longer falls back to reusing CRM_API_TOKEN as its signing secret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:52:33 +02:00
parent b5c0fc8a5a
commit 2f6e0c1459
11 changed files with 789 additions and 15 deletions
+22 -6
View File
@@ -104,12 +104,28 @@ CREATE TABLE IF NOT EXISTS credentials (
-- tables the owner-login tickets build on. All access goes through
-- app/booking_db.py — see that module for the tenancy-safe data-access layer.
CREATE TABLE IF NOT EXISTS resources (
resource_id text PRIMARY KEY,
client_id text NOT NULL,
name text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
resource_id text PRIMARY KEY,
client_id text NOT NULL,
name text NOT NULL,
active boolean NOT NULL DEFAULT true,
min_notice_minutes integer NOT NULL DEFAULT 60,
max_advance_days integer NOT NULL DEFAULT 30,
buffer_minutes integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Per-resource weekly opening hours (#16): one open/close interval per
-- weekday (0=Monday .. 6=Sunday, matching Python's date.weekday()); no row
-- for a weekday means the resource is closed that day. Per-resource, not
-- per-client, since multi-resource clients may have staff with different
-- hours (#14).
CREATE TABLE IF NOT EXISTS resource_hours (
resource_id text NOT NULL,
weekday smallint NOT NULL CHECK (weekday BETWEEN 0 AND 6),
opens_at time NOT NULL,
closes_at time NOT NULL,
PRIMARY KEY (resource_id, weekday)
);
CREATE TABLE IF NOT EXISTS services (