All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m29s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m49s
Connecting now asks for offline_access, retrying once without it if Tiny refuses the scope. Renewal failures are stored: a refused refresh token marks the connection lost and is not sent again (the Kanban previously still said "conectado"), a transient failure shows as a warning until the next renewal, and a session grant with under 12 hours left is flagged. Tiny errors on the callback return to the Kanban instead of a 422. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
11 KiB
SQL
196 lines
11 KiB
SQL
-- 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)
|
|
);
|
|
|
|
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);
|