-- Custom migration, hand-authored. Drizzle cannot diff a SQL function. -- -- Fixed by #14 decision 1: ONE function collapses the four inputs #12 named -- into a release level, and ONE lookup answers whether a class is open at it. -- Nothing else in the system encodes lifecycle. -- -- ── WHY plpgsql AND WHY FIRST ──────────────────────────────────────────── -- The RLS policies are emitted by Drizzle in the same migration as the tables -- they guard, so the functions those policies call must already exist. But the -- functions read `solicitation`, `participation` and `release_matrix`, which -- that same later migration creates. -- -- `LANGUAGE sql` validates the body at CREATE time and would fail here. -- plpgsql defers validation to first call, which is exactly the property -- needed to break the cycle. Signatures use `text` rather than the -- `release_level` enum for the same reason — the enum does not exist yet. -- ───────────────────────────────────────────────────────────────────────── -- The four inputs: audience membership, stage, participation state, acknowledgement. CREATE FUNCTION release_level_for_solicitation(p_solicitation uuid) RETURNS text LANGUAGE plpgsql STABLE AS $$ DECLARE v_level text; BEGIN SELECT CASE WHEN p.id IS NULL THEN 'none' WHEN s.stage = 'awarded' THEN 'awarded' WHEN s.stage = 'finalists' THEN 'finalist' WHEN s.stage = 'evaluating' THEN 'evaluating' -- The deadline is the moment the whole field opens at once (#14 decision 2). -- Before it, no response content is readable by anyone but its author, which -- is what makes bid shopping structurally impossible rather than discouraged. WHEN s.responses_due_at IS NOT NULL AND now() >= s.responses_due_at THEN 'open' WHEN p.state = 'submitted' THEN 'submitted' WHEN p.acknowledged_at IS NOT NULL OR NOT s.acknowledgement_required THEN 'acknowledged' ELSE 'invited' END INTO v_level FROM solicitation s LEFT JOIN participation p ON p.solicitation_id = s.id AND p.vendor_org_id = nullif(current_setting('app.org_id', true), '')::uuid WHERE s.id = p_solicitation; RETURN coalesce(v_level, 'none'); END; $$; -- The issuer reaches their own event at the level its stage implies, without -- being a participant in it. Everyone else gets 'none' — including a dual-role -- org's issuing side looking at its own vendor side (#11 decision 2). CREATE FUNCTION release_level_for_response(p_response uuid) RETURNS text LANGUAGE plpgsql STABLE AS $$ DECLARE v_level text; BEGIN SELECT CASE WHEN current_setting('app.capability', true) = 'issuer' AND s.issuer_org_id = nullif(current_setting('app.org_id', true), '')::uuid THEN release_level_for_solicitation(s.id) WHEN current_setting('app.capability', true) = 'vendor' AND r.vendor_org_id = nullif(current_setting('app.org_id', true), '')::uuid THEN release_level_for_solicitation(s.id) ELSE 'none' END INTO v_level FROM response r JOIN solicitation s ON s.id = r.solicitation_id WHERE r.id = p_response; RETURN coalesce(v_level, 'none'); END; $$; -- Reads the CURRENT matrix version. A past release is reconstructed by reading -- an older version, which is why the matrix is versioned rather than updated. CREATE FUNCTION class_released(p_level text, p_class text) RETURNS boolean LANGUAGE plpgsql STABLE AS $$ DECLARE v_released boolean; BEGIN SELECT released = 'true' INTO v_released FROM release_matrix WHERE class = p_class::disclosure_class AND level = p_level::release_level ORDER BY version DESC LIMIT 1; RETURN coalesce(v_released, false); END; $$;