Files
dtf-system/app/schema.sql
Cauê Faleiros c18b9e5b87
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
feat: generate print files, collect delivery addresses, add provider adapters
Week 2 work that did not need client inputs.

Print files (1.4): each paid item gets a PDF the width of the film and the
length of the approved layout, with every copy at its reviewed position,
rotation and mirror. Sources are embedded once at original resolution; JPEG
bytes pass through and PNG alpha becomes a soft mask. Artwork the generator
cannot reproduce goes to hand preparation with the reason. The worker renders
outside any transaction, and the operator approves the generated file as the
final one through the existing review.

Delivery address (3.8): required for any non-pickup quote, bound to the
quoted CEP, carried into the order snapshot, the Kanban card and Tiny.

Kanban (1.5): print-file status per item, and a panel of payment events that
need a person (money without an order, refunds after an order) until an
operator records the resolution.

Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API
documentation and tested against fake transports only. Selectable for
sandbox testing with their credentials; the production preflight still
blocks release. Adds payment intents and a PIX step on the Site.

MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI
storage use Chainguard's MinIO build, pinned by digest.

Verified with the full CI integration sequence on a fresh local build,
including the new print_file_test and both browser suites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 11:56:46 -03:00

172 lines
9.3 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)
);
-- 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');
-- 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);