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>
218 lines
7.9 KiB
PL/PgSQL
218 lines
7.9 KiB
PL/PgSQL
-- smb-crm — source-of-truth schema (Postgres).
|
|
-- Google Sheets is a one-way downstream mirror fed by the DB->Sheets sync.
|
|
-- Column sets mirror the Sheet tabs so the mirror stays 1:1.
|
|
|
|
CREATE TABLE IF NOT EXISTS clients (
|
|
client_id text PRIMARY KEY,
|
|
business_name text,
|
|
owner_name text,
|
|
email text,
|
|
phone text,
|
|
niche text,
|
|
tier text,
|
|
status text,
|
|
domain text,
|
|
stack_notes text,
|
|
vault_ref text,
|
|
services text,
|
|
billing_cycle text,
|
|
monthly_fee_eur numeric,
|
|
start_date date,
|
|
renewal_date date,
|
|
created_at timestamptz,
|
|
notes text,
|
|
notify_channel text,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS leads (
|
|
lead_id text PRIMARY KEY,
|
|
received_at timestamptz,
|
|
client_id text,
|
|
source text,
|
|
name text,
|
|
contact text,
|
|
service_interest text,
|
|
message text,
|
|
status text,
|
|
notified boolean,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
project_id text PRIMARY KEY,
|
|
client_id text,
|
|
deliverable text,
|
|
tier text,
|
|
checklist text,
|
|
go_live_date date,
|
|
status text,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS activity_log (
|
|
id bigserial PRIMARY KEY,
|
|
ts timestamptz,
|
|
workflow text,
|
|
client_id text,
|
|
action text,
|
|
detail text,
|
|
result text
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS bookings (
|
|
booking_id text PRIMARY KEY,
|
|
created_at timestamptz,
|
|
client_id text,
|
|
customer_name text,
|
|
customer_contact text,
|
|
service text,
|
|
start_time timestamptz,
|
|
end_time timestamptz,
|
|
source text,
|
|
status text,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS invoices (
|
|
invoice_id text PRIMARY KEY,
|
|
client_id text,
|
|
issued_date date,
|
|
due_date date,
|
|
amount_eur numeric,
|
|
period text,
|
|
status text,
|
|
paid_date date,
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Per-client login credentials (e.g. the auto-generated Easy!Appointments
|
|
-- provider login). Deliberately NOT mirrored to Sheets — see db.py TABLES
|
|
-- ("mirror": False) — so secrets never leave Postgres.
|
|
CREATE TABLE IF NOT EXISTS credentials (
|
|
cred_id text PRIMARY KEY,
|
|
client_id text,
|
|
label text,
|
|
username text,
|
|
secret text,
|
|
notes text,
|
|
created_at timestamptz,
|
|
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,
|
|
min_notice_minutes integer NOT NULL DEFAULT 60,
|
|
max_advance_days integer NOT NULL DEFAULT 30,
|
|
buffer_minutes integer NOT NULL DEFAULT 0,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Per-resource weekly opening hours (#16): one open/close interval per
|
|
-- weekday (0=Monday .. 6=Sunday, matching Python's date.weekday()); no row
|
|
-- for a weekday means the resource is closed that day. Per-resource, not
|
|
-- per-client, since multi-resource clients may have staff with different
|
|
-- hours (#14).
|
|
CREATE TABLE IF NOT EXISTS resource_hours (
|
|
resource_id text NOT NULL,
|
|
weekday smallint NOT NULL CHECK (weekday BETWEEN 0 AND 6),
|
|
opens_at time NOT NULL,
|
|
closes_at time NOT NULL,
|
|
PRIMARY KEY (resource_id, weekday)
|
|
);
|
|
|
|
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','resources','services','users'] LOOP
|
|
EXECUTE format(
|
|
'CREATE OR REPLACE TRIGGER %I_touch BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION touch_updated_at()',
|
|
t, t);
|
|
END LOOP;
|
|
END $$;
|
|
|
|
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);
|