24d9aca812
Test backoffice (smb-crm) / test (push) Has been cancelled
Each client now gets their own clients.ics_token (lazily generated on first /owner/settings visit), which both authenticates and scopes /api/bookings.ics -- closing the gap where any shared-token holder could view another client's bookings by swapping the client_id query param. The owner settings page now surfaces a copyable subscribe URL. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
221 lines
8.0 KiB
PL/PgSQL
221 lines
8.0 KiB
PL/PgSQL
-- smb-crm — sole source-of-truth schema (Postgres).
|
|
|
|
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). Gated by X-CRM-Token even to read, 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);
|
|
|
|
-- Partial (NULL-excluding) so many clients can share ics_token IS NULL before
|
|
-- their first ensure_ics_token() call lazily backfills a real value (#22).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS clients_ics_token_idx ON clients (ics_token)
|
|
WHERE ics_token IS NOT NULL;
|