Phase 2: core domain, policy engine, packet API, Clerical screen

Migration 0003: projects, aliases, renumbers, units, packets and every child table,
change orders, assets, documents, ITPs, station limits, department weld flag.
Policy package: Principal, PacketState, Action rules, custody, preconditions,
CO hold, order of checks, can/blocked/holder on reads. docs/POLICY.md with the
tightenings table for Seth. Write pipeline: FOR UPDATE, CAS on status and dept,
If-Match row versions, server-assigned numbers, field CAS, version bump, packet
log, audit, NOTIFY, signatures, IT reasons, filed lock and HMAC seal.
API: /api/packets (header, transitions, work orders, pages, docs, move, remove,
BOM, shorts, welds, heat tags, heat table, stage, checklist, clock, warehouse
stage, Q.C., change orders, log, seal), /api/projects, /api/units, /api/assets,
/_blob. Frontend: Clerical home, projects and units, packet page with pdf.js
and QR codes, hand draft, heat map, send to warehouse, department or Q.C.
Tests: policy matrix, API, concurrency, schema, projects; Playwright Clerical
draft scenario. Verified on docker-1 with the CI stack and the Samba DC.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-10-16 09:31:45 -06:00
co-authored by Claude Fable 5.1
parent 97b0973130
commit 37d9a0cb50
83 changed files with 16490 additions and 55 deletions
@@ -0,0 +1,771 @@
-- 0003_domain: the core domain. Projects and units, packets and their child tables, change
-- orders, documents and ITPs, plus the views v_units and v_open_cos. Runs as ps_migrate; 0001's
-- default privileges give ps_app SELECT, INSERT, UPDATE, DELETE on every table here.
-- Append-only tables revoke below. Text ID columns (work order base, lot, split, sub, part ids)
-- stay text: leading zeros are kept and lot 7A occurs.
CREATE TYPE packet_status AS ENUM (
'draft', 'warehouse', 'ready', 'assigned', 'in_progress', 'review', 'qc', 'complete', 'filed'
);
CREATE TYPE wh_stage AS ENUM ('incoming', 'pulling', 'hold', 'pulled');
CREATE TYPE co_review AS ENUM ('pending', 'approved', 'rejected');
CREATE TYPE co_origin AS ENUM ('visual', 'drawing', 'manual');
CREATE TYPE source_kind AS ENUM ('visual', 'hand');
CREATE TYPE project_status AS ENUM ('open', 'hold', 'closed');
CREATE TYPE route_kind AS ENUM ('databook', 'shipping');
CREATE TYPE drawing_state AS ENUM (
'none', 'needed', 'attached', 'rev_mismatch', 'not_released', 'not_found', 'lookup_failed', 'hand'
);
CREATE TYPE itp_status AS ENUM ('open', 'signed', 'reopened');
-- ---------------------------------------------------------------- organisation
-- Weld departments get the welding checklist and need welds and filler before submit.
ALTER TABLE departments ADD COLUMN weld bool NOT NULL DEFAULT false;
UPDATE departments SET weld = true WHERE code IN ('03', '04', '05', '06', '07', '10');
-- A worker with rows here may only work on packets at these stations. No rows = any station.
CREATE TABLE user_station_limits (
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
station_id bigint NOT NULL REFERENCES stations (id),
PRIMARY KEY (user_id, station_id)
);
-- ---------------------------------------------------------------- projects and units
-- *_src says where a value came from: visual, hand, or null when unset. A hand value is never
-- overwritten by the feed; *_visual keeps what VISUAL said beside it.
CREATE TABLE projects (
id bigserial PRIMARY KEY,
number text NOT NULL,
name text,
customer text,
po text,
due date,
status project_status NOT NULL DEFAULT 'open',
notes text,
source text NOT NULL DEFAULT 'hand' CHECK (source IN ('hand', 'visual')),
needs_details bool NOT NULL DEFAULT false,
customer_src text CHECK (customer_src IN ('visual', 'hand')),
customer_visual text,
po_src text CHECK (po_src IN ('visual', 'hand')),
po_visual text,
order_date date,
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
ship_date date,
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX projects_number_live ON projects (upper(number)) WHERE removed_at IS NULL;
-- Every current and old number of every project and unit, upper-cased, so a lookup by any
-- number resolves. unit_id is null for a project number. The unit_id FK is added after units.
CREATE TABLE project_aliases (
alias text PRIMARY KEY CHECK (alias = upper(alias)),
project_id bigint NOT NULL REFERENCES projects (id),
unit_id bigint,
kind text NOT NULL CHECK (kind IN ('renumber', 'unit_renumber', 'import')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX project_aliases_project ON project_aliases (project_id);
-- lot is text: 1 to 3 characters, upper-cased, zero-padded to 3 when numeric (001, 7A).
CREATE TABLE units (
id bigserial PRIMARY KEY,
project_id bigint NOT NULL REFERENCES projects (id),
lot text NOT NULL CHECK (lot ~ '^[A-Z0-9]{1,3}$' AND (lot !~ '^[0-9]+$' OR length(lot) = 3)),
name text,
due date,
status project_status NOT NULL DEFAULT 'open',
notes text,
customer_src text CHECK (customer_src IN ('visual', 'hand')),
po_src text CHECK (po_src IN ('visual', 'hand')),
order_date date,
order_date_src text CHECK (order_date_src IN ('visual', 'hand')),
ship_date date,
ship_date_src text CHECK (ship_date_src IN ('visual', 'hand')),
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX units_project_lot_live ON units (project_id, lot) WHERE removed_at IS NULL;
ALTER TABLE project_aliases
ADD CONSTRAINT project_aliases_unit_id_fkey FOREIGN KEY (unit_id) REFERENCES units (id);
CREATE TABLE project_renumbers (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
project_id bigint NOT NULL REFERENCES projects (id),
unit_id bigint REFERENCES units (id),
old_number text NOT NULL,
new_number text NOT NULL,
reason text
);
-- The unit number is the project number plus the lot. A unit's own due date wins over the project's.
CREATE VIEW v_units AS
SELECT u.*,
p.number || u.lot AS number,
p.number AS project_number,
p.name AS project_name,
p.status AS project_status,
coalesce(u.due, p.due) AS due_effective
FROM units u
JOIN projects p ON p.id = u.project_id;
-- ---------------------------------------------------------------- assets
-- One row per stored file. storage_key is the path under ASSETS_DIR. packet_id is the packet the
-- file was uploaded for, if any; the attach rows (packet_pages, packet_docs) are the real links.
CREATE TABLE assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sha256 bytea NOT NULL,
size bigint NOT NULL,
mime text NOT NULL,
original_name text,
storage_key text NOT NULL UNIQUE,
source text NOT NULL DEFAULT 'upload',
source_ref jsonb,
pages int,
uploaded_by uuid REFERENCES users (id),
uploaded_at timestamptz NOT NULL DEFAULT now(),
packet_id bigint
);
CREATE INDEX assets_sha256 ON assets (sha256);
-- ---------------------------------------------------------------- packets
-- version goes up in every write transaction (the pipeline does it, never a trigger).
-- next_*_no are the counters for server-assigned child numbers, taken under the FOR UPDATE lock.
CREATE TABLE packets (
id bigserial PRIMARY KEY,
unit_id bigint NOT NULL REFERENCES units (id),
dept_code text NOT NULL REFERENCES departments (code),
op text,
title text,
customer text,
drawing text,
rev text,
wps text,
status packet_status NOT NULL DEFAULT 'draft',
status_at timestamptz NOT NULL DEFAULT now(),
station_id bigint REFERENCES stations (id),
welder_id uuid REFERENCES users (id),
welder_stamp text,
for_sup_id uuid REFERENCES users (id),
heat_map bool,
wh_skipped bool NOT NULL DEFAULT false,
wh_sent_at timestamptz,
routed_to route_kind,
return_note text,
vclosed_at timestamptz,
vclosed_by uuid REFERENCES users (id),
databook_at timestamptz,
source_kind source_kind NOT NULL DEFAULT 'hand',
source jsonb NOT NULL DEFAULT '{}',
visual_key text,
visual_rel date,
visual_want date,
drawing_state drawing_state NOT NULL DEFAULT 'none',
flags text[] NOT NULL DEFAULT '{}',
visual_fp_applied text,
visual_closed_at timestamptz,
visual jsonb NOT NULL DEFAULT '{}',
next_weld_no int NOT NULL DEFAULT 1,
next_heat_no int NOT NULL DEFAULT 1,
next_page_no int NOT NULL DEFAULT 1,
next_co_no int NOT NULL DEFAULT 1,
version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES users (id),
updated_at timestamptz NOT NULL DEFAULT now(),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
-- One live packet per VISUAL key. A removed packet keeps its key so the feed never recreates it.
CREATE UNIQUE INDEX packets_visual_key ON packets (visual_key)
WHERE visual_key IS NOT NULL AND removed_at IS NULL;
CREATE INDEX packets_status_live ON packets (status) WHERE removed_at IS NULL;
CREATE INDEX packets_unit ON packets (unit_id);
CREATE INDEX packets_dept_status ON packets (dept_code, status);
-- Sets updated_at only. A read never bumps version; the write pipeline bumps it explicitly.
CREATE FUNCTION ps_packet_touch() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER packets_touch
BEFORE UPDATE ON packets
FOR EACH ROW EXECUTE FUNCTION ps_packet_touch();
-- ---------------------------------------------------------------- change orders
-- One pending VISUAL change order per packet at a time; the feed re-aims it rather than adding one.
CREATE TABLE change_orders (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
co_no int NOT NULL,
origin co_origin NOT NULL,
review co_review NOT NULL DEFAULT 'pending',
minor bool NOT NULL DEFAULT false,
hold bool NOT NULL DEFAULT false,
held_by uuid REFERENCES users (id),
held_at timestamptz,
ecn text,
reason text,
issued_at timestamptz DEFAULT now(),
issued_by uuid REFERENCES users (id),
issued_signature_id bigint REFERENCES signatures (id),
approved_at timestamptz,
approved_by uuid REFERENCES users (id),
approved_signature_id bigint REFERENCES signatures (id),
rejected_at timestamptz,
rejected_by uuid REFERENCES users (id),
rejected_reason text,
rejected_signature_id bigint REFERENCES signatures (id),
vis jsonb,
waiting jsonb,
staged jsonb,
target_fp text,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, co_no)
);
CREATE UNIQUE INDEX change_orders_visual_pending ON change_orders (packet_id)
WHERE origin = 'visual' AND review = 'pending';
CREATE TABLE co_items (
id bigserial PRIMARY KEY,
co_id bigint NOT NULL REFERENCES change_orders (id),
old_page int,
old_dwg text,
old_rev text,
new_page int,
new_dwg text,
new_rev text,
added bool NOT NULL DEFAULT false
);
CREATE INDEX co_items_co ON co_items (co_id);
CREATE TABLE co_required_depts (
co_id bigint NOT NULL REFERENCES change_orders (id),
dept_code text NOT NULL REFERENCES departments (code),
PRIMARY KEY (co_id, dept_code)
);
-- Insert-only. A primary key violation means the department already verified.
CREATE TABLE co_verifications (
co_id bigint NOT NULL REFERENCES change_orders (id),
dept_code text NOT NULL REFERENCES departments (code),
by uuid NOT NULL REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
note text,
signature_id bigint NOT NULL REFERENCES signatures (id),
it_reason text,
PRIMARY KEY (co_id, dept_code)
);
-- The old openCOs(): an approved change order that some required department has not verified,
-- or a pending one Clerical put on hold. pending_depts lists the unverified departments, sorted.
CREATE VIEW v_open_cos AS
SELECT x.packet_id, x.co_id, x.co_no, x.review, x.hold, x.pending_depts
FROM (
SELECT co.packet_id,
co.id AS co_id,
co.co_no,
co.review,
co.hold,
ARRAY(
SELECT r.dept_code
FROM co_required_depts r
WHERE r.co_id = co.id
AND NOT EXISTS (
SELECT 1 FROM co_verifications v
WHERE v.co_id = r.co_id AND v.dept_code = r.dept_code
)
ORDER BY r.dept_code
) AS pending_depts
FROM change_orders co
WHERE co.review IN ('approved', 'pending')
) x
WHERE (x.review = 'approved' AND cardinality(x.pending_depts) > 0)
OR (x.review = 'pending' AND x.hold);
-- ---------------------------------------------------------------- packet children
-- All key parts are text. lot, split and sub default to '' so the unique key works without nulls.
CREATE TABLE packet_work_orders (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
wo_base text NOT NULL,
lot text NOT NULL DEFAULT '',
split text NOT NULL DEFAULT '',
sub text NOT NULL DEFAULT '',
seq int,
qty numeric,
target_hrs numeric,
op text,
description text,
visual_status text,
missing_runs int NOT NULL DEFAULT 0,
gone_at timestamptz,
sort int NOT NULL DEFAULT 0,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, wo_base, lot, split, sub)
);
-- n comes from packets.next_page_no. A page replaced by a change order is voided, never deleted.
CREATE TABLE packet_pages (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
n int NOT NULL,
type text NOT NULL CHECK (type IN ('drawing', 'info', 'overlay')),
asset_id uuid REFERENCES assets (id),
pn int NOT NULL DEFAULT 1,
dwg text,
rev text,
co_id bigint REFERENCES change_orders (id),
void_co_id bigint REFERENCES change_orders (id),
void_at timestamptz,
void_by uuid REFERENCES users (id),
void_new_n int,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, n)
);
CREATE TABLE bom_lines (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
item int NOT NULL,
qty numeric,
part_id text,
description text,
dwg_ref text,
cl_item text,
length text,
material text,
heat text,
na bool NOT NULL DEFAULT false,
issued numeric,
loc text,
pulled bool NOT NULL DEFAULT false,
pulled_at timestamptz,
pulled_by uuid REFERENCES users (id),
mtr_asset_id uuid REFERENCES assets (id),
src text CHECK (src IN ('visual', 'hand')),
vkey text,
whs text,
comm text,
cons text,
sub text,
grp text,
sort int NOT NULL DEFAULT 0,
row_version int NOT NULL DEFAULT 1,
UNIQUE (packet_id, item)
);
-- At most one open short per BOM line.
CREATE TABLE bom_shorts (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
bom_line_id bigint NOT NULL REFERENCES bom_lines (id) ON DELETE CASCADE,
qty numeric NOT NULL,
qty_open numeric NOT NULL,
reason text,
ref text,
eta date,
note text,
opened_at timestamptz NOT NULL DEFAULT now(),
opened_by uuid REFERENCES users (id),
closed_at timestamptz,
row_version int NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX bom_shorts_open ON bom_shorts (bom_line_id) WHERE closed_at IS NULL;
CREATE INDEX bom_shorts_packet_open ON bom_shorts (packet_id) WHERE closed_at IS NULL;
CREATE TABLE short_receipts (
id bigserial PRIMARY KEY,
short_id bigint NOT NULL REFERENCES bom_shorts (id),
packet_id bigint NOT NULL REFERENCES packets (id),
qty numeric NOT NULL,
heat text,
note text,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id)
);
CREATE INDEX short_receipts_short ON short_receipts (short_id);
-- One stage per visit to a shop department. The open stage has closed_at null.
-- checklist maps key -> {done, at, by, stamp}.
CREATE TABLE packet_stages (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
dept_code text NOT NULL REFERENCES departments (code),
station_id bigint REFERENCES stations (id),
welder_id uuid REFERENCES users (id),
welder_stamp text,
checklist jsonb NOT NULL DEFAULT '{}',
filler text,
filler_lot text,
preheat text,
notes text,
clock_elapsed_ms bigint NOT NULL DEFAULT 0,
clock_running_since timestamptz,
clock_started_at timestamptz,
qc_snapshot jsonb,
opened_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
row_version int NOT NULL DEFAULT 1
);
CREATE UNIQUE INDEX packet_stages_open ON packet_stages (packet_id) WHERE closed_at IS NULL;
CREATE INDEX packet_stages_packet ON packet_stages (packet_id, id);
-- weld_no comes from packets.next_weld_no. fx and fy are fractions of the page.
CREATE TABLE welds (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
weld_no int NOT NULL,
page int,
fx double precision,
fy double precision,
wo_base text,
piece int,
items int[] NOT NULL DEFAULT '{}',
type text,
size text,
process text,
stamp text,
welder_id uuid REFERENCES users (id),
dept_code text REFERENCES departments (code),
stage_id bigint REFERENCES packet_stages (id),
carried_from int,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
row_version int NOT NULL DEFAULT 1,
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX welds_packet_no_live ON welds (packet_id, weld_no) WHERE removed_at IS NULL;
-- tag_no comes from packets.next_heat_no.
CREATE TABLE heat_tags (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
tag_no int NOT NULL,
page int,
item int,
fx double precision,
fy double precision,
carried_from int,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
row_version int NOT NULL DEFAULT 1,
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE UNIQUE INDEX heat_tags_packet_no_live ON heat_tags (packet_id, tag_no) WHERE removed_at IS NULL;
-- One heat table position per page; written as an idempotent upsert.
CREATE TABLE heat_table_placements (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
page int NOT NULL,
x double precision,
y double precision,
s double precision,
by uuid REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
UNIQUE (packet_id, page)
);
-- The warehouse stage history. The current row has closed_at null; a change closes it and inserts.
CREATE TABLE packet_wh (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
stage wh_stage NOT NULL,
by uuid REFERENCES users (id),
at timestamptz NOT NULL DEFAULT now(),
reason text,
note text,
auto bool NOT NULL DEFAULT false,
closed_at timestamptz
);
CREATE UNIQUE INDEX packet_wh_current ON packet_wh (packet_id) WHERE closed_at IS NULL;
-- Every status change. Replaces the old hist and sentBack lists.
CREATE TABLE packet_transitions (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
action text NOT NULL,
from_status packet_status,
to_status packet_status,
from_dept text,
to_dept text,
reason text,
signature_id bigint REFERENCES signatures (id),
it_reason text
);
CREATE INDEX packet_transitions_packet ON packet_transitions (packet_id, id);
-- One open inspection per packet. weld_exam maps weld_no -> {result, note}.
CREATE TABLE qc_inspections (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
stage_id bigint REFERENCES packet_stages (id),
sent_at timestamptz NOT NULL DEFAULT now(),
sent_by uuid REFERENCES users (id),
book bool,
spool bool,
location text,
sent_note text,
checks jsonb NOT NULL DEFAULT '{}',
weld_exam jsonb NOT NULL DEFAULT '{}',
nde_method text,
nde_result text,
nde_report text,
hardness text,
nace bool,
inspector text,
stamp text,
notes text,
result text CHECK (result IN ('accepted', 'rejected')),
result_at timestamptz,
result_by uuid REFERENCES users (id),
result_note text,
signature_id bigint REFERENCES signatures (id),
row_version int NOT NULL DEFAULT 1,
closed_at timestamptz
);
CREATE UNIQUE INDEX qc_inspections_open ON qc_inspections (packet_id) WHERE closed_at IS NULL;
CREATE TABLE qc_history (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
result text,
inspector text,
note text,
dept_code text,
snapshot jsonb
);
CREATE INDEX qc_history_packet ON qc_history (packet_id, id);
-- HMAC seal written when a packet is filed. At most one unbroken seal per packet.
CREATE TABLE packet_seals (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
hash bytea NOT NULL,
key_id text NOT NULL,
sealed_at timestamptz NOT NULL DEFAULT now(),
sealed_by uuid REFERENCES users (id),
signature_id bigint REFERENCES signatures (id),
broken_at timestamptz,
broken_by uuid REFERENCES users (id),
broken_reason text
);
CREATE UNIQUE INDEX packet_seals_unbroken ON packet_seals (packet_id) WHERE broken_at IS NULL;
-- The user-visible history. by_name and role are snapshots at the time of writing.
CREATE TABLE packet_log (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id),
by_name text,
role text,
action text NOT NULL,
note text,
it_reason text,
signature_id bigint REFERENCES signatures (id),
transition_id bigint REFERENCES packet_transitions (id)
);
CREATE INDEX packet_log_packet ON packet_log (packet_id, id);
-- What the feed last observed per leg. Written by the worker only.
CREATE TABLE packet_visual_state (
packet_id bigint PRIMARY KEY REFERENCES packets (id),
legs jsonb NOT NULL DEFAULT '[]',
ticket_hours numeric,
order_ref jsonb,
observed_at timestamptz
);
-- ---------------------------------------------------------------- documents and ITPs
CREATE TABLE packet_docs (
id bigserial PRIMARY KEY,
packet_id bigint NOT NULL REFERENCES packets (id),
asset_id uuid NOT NULL REFERENCES assets (id),
kind text NOT NULL,
title text,
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES users (id),
removed_at timestamptz,
removed_by uuid REFERENCES users (id),
removed_reason text
);
CREATE INDEX packet_docs_packet ON packet_docs (packet_id) WHERE removed_at IS NULL;
-- Q.C. documents for a project or a unit. A new revision points the old row at the new one.
CREATE TABLE qc_docs (
id bigserial PRIMARY KEY,
project_id bigint REFERENCES projects (id),
unit_id bigint REFERENCES units (id),
type text NOT NULL,
rev text,
asset_id uuid NOT NULL REFERENCES assets (id),
auto bool NOT NULL DEFAULT false,
superseded_by bigint REFERENCES qc_docs (id),
added_at timestamptz NOT NULL DEFAULT now(),
added_by uuid REFERENCES users (id)
);
-- One ITP per unit. template is frozen when the ITP is made.
CREATE TABLE itps (
id bigserial PRIMARY KEY,
unit_id bigint UNIQUE NOT NULL REFERENCES units (id),
template jsonb NOT NULL,
status itp_status NOT NULL DEFAULT 'open',
signoff_at timestamptz,
signoff_by uuid REFERENCES users (id),
signoff_signature_id bigint REFERENCES signatures (id),
signoff_asset_id uuid REFERENCES assets (id),
reopen_at timestamptz,
reopen_by uuid REFERENCES users (id),
reopen_reason text,
version int NOT NULL DEFAULT 1
);
CREATE TABLE itp_lines (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
key text NOT NULL,
result text CHECK (result IN ('approve', 'reject', 'na')),
by uuid REFERENCES users (id),
at timestamptz,
ncr text,
note text,
signature_id bigint REFERENCES signatures (id),
row_version int NOT NULL DEFAULT 1,
UNIQUE (itp_id, key)
);
-- Every reject keeps its NCR id, even after the line is approved later.
CREATE TABLE itp_rejects (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
line_key text NOT NULL,
ncr text NOT NULL,
note text,
at timestamptz NOT NULL DEFAULT now(),
by uuid REFERENCES users (id)
);
CREATE TABLE itp_blocks (
id bigserial PRIMARY KEY,
itp_id bigint NOT NULL REFERENCES itps (id),
block_key text NOT NULL,
signed_at timestamptz NOT NULL DEFAULT now(),
signed_by uuid REFERENCES users (id),
signature_id bigint REFERENCES signatures (id),
UNIQUE (itp_id, block_key)
);
-- ---------------------------------------------------------------- append-only records
CREATE TRIGGER project_renumbers_append_only
BEFORE UPDATE OR DELETE ON project_renumbers
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER project_renumbers_no_truncate
BEFORE TRUNCATE ON project_renumbers
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER short_receipts_append_only
BEFORE UPDATE OR DELETE ON short_receipts
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER short_receipts_no_truncate
BEFORE TRUNCATE ON short_receipts
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_transitions_append_only
BEFORE UPDATE OR DELETE ON packet_transitions
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_transitions_no_truncate
BEFORE TRUNCATE ON packet_transitions
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER qc_history_append_only
BEFORE UPDATE OR DELETE ON qc_history
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER qc_history_no_truncate
BEFORE TRUNCATE ON qc_history
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_log_append_only
BEFORE UPDATE OR DELETE ON packet_log
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER packet_log_no_truncate
BEFORE TRUNCATE ON packet_log
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER itp_rejects_append_only
BEFORE UPDATE OR DELETE ON itp_rejects
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER itp_rejects_no_truncate
BEFORE TRUNCATE ON itp_rejects
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER co_verifications_append_only
BEFORE UPDATE OR DELETE ON co_verifications
FOR EACH ROW EXECUTE FUNCTION ps_append_only();
CREATE TRIGGER co_verifications_no_truncate
BEFORE TRUNCATE ON co_verifications
FOR EACH STATEMENT EXECUTE FUNCTION ps_append_only();
-- Explicit, after the default privileges: the app may only read and append these.
REVOKE UPDATE, DELETE, TRUNCATE ON project_renumbers, short_receipts, packet_transitions, qc_history,
packet_log, itp_rejects, co_verifications FROM ps_app;
GRANT SELECT, INSERT ON project_renumbers, short_receipts, packet_transitions, qc_history,
packet_log, itp_rejects, co_verifications TO ps_app;
-- Views are read-only to the app.
REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON v_units, v_open_cos FROM ps_app;
GRANT SELECT ON v_units, v_open_cos TO ps_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ps_app;