2f6e0c1459
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>
290 lines
12 KiB
Python
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
|