-- Separate schema: never imports/migrates the historical schema.sql or SQLite. CREATE SCHEMA IF NOT EXISTS dtf_local; CREATE TABLE IF NOT EXISTS dtf_local.uploads ( id uuid PRIMARY KEY, owner uuid NOT NULL, name text NOT NULL, size bigint NOT NULL, object_key text UNIQUE NOT NULL, multipart_id text NOT NULL, complete boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS dtf_local.quotes ( id uuid PRIMARY KEY, owner uuid NOT NULL, request_key uuid NOT NULL, request_hash text NOT NULL, draft jsonb NOT NULL, approved jsonb, reviewed_by text, approved_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(owner, request_key) ); CREATE TABLE IF NOT EXISTS dtf_local.orders ( id uuid PRIMARY KEY, number bigint GENERATED ALWAYS AS IDENTITY UNIQUE, quote_id uuid NOT NULL UNIQUE REFERENCES dtf_local.quotes(id), owner uuid NOT NULL, snapshot jsonb NOT NULL, payment jsonb NOT NULL, state text NOT NULL DEFAULT 'rec', version integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS dtf_local.movements ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id), from_state text NOT NULL, to_state text NOT NULL, operator text NOT NULL, reason text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS dtf_local.outbox ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event_key text NOT NULL UNIQUE, provider text NOT NULL, payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL DEFAULT now(), delivered_at timestamptz, last_error text, receipt jsonb ); CREATE TABLE IF NOT EXISTS dtf_local.accounts ( id uuid PRIMARY KEY, email text UNIQUE NOT NULL, password_hash text NOT NULL, profile jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS dtf_local.sessions ( id uuid PRIMARY KEY, owner uuid NOT NULL, expires_at timestamptz NOT NULL DEFAULT now() + interval '7 days' ); CREATE TABLE IF NOT EXISTS dtf_local.migrations (name text PRIMARY KEY); -- Preserve pre-account guest sessions once, without resurrecting logged-out sessions. DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM dtf_local.migrations WHERE name='customer-sessions-v1') THEN INSERT INTO dtf_local.sessions(id,owner) SELECT owner,owner FROM ( SELECT owner FROM dtf_local.orders UNION SELECT owner FROM dtf_local.quotes UNION SELECT owner FROM dtf_local.uploads ) legacy ON CONFLICT DO NOTHING; INSERT INTO dtf_local.migrations VALUES('customer-sessions-v1'); END IF; END $$; CREATE TABLE IF NOT EXISTS dtf_local.login_attempts ( key text PRIMARY KEY, attempts integer NOT NULL DEFAULT 0, started_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE IF NOT EXISTS dtf_local.operator_sessions ( token_hash text PRIMARY KEY, username text NOT NULL, expires_at timestamptz NOT NULL DEFAULT now() + interval '8 hours' ); CREATE TABLE IF NOT EXISTS dtf_local.operators ( id uuid PRIMARY KEY, email text UNIQUE NOT NULL, name text NOT NULL DEFAULT '', password_hash text NOT NULL, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), last_login_at timestamptz ); CREATE TABLE IF NOT EXISTS dtf_local.payment_events ( id uuid PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL, reference text, status text NOT NULL, amount_cents bigint, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz, outcome text, UNIQUE(provider, event_id) ); CREATE TABLE IF NOT EXISTS dtf_local.security_events ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, event text NOT NULL, details jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS expires_at timestamptz; ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS purged_at timestamptz; ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_state text NOT NULL DEFAULT 'pending'; ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_reason text; ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scanned_at timestamptz; ALTER TABLE dtf_local.uploads ADD COLUMN IF NOT EXISTS scan_after timestamptz NOT NULL DEFAULT now(); UPDATE dtf_local.uploads SET expires_at=created_at + interval '30 days' WHERE expires_at IS NULL; ALTER TABLE dtf_local.uploads ALTER COLUMN expires_at SET DEFAULT now() + interval '30 days'; CREATE TABLE IF NOT EXISTS dtf_local.order_files ( id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id), upload_id uuid NOT NULL REFERENCES dtf_local.uploads(id), item_index integer NOT NULL, kind text NOT NULL CHECK(kind IN ('final','correction')), active boolean NOT NULL DEFAULT true, note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(order_id,upload_id,kind) ); -- A payment started at the provider for an approved quote: which provider -- payment belongs to which quote, and its latest known status. CREATE TABLE IF NOT EXISTS dtf_local.payment_intents ( id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id), provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL, status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(provider, provider_payment_id) ); -- OAuth connections to providers (Tiny). One row per provider; the refresh -- token rotates on use, so it lives here, never in configuration. CREATE TABLE IF NOT EXISTS dtf_local.provider_tokens ( provider text PRIMARY KEY, access_token text NOT NULL, refresh_token text NOT NULL, access_expires_at timestamptz NOT NULL, refresh_expires_at timestamptz, connected_by text NOT NULL, connected_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- Renewal health, cleared by the next successful renewal or connection. -- refused_at: the provider rejected the refresh token, so only a new -- connection helps. offline: the grant is not bound to a login session. ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_failed_at timestamptz; ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refresh_error text; ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS refused_at timestamptz; ALTER TABLE dtf_local.provider_tokens ADD COLUMN IF NOT EXISTS offline boolean NOT NULL DEFAULT false; -- Single-use states for an operator-started OAuth connection. They protect the -- callback, which arrives cross-site without the operator's cookie. CREATE TABLE IF NOT EXISTS dtf_local.oauth_states ( state text PRIMARY KEY, provider text NOT NULL, operator text NOT NULL, expires_at timestamptz NOT NULL ); -- The print file generated from each paid item's approved layout. One row per -- item: the worker claims it, renders, and records either the file or why the -- item has to be prepared by hand. Regenerating replaces the row's result. CREATE TABLE IF NOT EXISTS dtf_local.print_files ( id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id), item_index integer NOT NULL, status text NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','rendering','ready','manual','failed')), upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}', attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, UNIQUE(order_id,item_index) ); -- Each run of the off-server database backup (ops/db_backup.py), which the -- Kanban shows so a backup that stopped working does not go unnoticed. CREATE TABLE IF NOT EXISTS dtf_local.backups ( id uuid PRIMARY KEY, started_at timestamptz NOT NULL, finished_at timestamptz NOT NULL, status text NOT NULL CHECK(status IN ('ok','failed')), object_key text, bytes bigint, detail text ); CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC); -- Why a quote waits for review, when the reason came from its files (the grade -- the server recomputed) and cannot be worked out again from the draft alone. ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text; CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner); -- Indexes follow the queries the application actually issues. Only these; every -- extra index is paid for on each write. -- The customer portal lists a person's orders and open quotes, newest first. CREATE INDEX IF NOT EXISTS orders_owner ON dtf_local.orders(owner, created_at DESC); CREATE INDEX IF NOT EXISTS quotes_owner ON dtf_local.quotes(owner, created_at DESC); -- Order detail and the movement history read by order. CREATE INDEX IF NOT EXISTS movements_order ON dtf_local.movements(order_id, id); CREATE INDEX IF NOT EXISTS order_files_order ON dtf_local.order_files(order_id); -- submit_files checks whether an upload is already attached, by upload. CREATE INDEX IF NOT EXISTS order_files_upload ON dtf_local.order_files(upload_id); -- The worker polls this once a second, and the table only grows. Partial, so the -- index stays the size of the backlog rather than of all history. CREATE INDEX IF NOT EXISTS outbox_pending ON dtf_local.outbox(available_at, id) WHERE delivered_at IS NULL; -- The scanner and the retention sweep both walk live uploads in arrival order. -- now() cannot appear in a partial index predicate, so the time comparisons stay -- in the query and the index narrows to rows still worth looking at. CREATE INDEX IF NOT EXISTS uploads_live ON dtf_local.uploads(created_at) WHERE purged_at IS NULL; -- Sign-in transfers and logout delete a person's sessions by owner. CREATE INDEX IF NOT EXISTS sessions_owner ON dtf_local.sessions(owner); -- Disabling an operator revokes their open sessions by name. CREATE INDEX IF NOT EXISTS operator_sessions_username ON dtf_local.operator_sessions(username); -- security_status reads recent events; the worker prunes old ones by age. CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(created_at); -- The webhook looks an event up by provider and id on every delivery, and the -- unique constraint already indexes that pair. Only the unprocessed sweep needs -- its own index, and it stays the size of the backlog. CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at) WHERE processed_at IS NULL; -- The render worker polls for unclaimed or abandoned jobs; the order view reads -- by order through the unique constraint. CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at) WHERE status IN ('pending','rendering'); -- A movement that undid an earlier one (a mistaken move), shown as such. ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false; -- The Kanban lists payment events a person must act on (money without an -- order, or a reversed payment on an existing order) until resolved. ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz; ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text; ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text; CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at) WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL; -- Payment intents look up by quote (customer retry) and by provider id (webhook). CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);