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