From 2f6e0c1459e5fb4e4c4d8831ad1b8e4094505b6e Mon Sep 17 00:00:00 2001 From: rogalik27 Date: Thu, 23 Jul 2026 14:52:33 +0200 Subject: [PATCH] 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 --- backoffice/.env.example | 1 + backoffice/app/app.py | 2 + backoffice/app/availability.py | 55 +++++ backoffice/app/booking_api.py | 209 +++++++++++++++++ backoffice/app/booking_db.py | 90 +++++++- backoffice/app/requirements.txt | 3 + backoffice/app/tests/test_availability.py | 119 ++++++++++ backoffice/app/tests/test_booking_api.py | 262 ++++++++++++++++++++++ backoffice/app/tests/test_booking_db.py | 34 +++ backoffice/db/init.sql | 28 ++- backoffice/docker-compose.yml | 1 + 11 files changed, 789 insertions(+), 15 deletions(-) create mode 100644 backoffice/app/availability.py create mode 100644 backoffice/app/booking_api.py create mode 100644 backoffice/app/tests/test_availability.py create mode 100644 backoffice/app/tests/test_booking_api.py diff --git a/backoffice/.env.example b/backoffice/.env.example index 1ab33f0..4f1ab8d 100644 --- a/backoffice/.env.example +++ b/backoffice/.env.example @@ -1,4 +1,5 @@ # Copy to .env on the host and fill in. .env and secrets/ are gitignored. DB_PASSWORD=change-me-strong CRM_API_TOKEN=change-me-long-random +BOOKING_TOKEN_SECRET=change-me-long-random-too SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8 diff --git a/backoffice/app/app.py b/backoffice/app/app.py index a38afe4..a895c0a 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -19,8 +19,10 @@ from waitress import serve import db from sheets import Sheets +from booking_api import bp as booking_bp app = Flask(__name__, static_folder="static", static_url_path="") +app.register_blueprint(booking_bp) CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") # Separate read-only token for the public iCal feed (calendar apps can't send diff --git a/backoffice/app/availability.py b/backoffice/app/availability.py new file mode 100644 index 0000000..42aea02 --- /dev/null +++ b/backoffice/app/availability.py @@ -0,0 +1,55 @@ +"""Slot generation for the booking module (#16). + +Pure functions only -- no I/O. Callers (booking_api.py) fetch a resource's +hours/existing bookings via booking_db.py and pass them in here, which keeps +this module trivially unit-testable against hand-computed expectations, +including across a DST transition. +""" +from datetime import date, datetime, timedelta, timezone +from zoneinfo import ZoneInfo + + +def generate_slots(hours_by_weekday, duration_minutes, date_from, date_to, + tz_name, now, min_notice_minutes=60, max_advance_days=30, + buffer_minutes=0, busy=()): + """Return a sorted list of tz-aware UTC datetimes, one per bookable slot + start, for [date_from, date_to] inclusive. + + hours_by_weekday: {0..6: (opens_at time, closes_at time)}, 0=Monday, + matching date.weekday(); a missing weekday means closed that day. + tz_name: IANA zone the hours are local to (e.g. "Europe/Berlin"). + now: tz-aware datetime "now" is measured from, for min-notice/max-advance. + busy: iterable of (start, end) tz-aware datetimes already booked on this + resource -- a candidate slot within buffer_minutes of one is dropped. + """ + tz = ZoneInfo(tz_name) + duration = timedelta(minutes=duration_minutes) + buffer_td = timedelta(minutes=buffer_minutes) + earliest = now + timedelta(minutes=min_notice_minutes) + latest = now + timedelta(days=max_advance_days) + busy_utc = [(s.astimezone(timezone.utc), e.astimezone(timezone.utc)) for s, e in busy] + + slots = [] + day = date_from + while day <= date_to: + hours = hours_by_weekday.get(day.weekday()) + if hours: + opens_at, closes_at = hours + cursor = datetime.combine(day, opens_at, tzinfo=tz) + close = datetime.combine(day, closes_at, tzinfo=tz) + while cursor + duration <= close: + start_utc = cursor.astimezone(timezone.utc) + end_utc = (cursor + duration).astimezone(timezone.utc) + if (earliest <= start_utc <= latest + and not _conflicts(start_utc, end_utc, busy_utc, buffer_td)): + slots.append(start_utc) + cursor += duration + day += timedelta(days=1) + return slots + + +def _conflicts(start, end, busy_utc, buffer_td): + for b_start, b_end in busy_utc: + if start < b_end + buffer_td and end + buffer_td > b_start: + return True + return False diff --git a/backoffice/app/booking_api.py b/backoffice/app/booking_api.py new file mode 100644 index 0000000..85a3fb5 --- /dev/null +++ b/backoffice/app/booking_api.py @@ -0,0 +1,209 @@ +"""Booking API blueprint (#16): availability + create/cancel/reschedule. + +Headless JSON API -- no browser UI yet (that's #17/#18). Routes here are the +only place that mints/verifies the signed cancel/reschedule token and the +only caller of the availability engine; all DB access still goes through +booking_db.py. +""" +import os +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +import jwt +from flask import Blueprint, jsonify, request + +import availability +import booking_db as bdb + +bp = Blueprint("booking_api", __name__, url_prefix="/api/booking") + +# Dedicated secret -- deliberately not shared with CRM_API_TOKEN, so rotating +# one never silently invalidates (or, worse, cross-signs) the other. +TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "") +TOKEN_TTL_DAYS = 30 + + +def _mint_manage_token(client_id, booking_id): + payload = { + "client_id": client_id, + "booking_id": booking_id, + "exp": datetime.now(timezone.utc) + timedelta(days=TOKEN_TTL_DAYS), + } + return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256") + + +def _verify_manage_token(token): + """Returns (client_id, booking_id), or None if the token is + missing/expired/malformed.""" + try: + payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"]) + except jwt.PyJWTError: + return None + return payload.get("client_id"), payload.get("booking_id") + + +def _parse_dt(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + + +def _parse_date(value): + try: + return datetime.fromisoformat(str(value)).date() + except (ValueError, TypeError): + return None + + +def _tz_name(client): + return (client or {}).get("timezone") or "Europe/Berlin" + + +def _local_date(dt, tz_name): + """The calendar date dt falls on in tz_name -- used to pick the right + business day (and the right resource_hours row) regardless of what UTC + offset the caller's ISO string happened to use.""" + return dt.astimezone(ZoneInfo(tz_name)).date() + + +def _available_slots(client_id, resource, tz_name, duration_minutes, date_from, + date_to, exclude_booking_id=None): + hours = bdb.get_resource_hours(client_id, resource["resource_id"]) + # date_from/date_to are local calendar dates -- widen the busy-booking + # query to the UTC instants that actually cover them in tz_name, not a + # literal UTC midnight window (which would miss/misalign bookings near + # local midnight, e.g. in winter Berlin midnight is 23:00 UTC the day + # before). + tz = ZoneInfo(tz_name) + day_start = datetime.combine(date_from, datetime.min.time(), tzinfo=tz).astimezone(timezone.utc) + day_end = (datetime.combine(date_to, datetime.min.time(), tzinfo=tz) + + timedelta(days=1)).astimezone(timezone.utc) + busy_rows = bdb.list_active_bookings_for_resource( + client_id, resource["resource_id"], day_start, day_end, + exclude_booking_id=exclude_booking_id) + busy = [(r["start_time"], r["end_time"]) for r in busy_rows] + return availability.generate_slots( + hours, duration_minutes, date_from, date_to, tz_name, + now=datetime.now(timezone.utc), + min_notice_minutes=resource["min_notice_minutes"], + max_advance_days=resource["max_advance_days"], + buffer_minutes=resource["buffer_minutes"], + busy=busy) + + +@bp.get("/slots") +def slots(): + client_id = request.args.get("client_id") + resource_id = request.args.get("resource_id") + service_id = request.args.get("service_id") + date_from = _parse_date(request.args.get("date_from")) + date_to = _parse_date(request.args.get("date_to")) + if not (client_id and resource_id and service_id and date_from and date_to): + return jsonify({"error": "client_id, resource_id, service_id, date_from, " + "date_to are required"}), 400 + resource = bdb.get_resource(client_id, resource_id) + service = bdb.get_service(client_id, service_id) + client = bdb.get_client(client_id) + if resource is None or service is None or client is None: + return jsonify({"error": "not found"}), 404 + slot_list = _available_slots(client_id, resource, _tz_name(client), + service["duration_minutes"], date_from, date_to) + return jsonify({"slots": [s.isoformat() for s in slot_list]}) + + +@bp.post("") +def create_booking(): + body = request.get_json(force=True, silent=True) or {} + client_id = body.get("client_id") + resource_id = body.get("resource_id") + service_id = body.get("service_id") + start_time = _parse_dt(body.get("start_time")) + if not (client_id and resource_id and service_id and start_time + and body.get("customer_name") and body.get("customer_contact")): + return jsonify({"error": "client_id, resource_id, service_id, start_time, " + "customer_name, customer_contact are required"}), 400 + resource = bdb.get_resource(client_id, resource_id) + service = bdb.get_service(client_id, service_id) + client = bdb.get_client(client_id) + if resource is None or service is None or client is None: + return jsonify({"error": "not found"}), 404 + + tz_name = _tz_name(client) + day = _local_date(start_time, tz_name) + valid_starts = _available_slots(client_id, resource, tz_name, + service["duration_minutes"], day, day) + if start_time not in valid_starts: + return jsonify({"error": "that slot is no longer available"}), 409 + + end_time = start_time + timedelta(minutes=service["duration_minutes"]) + status = "confirmed" if client.get("auto_confirm", True) else "pending" + try: + booking = bdb.create_booking( + client_id, resource_id, body["customer_name"], body["customer_contact"], + service["name"], start_time, end_time, source="public", status=status) + except bdb.BookingConflict: + return jsonify({"error": "that slot was just taken"}), 409 + + token = _mint_manage_token(client_id, booking["booking_id"]) + return jsonify({"booking_id": booking["booking_id"], "status": booking["status"], + "token": token}), 201 + + +@bp.post("/cancel") +def cancel_booking(): + body = request.get_json(force=True, silent=True) or {} + resolved = _verify_manage_token(body.get("token")) + if resolved is None: + return jsonify({"error": "invalid or expired token"}), 400 + client_id, booking_id = resolved + existing = bdb.get_booking(client_id, booking_id) + if existing is None: + return jsonify({"error": "not found"}), 404 + if existing["status"] == "cancelled": + return jsonify({"error": "already cancelled"}), 409 + updated = bdb.update_booking(client_id, booking_id, status="cancelled") + return jsonify({"cancelled": updated["booking_id"]}) + + +@bp.post("/reschedule") +def reschedule_booking(): + body = request.get_json(force=True, silent=True) or {} + resolved = _verify_manage_token(body.get("token")) + if resolved is None: + return jsonify({"error": "invalid or expired token"}), 400 + client_id, booking_id = resolved + new_start = _parse_dt(body.get("start_time")) + if new_start is None: + return jsonify({"error": "start_time is required"}), 400 + + existing = bdb.get_booking(client_id, booking_id) + if existing is None: + return jsonify({"error": "not found"}), 404 + if existing["status"] == "cancelled": + return jsonify({"error": "already cancelled"}), 409 + duration = existing["end_time"] - existing["start_time"] + new_end = new_start + duration + + resource = bdb.get_resource(client_id, existing["resource_id"]) + client = bdb.get_client(client_id) + if resource is None or client is None: + return jsonify({"error": "not found"}), 404 + tz_name = _tz_name(client) + day = _local_date(new_start, tz_name) + valid_starts = _available_slots(client_id, resource, tz_name, + duration.total_seconds() // 60, day, day, + exclude_booking_id=booking_id) + if new_start not in valid_starts: + return jsonify({"error": "that slot is no longer available"}), 409 + + try: + updated = bdb.update_booking(client_id, booking_id, start_time=new_start, + end_time=new_end) + except bdb.BookingConflict: + return jsonify({"error": "that slot was just taken"}), 409 + return jsonify({"booking_id": updated["booking_id"], + "start_time": updated["start_time"].isoformat(), + "end_time": updated["end_time"].isoformat()}) diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index 0e581ed..c7db724 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -1,7 +1,8 @@ -"""Tenancy-safe data-access layer for the booking module (#15). +"""Tenancy-safe data-access layer for the booking module (#15, #16). -This is the only place that runs raw SQL against resources, services, -bookings, users, and password_reset_tokens. Every function takes a +This is the only place that runs raw SQL against resources, resource_hours, +services, bookings, users, and password_reset_tokens (plus a read-only +lookup against the existing clients table). Every function takes a client_id (or, for password-reset consumption, an unguessable token that resolves straight to a user) and injects the tenant filter itself -- callers never write "WHERE client_id = ..." by hand. @@ -31,13 +32,16 @@ def _new_id(prefix): # ---- resources ---- -def create_resource(client_id, name, active=True): +def create_resource(client_id, name, active=True, min_notice_minutes=60, + max_advance_days=30, buffer_minutes=0): resource_id = _new_id("RS") with db.connect() as conn, conn.cursor() as cur: cur.execute( - "INSERT INTO resources (resource_id, client_id, name, active) " - "VALUES (%s, %s, %s, %s) RETURNING *", - (resource_id, client_id, name, active)) + "INSERT INTO resources (resource_id, client_id, name, active, " + "min_notice_minutes, max_advance_days, buffer_minutes) " + "VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING *", + (resource_id, client_id, name, active, min_notice_minutes, + max_advance_days, buffer_minutes)) row = cur.fetchone() conn.commit() return row @@ -51,6 +55,38 @@ def get_resource(client_id, resource_id): return cur.fetchone() +def set_resource_hours(client_id, resource_id, weekday, opens_at, closes_at): + """Upsert the opening hours for one weekday (0=Monday..6=Sunday) of a + client's own resource. Raises UnknownResource if resource_id isn't + client_id's.""" + if get_resource(client_id, resource_id) is None: + raise UnknownResource(f"no resource {resource_id} for client {client_id}") + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO resource_hours (resource_id, weekday, opens_at, closes_at) " + "VALUES (%s, %s, %s, %s) " + "ON CONFLICT (resource_id, weekday) DO UPDATE SET " + "opens_at = EXCLUDED.opens_at, closes_at = EXCLUDED.closes_at " + "RETURNING *", + (resource_id, weekday, opens_at, closes_at)) + row = cur.fetchone() + conn.commit() + return row + + +def get_resource_hours(client_id, resource_id): + """Return {weekday: (opens_at, closes_at)} for client_id's own resource + (empty for a resource with no hours configured yet, or one that isn't + client_id's).""" + if get_resource(client_id, resource_id) is None: + return {} + with db.connect() as conn, conn.cursor() as cur: + cur.execute( + "SELECT weekday, opens_at, closes_at FROM resource_hours " + "WHERE resource_id = %s", (resource_id,)) + return {r["weekday"]: (r["opens_at"], r["closes_at"]) for r in cur.fetchall()} + + # ---- services ---- def create_service(client_id, name, duration_minutes, price=None, active=True): @@ -102,7 +138,13 @@ def create_booking(client_id, resource_id, customer_name, customer_contact, service, start_time, end_time, source, status)) row = cur.fetchone() conn.commit() - except psycopg.errors.ExclusionViolation as e: + except (psycopg.errors.ExclusionViolation, psycopg.errors.DeadlockDetected) as e: + # Two genuinely concurrent inserts racing the same exclusion-constraint + # index probe can deadlock instead of one cleanly losing to the other + # (each waits on a lock the other holds) -- Postgres aborts one side + # with DeadlockDetected rather than ExclusionViolation. The only way + # this INSERT can deadlock at all is via that index, so it still means + # "this resource/time is already booked." raise BookingConflict( f"resource {resource_id} is already booked for that time") from e return row @@ -124,6 +166,27 @@ def list_bookings(client_id): return cur.fetchall() +def list_active_bookings_for_resource(client_id, resource_id, start, end, + exclude_booking_id=None): + """Bookings on client_id's own resource_id, not cancelled, that fall + within [start, end) -- used by the availability engine to keep booked + slots (and their buffer) out of the generated slot list. Pass + exclude_booking_id when validating a reschedule so a booking doesn't + count as a conflict against itself.""" + if get_resource(client_id, resource_id) is None: + return [] + sql = ("SELECT * FROM bookings WHERE client_id = %s AND resource_id = %s " + "AND status != 'cancelled' AND start_time < %s AND end_time > %s") + params = [client_id, resource_id, end, start] + if exclude_booking_id is not None: + sql += " AND booking_id != %s" + params.append(exclude_booking_id) + sql += " ORDER BY start_time" + with db.connect() as conn, conn.cursor() as cur: + cur.execute(sql, params) + return cur.fetchall() + + def update_booking(client_id, booking_id, **fields): """Update a booking scoped to client_id (e.g. reschedule/cancel). Returns the updated row, or None if no such booking exists for this @@ -147,11 +210,20 @@ def update_booking(client_id, booking_id, **fields): [*fields.values(), client_id, booking_id]) row = cur.fetchone() conn.commit() - except psycopg.errors.ExclusionViolation as e: + except (psycopg.errors.ExclusionViolation, psycopg.errors.DeadlockDetected) as e: raise BookingConflict("resource is already booked for that time") from e return row +# ---- clients (read-only; the clients table itself is db.py's, this is just +# the booking flow's read of its own client's config) ---- + +def get_client(client_id): + with db.connect() as conn, conn.cursor() as cur: + cur.execute("SELECT * FROM clients WHERE client_id = %s", (client_id,)) + return cur.fetchone() + + # ---- users (owner login) ---- def create_user(client_id, email, password): diff --git a/backoffice/app/requirements.txt b/backoffice/app/requirements.txt index 2b0347b..d96bfba 100644 --- a/backoffice/app/requirements.txt +++ b/backoffice/app/requirements.txt @@ -3,3 +3,6 @@ psycopg[binary]==3.2.1 waitress==3.0.0 PyJWT[crypto]==2.9.0 requests==2.32.3 +# python:3.12-slim has no system IANA tz database; zoneinfo (used by +# availability.py for Europe/Berlin) falls back to this package for it. +tzdata==2024.1 diff --git a/backoffice/app/tests/test_availability.py b/backoffice/app/tests/test_availability.py new file mode 100644 index 0000000..6b49b1d --- /dev/null +++ b/backoffice/app/tests/test_availability.py @@ -0,0 +1,119 @@ +from datetime import date, datetime, time, timezone + +import availability as av + +BERLIN = "Europe/Berlin" +NINE_TO_FIVE = {d: (time(9, 0), time(17, 0)) for d in range(7)} + + +def _far_past(): + # "far enough in the past" relative to the 2026 test dates below, not + # literally far past -- max_advance_days is finite, so "now" has to be + # within max_advance_days of the date under test. + return datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def _far_future_notice(): + return {"min_notice_minutes": 0, "max_advance_days": 365 * 10} + + +def test_normal_day_matches_hand_computed_slots(): + # 2026-07-06 is a Monday in Berlin summer time (CEST, UTC+2). + day = date(2026, 7, 6) + slots = av.generate_slots( + {0: (time(9, 0), time(12, 0))}, duration_minutes=60, + date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(), + **_far_future_notice()) + assert slots == [ + datetime(2026, 7, 6, 7, 0, tzinfo=timezone.utc), + datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc), + datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc), + ] + + +def test_dst_spring_forward_shifts_utc_offset_but_keeps_slot_count(): + # Berlin DST 2026 starts 2026-03-29 (clocks 02:00 -> 03:00 CET->CEST). + before = date(2026, 3, 28) # CET, UTC+1 + on_day = date(2026, 3, 29) # transition day; business hours all CEST + after = date(2026, 3, 30) # CEST, UTC+2 + + def slots_for(day): + return av.generate_slots( + NINE_TO_FIVE, duration_minutes=60, date_from=day, date_to=day, + tz_name=BERLIN, now=_far_past(), **_far_future_notice()) + + before_slots = slots_for(before) + on_day_slots = slots_for(on_day) + after_slots = slots_for(after) + + assert len(before_slots) == len(on_day_slots) == len(after_slots) == 8 + assert before_slots[0] == datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc) + # one hour earlier in UTC once CEST (UTC+2) kicks in + assert on_day_slots[0] == datetime(2026, 3, 29, 7, 0, tzinfo=timezone.utc) + assert after_slots[0] == datetime(2026, 3, 30, 7, 0, tzinfo=timezone.utc) + + +def test_dst_fall_back_shifts_utc_offset_but_keeps_slot_count(): + # Berlin DST 2026 ends 2026-10-25 (clocks 03:00 -> 02:00 CEST->CET). + before = date(2026, 10, 24) # CEST, UTC+2 + after = date(2026, 10, 26) # CET, UTC+1 + + def slots_for(day): + return av.generate_slots( + NINE_TO_FIVE, duration_minutes=60, date_from=day, date_to=day, + tz_name=BERLIN, now=_far_past(), **_far_future_notice()) + + before_slots = slots_for(before) + after_slots = slots_for(after) + assert len(before_slots) == len(after_slots) == 8 + assert before_slots[0] == datetime(2026, 10, 24, 7, 0, tzinfo=timezone.utc) + assert after_slots[0] == datetime(2026, 10, 26, 8, 0, tzinfo=timezone.utc) + + +def test_min_notice_excludes_near_term_slots(): + day = date(2026, 7, 6) + now = datetime(2026, 7, 6, 6, 30, tzinfo=timezone.utc) # 08:30 local + slots = av.generate_slots( + {0: (time(9, 0), time(12, 0))}, duration_minutes=60, + date_from=day, date_to=day, tz_name=BERLIN, now=now, + min_notice_minutes=60, max_advance_days=365) + # 07:00 UTC (09:00 local) is only 30min out -- excluded by 60min notice. + assert slots == [ + datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc), + datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc), + ] + + +def test_max_advance_excludes_far_future_slots(): + day = date(2026, 7, 6) + now = datetime(2026, 7, 5, 0, 0, tzinfo=timezone.utc) + slots = av.generate_slots( + {0: (time(9, 0), time(12, 0))}, duration_minutes=60, + date_from=day, date_to=day, tz_name=BERLIN, now=now, + min_notice_minutes=0, max_advance_days=1) + assert slots == [] + + +def test_buffer_excludes_slots_too_close_to_an_existing_booking(): + day = date(2026, 7, 6) + # existing booking 09:00-10:00 local (07:00-08:00 UTC) + busy = [(datetime(2026, 7, 6, 7, 0, tzinfo=timezone.utc), + datetime(2026, 7, 6, 8, 0, tzinfo=timezone.utc))] + slots = av.generate_slots( + {0: (time(9, 0), time(12, 0))}, duration_minutes=60, + date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(), + min_notice_minutes=0, max_advance_days=365, buffer_minutes=30, + busy=busy) + # 08:00 UTC (10:00 local) slot starts only 30min after the busy booking + # ends at 08:00 -- exactly at the buffer boundary, so still blocked; + # 09:00 UTC (11:00 local) is clear. + assert slots == [datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)] + + +def test_no_hours_configured_for_weekday_yields_no_slots(): + day = date(2026, 7, 6) # Monday, but hours only configured for Tuesday + slots = av.generate_slots( + {1: (time(9, 0), time(12, 0))}, duration_minutes=60, + date_from=day, date_to=day, tz_name=BERLIN, now=_far_past(), + **_far_future_notice()) + assert slots == [] diff --git a/backoffice/app/tests/test_booking_api.py b/backoffice/app/tests/test_booking_api.py new file mode 100644 index 0000000..097025b --- /dev/null +++ b/backoffice/app/tests/test_booking_api.py @@ -0,0 +1,262 @@ +"""Flask test client / real-DB integration tests for the booking API (#16), +per #14's testing decision: assert on HTTP response + resulting DB state, +not on which internal function got called. +""" +from datetime import date, datetime, time, timedelta, timezone + +import pytest + +import booking_db as bdb +from app import app as flask_app + +CLIENT_A = "C-TEST-A" +CLIENT_B = "C-TEST-B" + + +@pytest.fixture +def client(): + flask_app.config["TESTING"] = True + return flask_app.test_client() + + +def _next_monday(after): + d = after + timedelta(days=1) + while d.weekday() != 0: + d += timedelta(days=1) + return d + + +def _setup_resource_and_service(client_id=CLIENT_A, auto_confirm=True, **resource_kwargs): + with bdb.db.connect() as conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO clients (client_id, timezone, auto_confirm) VALUES (%s, %s, %s) " + "ON CONFLICT (client_id) DO UPDATE SET timezone = EXCLUDED.timezone, " + "auto_confirm = EXCLUDED.auto_confirm", + (client_id, "Europe/Berlin", auto_confirm)) + conn.commit() + resource = bdb.create_resource(client_id, "Chair 1", **resource_kwargs) + bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0)) + service = bdb.create_service(client_id, "Haircut", 60, price=25) + return resource, service + + +def test_slots_endpoint_lists_bookable_starts(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + resp = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}) + assert resp.status_code == 200 + body = resp.get_json() + assert len(body["slots"]) == 8 # 09:00-17:00, 60min slots + + +def test_create_booking_auto_confirm_true_yields_confirmed(client): + resource, service = _setup_resource_and_service( + auto_confirm=True, min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slots = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + + resp = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slots[0], + "customer_name": "Alice", "customer_contact": "alice@example.com"}) + assert resp.status_code == 201 + body = resp.get_json() + assert body["status"] == "confirmed" + assert "token" in body + + +def test_create_booking_auto_confirm_false_yields_pending(client): + resource, service = _setup_resource_and_service( + auto_confirm=False, min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slots = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + + resp = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slots[0], + "customer_name": "Bob", "customer_contact": "bob@example.com"}) + assert resp.status_code == 201 + assert resp.get_json()["status"] == "pending" + + +def test_create_booking_rejects_slot_outside_business_rules(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + # 20:00 is outside the 09:00-17:00 hours configured above. + outside = datetime.combine(day, time(20, 0), tzinfo=timezone.utc).isoformat() + resp = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": outside, + "customer_name": "Carl", "customer_contact": "c@example.com"}) + assert resp.status_code == 409 + + +def test_concurrent_booking_requests_only_one_succeeds(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0] + + payload = {"client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot, + "customer_name": "Race1", "customer_contact": "r1@example.com"} + first = client.post("/api/booking", json=payload) + payload2 = dict(payload, customer_name="Race2", customer_contact="r2@example.com") + second = client.post("/api/booking", json=payload2) + + statuses = sorted([first.status_code, second.status_code]) + assert statuses == [201, 409] + assert len(bdb.list_bookings(CLIENT_A)) == 1 + + +def test_cancel_with_valid_token_cancels_booking(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot, + "customer_name": "Dana", "customer_contact": "d@example.com"}).get_json() + + resp = client.post("/api/booking/cancel", json={"token": created["token"]}) + assert resp.status_code == 200 + assert bdb.get_booking(CLIENT_A, created["booking_id"])["status"] == "cancelled" + + # already-used: cancelling an already-cancelled booking is rejected, not + # silently repeated. + resp2 = client.post("/api/booking/cancel", json={"token": created["token"]}) + assert resp2.status_code == 409 + + # garbage token is rejected cleanly, not a 500. + resp3 = client.post("/api/booking/cancel", json={"token": "not-a-real-token"}) + assert resp3.status_code == 400 + + +def test_cancel_with_expired_token_is_rejected(client): + import jwt as pyjwt + from booking_api import TOKEN_SECRET + resource, service = _setup_resource_and_service() + booking = bdb.create_booking( + CLIENT_A, resource["resource_id"], "Zara", "z@example.com", "Haircut", + datetime.now(timezone.utc) + timedelta(days=1), + datetime.now(timezone.utc) + timedelta(days=1, hours=1)) + expired = pyjwt.encode( + {"client_id": CLIENT_A, "booking_id": booking["booking_id"], + "exp": datetime.now(timezone.utc) - timedelta(minutes=1)}, + TOKEN_SECRET, algorithm="HS256") + resp = client.post("/api/booking/cancel", json={"token": expired}) + assert resp.status_code == 400 + assert bdb.get_booking(CLIENT_A, booking["booking_id"])["status"] != "cancelled" + + +def test_reschedule_of_already_cancelled_booking_is_rejected(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot_list = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[0], + "customer_name": "Jan", "customer_contact": "j@example.com"}).get_json() + client.post("/api/booking/cancel", json={"token": created["token"]}) + + resp = client.post("/api/booking/reschedule", json={ + "token": created["token"], "start_time": slot_list[1]}) + assert resp.status_code == 409 + + +def test_reschedule_with_valid_token_updates_time(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot_list = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[0], + "customer_name": "Eve", "customer_contact": "e@example.com"}).get_json() + + resp = client.post("/api/booking/reschedule", json={ + "token": created["token"], "start_time": slot_list[2]}) + assert resp.status_code == 200 + booking = bdb.get_booking(CLIENT_A, created["booking_id"]) + assert booking["start_time"].isoformat() == slot_list[2] + + +def test_reschedule_into_occupied_slot_fails_cleanly(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot_list = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + + first = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[0], + "customer_name": "Fay", "customer_contact": "f@example.com"}).get_json() + second = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[1], + "customer_name": "Gus", "customer_contact": "g@example.com"}).get_json() + + resp = client.post("/api/booking/reschedule", json={ + "token": second["token"], "start_time": slot_list[0]}) + assert resp.status_code == 409 + assert bdb.get_booking(CLIENT_A, second["booking_id"])["start_time"].isoformat() \ + == slot_list[1] + + +def test_reschedule_to_same_slot_is_a_noop_success(client): + resource, service = _setup_resource_and_service( + min_notice_minutes=0, max_advance_days=365) + day = _next_monday(date.today()) + slot_list = client.get("/api/booking/slots", query_string={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], + "date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"] + created = client.post("/api/booking", json={ + "client_id": CLIENT_A, "resource_id": resource["resource_id"], + "service_id": service["service_id"], "start_time": slot_list[0], + "customer_name": "Hana", "customer_contact": "h@example.com"}).get_json() + + resp = client.post("/api/booking/reschedule", json={ + "token": created["token"], "start_time": slot_list[0]}) + assert resp.status_code == 200 + + +def test_manage_token_is_scoped_to_its_own_client(): + resource, service = _setup_resource_and_service(client_id=CLIENT_A) + from booking_api import _mint_manage_token, _verify_manage_token + booking = bdb.create_booking( + CLIENT_A, resource["resource_id"], "Ivy", "i@example.com", "Haircut", + datetime.now(timezone.utc) + timedelta(days=1), + datetime.now(timezone.utc) + timedelta(days=1, hours=1)) + token = _mint_manage_token(CLIENT_A, booking["booking_id"]) + assert _verify_manage_token(token) == (CLIENT_A, booking["booking_id"]) + tampered = token[:-1] + ("A" if token[-1] != "A" else "B") + assert _verify_manage_token(tampered) is None diff --git a/backoffice/app/tests/test_booking_db.py b/backoffice/app/tests/test_booking_db.py index 214986a..119a070 100644 --- a/backoffice/app/tests/test_booking_db.py +++ b/backoffice/app/tests/test_booking_db.py @@ -1,3 +1,4 @@ +import threading from datetime import datetime, timedelta, timezone import pytest @@ -57,6 +58,39 @@ def test_overlapping_booking_same_resource_raises_conflict(): "Haircut", _dt(10, 30), _dt(11, 30)) +def test_truly_concurrent_overlapping_inserts_only_one_wins(): + """Two threads, each on its own DB connection, racing to insert the same + overlapping slot -- not just a sequential second-request-fails check. + The Postgres EXCLUDE constraint (not app-level locking) is what has to + serialize this.""" + r = bdb.create_resource(CLIENT_A, "Chair 1") + start_barrier = threading.Barrier(2) + results = {} + + def attempt(name, customer): + start_barrier.wait() + try: + results[name] = bdb.create_booking( + CLIENT_A, r["resource_id"], customer, f"{customer}@x.com", + "Haircut", _dt(10), _dt(11)) + except bdb.BookingConflict as e: + results[name] = e + + t1 = threading.Thread(target=attempt, args=("t1", "Race1")) + t2 = threading.Thread(target=attempt, args=("t2", "Race2")) + t1.start() + t2.start() + t1.join() + t2.join() + + outcomes = list(results.values()) + conflicts = [o for o in outcomes if isinstance(o, bdb.BookingConflict)] + successes = [o for o in outcomes if not isinstance(o, bdb.BookingConflict)] + assert len(conflicts) == 1 + assert len(successes) == 1 + assert len(bdb.list_bookings(CLIENT_A)) == 1 + + def test_adjacent_non_overlapping_bookings_both_succeed(): r = bdb.create_resource(CLIENT_A, "Chair 1") bdb.create_booking(CLIENT_A, r["resource_id"], "Alice", "a@x.com", diff --git a/backoffice/db/init.sql b/backoffice/db/init.sql index a029281..2533b45 100644 --- a/backoffice/db/init.sql +++ b/backoffice/db/init.sql @@ -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 ( diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml index b9d8e75..b4895d9 100644 --- a/backoffice/docker-compose.yml +++ b/backoffice/docker-compose.yml @@ -24,6 +24,7 @@ services: environment: DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm CRM_API_TOKEN: ${CRM_API_TOKEN} + BOOKING_TOKEN_SECRET: ${BOOKING_TOKEN_SECRET} ICS_TOKEN: ${ICS_TOKEN} SHEET_ID: ${SHEET_ID} GOOGLE_SA_JSON: /run/secrets/gcp-sa.json