Booking schema + tenancy-safe data-access module (#15)
Adds the plumbing every other booking ticket builds on: resources, services, users, and password_reset_tokens tables, plus the clients columns (slug, timezone, auto_confirm, ics_token) and the bookings resource_id/EXCLUDE-constraint double-booking protection described in #14. booking_db.py is the only place raw SQL runs against these tables -- every function takes client_id and injects the tenant filter itself, and create/update_booking additionally verify the resource_id belongs to that client before writing, closing a guessed-ID cross-tenant hole. Tests spin up a real throwaway Postgres 16 container (matching prod) and exercise the EXCLUDE constraint, tenancy isolation, and password-reset single-use semantics end to end, per #14's "real Postgres, no mocking" testing decision. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"""Tenancy-safe data-access layer for the booking module (#15).
|
||||
|
||||
This is the only place that runs raw SQL against resources, services,
|
||||
bookings, users, and password_reset_tokens. 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):
|
||||
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))
|
||||
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()
|
||||
|
||||
|
||||
# ---- 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 as e:
|
||||
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 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 as e:
|
||||
raise BookingConflict("resource is already booked for that time") from e
|
||||
return row
|
||||
|
||||
|
||||
# ---- 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
|
||||
Reference in New Issue
Block a user