Availability engine + booking API: create/cancel/reschedule (#16)
Adds the plumbing that makes "can a customer actually get booked" true end to end at the API layer, on top of #15's schema/tenancy layer. - resource_hours table + min_notice_minutes/max_advance_days/buffer_minutes on resources -- config #15 didn't include but #16 depends on. - availability.py: pure slot-generation function, correct across a Europe/Berlin DST transition (tested both directions). - booking_api.py: JSON blueprint for slot listing, booking creation (auto_confirm -> confirmed/pending), and signed-JWT cancel/reschedule, registered into app.py. - booking_db.py gains resource-hours CRUD, a tenant-scoped busy-bookings query for buffer/slot validation, and a read-only client lookup. A true concurrent-threads test (not just sequential requests) surfaced a real gap: Postgres can raise DeadlockDetected instead of ExclusionViolation when two overlapping inserts race the exclusion constraint directly, which went uncaught and would have 500'd instead of giving the clean 4xx the ticket requires -- now caught alongside ExclusionViolation. Also fixed: reschedule used the request's raw UTC offset to pick the business day instead of the client's own timezone (could pick the wrong day's hours/bookings near local midnight); the cancel/reschedule JWT no longer falls back to reusing CRM_API_TOKEN as its signing secret. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user