Files
smb-online/backoffice/app/booking_db.py
T
mivanchenko 2f6e0c1459 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>
2026-07-23 14:52:33 +02:00

290 lines
12 KiB
Python

"""Tenancy-safe data-access layer for the booking module (#15, #16).
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.
"""
import secrets
import time
from datetime import datetime, timedelta, timezone
import psycopg
from werkzeug.security import check_password_hash, generate_password_hash
import db
class BookingConflict(Exception):
"""Raised when a booking would overlap another on the same resource."""
class UnknownResource(Exception):
"""Raised when a resource_id doesn't belong to the given client_id --
guards against a booking write smuggling in another tenant's resource."""
def _new_id(prefix):
return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}"
# ---- resources ----
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, "
"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
def get_resource(client_id, resource_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM resources WHERE client_id = %s AND resource_id = %s",
(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):
service_id = _new_id("SV")
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO services (service_id, client_id, name, duration_minutes, "
"price, active) VALUES (%s, %s, %s, %s, %s, %s) RETURNING *",
(service_id, client_id, name, duration_minutes, price, active))
row = cur.fetchone()
conn.commit()
return row
def get_service(client_id, service_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM services WHERE client_id = %s AND service_id = %s",
(client_id, service_id))
return cur.fetchone()
# ---- bookings ----
_BOOKING_UPDATABLE = {"resource_id", "customer_name", "customer_contact",
"service", "start_time", "end_time", "status"}
def create_booking(client_id, resource_id, customer_name, customer_contact,
service, start_time, end_time, source=None, status="confirmed"):
"""Insert a booking. Raises UnknownResource if resource_id isn't one of
client_id's own resources (the EXCLUDE constraint only scopes overlap by
resource_id, so this is the one place that has to stop a guessed
resource_id from another tenant). Raises BookingConflict if it overlaps
an existing booking on the same resource -- the Postgres EXCLUDE
constraint is the single source of truth for that, this just turns it
into a clean error."""
if get_resource(client_id, resource_id) is None:
raise UnknownResource(f"no resource {resource_id} for client {client_id}")
booking_id = _new_id("BK")
try:
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO bookings (booking_id, created_at, client_id, resource_id, "
"customer_name, customer_contact, service, start_time, end_time, "
"source, status) VALUES (%s, now(), %s, %s, %s, %s, %s, %s, %s, %s, %s) "
"RETURNING *",
(booking_id, client_id, resource_id, customer_name, customer_contact,
service, start_time, end_time, source, status))
row = cur.fetchone()
conn.commit()
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
def get_booking(client_id, booking_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM bookings WHERE client_id = %s AND booking_id = %s",
(client_id, booking_id))
return cur.fetchone()
def list_bookings(client_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time",
(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
client. Raises UnknownResource if fields moves the booking onto another
tenant's resource_id. Raises BookingConflict if the update would overlap
another booking on the same resource."""
bad = set(fields) - _BOOKING_UPDATABLE
if bad:
raise ValueError(f"not updatable: {', '.join(sorted(bad))}")
if not fields:
return get_booking(client_id, booking_id)
if "resource_id" in fields and get_resource(client_id, fields["resource_id"]) is None:
raise UnknownResource(
f"no resource {fields['resource_id']} for client {client_id}")
setsql = ", ".join(f"{c} = %s" for c in fields)
try:
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"UPDATE bookings SET {setsql} WHERE client_id = %s AND booking_id = %s "
"RETURNING *",
[*fields.values(), client_id, booking_id])
row = cur.fetchone()
conn.commit()
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):
user_id = _new_id("U")
password_hash = generate_password_hash(password)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO users (user_id, client_id, email, password_hash) "
"VALUES (%s, %s, %s, %s) RETURNING *",
(user_id, client_id, email, password_hash))
row = cur.fetchone()
conn.commit()
return row
def get_user_by_email(client_id, email):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"SELECT * FROM users WHERE client_id = %s AND email = %s",
(client_id, email))
return cur.fetchone()
def verify_password(user, password):
return check_password_hash(user["password_hash"], password)
# ---- password reset ----
def create_password_reset_token(user_id, ttl_minutes=30):
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO password_reset_tokens (token, user_id, expires_at) "
"VALUES (%s, %s, %s) RETURNING *",
(token, user_id, expires_at))
row = cur.fetchone()
conn.commit()
return row
def consume_password_reset_token(token, new_password):
"""Validate + single-use consume a reset token, then set the new
password. Returns the user_id on success, None if the token is
missing/expired/already used -- the UPDATE ... WHERE used_at IS NULL
makes the consume-and-invalidate step atomic."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
"UPDATE password_reset_tokens SET used_at = now() "
"WHERE token = %s AND used_at IS NULL AND expires_at > now() "
"RETURNING user_id",
(token,))
row = cur.fetchone()
if row is None:
conn.commit()
return None
user_id = row["user_id"]
cur.execute(
"UPDATE users SET password_hash = %s WHERE user_id = %s",
(generate_password_hash(new_password), user_id))
conn.commit()
return user_id