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:
+82
-2
@@ -100,17 +100,92 @@ CREATE TABLE IF NOT EXISTS credentials (
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Booking module (#15): resources, services, and the users / password-reset
|
||||
-- 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()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
service_id text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
name text NOT NULL,
|
||||
duration_minutes integer NOT NULL,
|
||||
price numeric,
|
||||
active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Owner login. email is globally unique (not per-client) per the spec (#14).
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id text PRIMARY KEY,
|
||||
client_id text NOT NULL,
|
||||
email text NOT NULL UNIQUE,
|
||||
password_hash text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Single-use reset tokens; consuming one (setting used_at) invalidates it.
|
||||
-- No client_id column: the token itself is the auth boundary, resolved
|
||||
-- straight to its user_id, same as a signed cancel/reschedule link.
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
token text PRIMARY KEY,
|
||||
user_id text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz
|
||||
);
|
||||
|
||||
-- Double-booking protection lives in Postgres, not app code: one resource
|
||||
-- can't hold two overlapping bookings, enforced on INSERT and UPDATE alike.
|
||||
-- btree_gist lets the GiST exclusion constraint use plain "=" on resource_id.
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
|
||||
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS resource_id text;
|
||||
ALTER TABLE bookings ADD COLUMN IF NOT EXISTS during tstzrange
|
||||
GENERATED ALWAYS AS (tstzrange(start_time, end_time, '[)')) STORED;
|
||||
|
||||
-- ALTER TABLE ... ADD CONSTRAINT has no IF NOT EXISTS form, so guard by name
|
||||
-- to keep this file safe to re-run on every deploy like everything above it.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'bookings_no_overlap'
|
||||
) THEN
|
||||
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
|
||||
EXCLUDE USING gist (resource_id WITH =, during WITH &&);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS slug text UNIQUE;
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS timezone text NOT NULL DEFAULT 'Europe/Berlin';
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS auto_confirm boolean NOT NULL DEFAULT true;
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS ics_token text;
|
||||
|
||||
-- keep updated_at fresh on row changes
|
||||
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- CREATE OR REPLACE TRIGGER, not plain CREATE TRIGGER: this whole DO block is
|
||||
-- one statement, so on a redeploy where e.g. clients_touch already exists, a
|
||||
-- plain CREATE would raise and roll back the entire block -- including the
|
||||
-- resources/services/users triggers this migration is adding -- before ever
|
||||
-- reaching them, since they're later in the array.
|
||||
DO $$
|
||||
DECLARE t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['clients','leads','projects','bookings','invoices','credentials'] LOOP
|
||||
FOREACH t IN ARRAY ARRAY['clients','leads','projects','bookings','invoices',
|
||||
'credentials','resources','services','users'] LOOP
|
||||
EXECUTE format(
|
||||
'CREATE TRIGGER %I_touch BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION touch_updated_at()',
|
||||
'CREATE OR REPLACE TRIGGER %I_touch BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION touch_updated_at()',
|
||||
t, t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -119,3 +194,8 @@ CREATE INDEX IF NOT EXISTS leads_received_idx ON leads (received_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS clients_status_idx ON clients (status);
|
||||
CREATE INDEX IF NOT EXISTS activity_ts_idx ON activity_log (ts DESC);
|
||||
CREATE INDEX IF NOT EXISTS credentials_client_idx ON credentials (client_id);
|
||||
CREATE INDEX IF NOT EXISTS resources_client_idx ON resources (client_id);
|
||||
CREATE INDEX IF NOT EXISTS services_client_idx ON services (client_id);
|
||||
CREATE INDEX IF NOT EXISTS bookings_client_idx ON bookings (client_id);
|
||||
CREATE INDEX IF NOT EXISTS users_client_idx ON users (client_id);
|
||||
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_idx ON password_reset_tokens (user_id);
|
||||
|
||||
Reference in New Issue
Block a user