-- +goose Up -- The proactive alert engine surface. One tenant scoped, typed, persisted, UI surfaceable alert per -- actionable market or bench event the watch loop detects: a winnable recompete just dropped that fits the -- bench, a clearance is about to expire, an open gap just became structurally fillable. The scheduled sweep -- (cmd/alert-sweep, the same host timer pattern as mm-opp-sync) UPSERTs one row per (org, the thing alerted) -- under the org's RLS session, so a re sweep refreshes rather than duplicates, and the owner facing UI reads -- the list and flips the status (unread to read to dismissed). These alerts are about the OWNER's OWN records -- under RLS, so the title may name the owner's own candidate, requirement, or agency; it must NEVER carry -- another tenant's data, which RLS guarantees because every read is the caller's org plus the GLOBAL PUBLIC -- opportunity_records catalog. This is alerting plumbing, so there is NO model_version bump and NO engine -- change: the frozen matching engine stays the only scorer and the gap trigger is a STRUCTURAL coverage test, -- never a match score. -- -- Tenant isolation is the platform standard: RLS on organization_id (ENABLE plus FORCE, USING plus WITH -- CHECK on the org GUC), the composite index leads with organization_id, and the dedupe uniqueness is scoped -- per org. The app role UPSERTs under RLS during the sweep and reads and updates status for the UI; it never -- deletes an alert, so the grant is SELECT, INSERT, UPDATE with no DELETE. CREATE TABLE alerts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), organization_id uuid NOT NULL REFERENCES organizations(id), alert_type text NOT NULL CHECK (alert_type IN ('recompete_winnable', 'clearance_expiring', 'gap_fillable')), severity text NOT NULL DEFAULT 'info' CHECK (severity IN ('info', 'watch', 'urgent')), subject_type text NOT NULL DEFAULT '', -- opportunity, candidate, or requirement subject_id uuid, -- the caller's OWN record this alert is about, nullable title text NOT NULL, -- a short human line, only the owner's own data detail text NOT NULL DEFAULT '', -- structural or count detail, never another tenant's data dedupe_key text NOT NULL, -- stable per (org, the thing alerted) so a re sweep upserts status text NOT NULL DEFAULT 'unread' CHECK (status IN ('unread', 'read', 'dismissed')), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), -- The dedupe key is unique per org, so the sweep UPSERTs ON CONFLICT (organization_id, dedupe_key) and a -- re sweep of the same event never creates a second row. CONSTRAINT uq_alerts_org_dedupe UNIQUE (organization_id, dedupe_key) ); -- The UI read path lists the org's alerts by status, newest first; the composite index leads with -- organization_id per the tenant index rule. CREATE INDEX idx_alerts_org_status ON alerts (organization_id, status, created_at DESC); ALTER TABLE alerts ENABLE ROW LEVEL SECURITY; ALTER TABLE alerts FORCE ROW LEVEL SECURITY; CREATE POLICY alerts_isolation ON alerts USING (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid) WITH CHECK (organization_id = NULLIF(current_setting('app.current_org_id', true), '')::uuid); -- The app role UPSERTs alerts during the sweep (INSERT plus the ON CONFLICT UPDATE), reads the list, and -- flips the status (UPDATE). It never deletes an alert, so the grant is SELECT, INSERT, UPDATE with no DELETE. GRANT SELECT, INSERT, UPDATE ON alerts TO missionmatch_app; -- +goose Down DROP TABLE IF EXISTS alerts;