-- =============================================================================
-- 005_doc_actions.sql — withdrawing a document, restoring one, purging one.
--
--   psql -h pg-ai -U postgres -d plant -f 005_doc_actions.sql
--
-- Applied at Phase 9, after 004. Additive.
--
-- 004 covers documents arriving. This covers them leaving, which is the other
-- half of the same job: a procedure gets cancelled, a manual outlives the
-- equipment it describes, someone uploads the wrong site's document. Without a
-- way out, the only way to stop citing something is `--supersede`, which needs
-- a REPLACEMENT revision to keep — so a document with no successor cannot be
-- withdrawn at all today. See section 16.10 of BUILD-AI-CONTAINERS.md.
--
-- THREE OPERATIONS, AND THE DIFFERENCE BETWEEN THEM IS THE WHOLE POINT:
--
--   withdraw   superseded = TRUE. Chunks stay, stop being citable, immediately.
--              Reversible. This is what "remove it" almost always means, and it
--              is the default the UI offers.
--   restore    superseded = FALSE again. Refused while another revision of the
--              same document is live — un-withdrawing the old rev of a
--              procedure alongside the new one is the failure this whole
--              project exists to avoid.
--   purge      DELETE the chunks. Irreversible, off by default, and it still
--              does not destroy the file or this audit row. For the upload that
--              should never have happened, not for the document that is merely
--              out of date.
-- =============================================================================

-- -----------------------------------------------------------------------------
-- doc_actions — who took a document out, when, why, and what it affected.
--
-- One row per action, written BEFORE the action and completed after, so an
-- interrupted purge leaves evidence rather than a silence. `chunks_affected` is
-- recorded because after a purge it is the only remaining count.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS doc_actions (
    action_id     UUID PRIMARY KEY,
    action        TEXT NOT NULL,      -- withdraw | restore | purge
    status        TEXT NOT NULL DEFAULT 'pending',

    -- --- what was targeted --------------------------------------------------
    -- Either (doc_number, revision) or source_file. Both are kept on the row
    -- whichever was used to select, because doc_number can be NULL on a chunk
    -- whose header never parsed, and source_file is then the only handle.
    doc_number    TEXT,
    revision      TEXT,
    source_file   TEXT,

    -- --- who ----------------------------------------------------------------
    -- From Authelia's forwarded headers, never from the request body.
    actor         TEXT NOT NULL,
    actor_name    TEXT,
    actor_groups  TEXT,               -- as presented, for the audit trail
    reason        TEXT NOT NULL,      -- required on every action, including restore
    acted_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

    -- --- outcome ------------------------------------------------------------
    chunks_affected INT,
    file_moved_to   TEXT,             -- /datadisk/ai-docs-withdrawn/<date>/<file>
    error           TEXT,
    completed_at    TIMESTAMPTZ,

    CONSTRAINT doc_actions_action_ck CHECK (action IN ('withdraw','restore','purge')),
    CONSTRAINT doc_actions_status_ck CHECK (status IN ('pending','complete','failed')),
    CONSTRAINT doc_actions_target_ck CHECK (
        source_file IS NOT NULL OR doc_number IS NOT NULL),
    -- A blank reason is not a reason. "Outdated" is a poor one but it is the
    -- reviewer's to give; an empty string is the API failing to ask.
    CONSTRAINT doc_actions_reason_ck CHECK (length(btrim(reason)) >= 10)
);

CREATE INDEX IF NOT EXISTS doc_actions_doc  ON doc_actions (doc_number, revision);
CREATE INDEX IF NOT EXISTS doc_actions_recent ON doc_actions (acted_at DESC);
CREATE INDEX IF NOT EXISTS doc_actions_pending
    ON doc_actions (acted_at) WHERE status = 'pending';

-- -----------------------------------------------------------------------------
-- Withdrawal is immediate; the file move is not.
--
-- Flipping `superseded` stops citation on the next query — that is the part an
-- operator is waiting for, and it happens inside the HTTP request. Moving the
-- file out of /datadisk/ai-docs needs the worker, because ai-api has no write
-- access to the document tree and is not getting any.
--
-- The file move is archival housekeeping, NOT the safety mechanism. It used to
-- be both: ingest_file() inserted every chunk with superseded = FALSE, so a
-- withdrawn document left in the tree came back LIVE on the next
-- `ai-ingest --all`. That is fixed in ingest.py itself - withdrawal state is
-- read before chunks are replaced and carried through, and --all skips
-- withdrawn documents - so citation stops on the database flip alone, whatever
-- folder the file is in. The move keeps /datadisk/ai-docs meaning "the
-- documents this plant runs on". Until it completes, doc_actions.status stays
-- 'pending' and the UI says so.
-- -----------------------------------------------------------------------------

-- =============================================================================
-- Grants — the API may make a document LESS visible, never more.
--
-- Column-level UPDATE on `superseded` lets ai-api withdraw inside the request,
-- which is what makes withdrawal immediate. It cannot touch chunk_text,
-- doc_number, revision or the embedding, and it cannot INSERT or DELETE.
--
-- But a column grant cannot express "may set TRUE only", so the trigger below
-- does. Restore therefore goes through the worker, the same as publishing:
-- anything that makes a document citable passes through the component that has
-- no HTTP surface, and through a person who gave a reason.
-- =============================================================================

GRANT UPDATE (superseded) ON doc_chunks TO uploads_rw;
GRANT INSERT, UPDATE ON doc_actions TO uploads_rw;
GRANT SELECT ON doc_actions TO uploads_rw, ingest_rw, agent_ro;
GRANT INSERT, UPDATE ON doc_actions TO ingest_rw;
-- No DELETE for anyone. An audit trail that can be edited is a log, not a
-- trail; purge deletes chunks and keeps its own receipt.
REVOKE DELETE ON doc_actions FROM uploads_rw, ingest_rw, agent_ro;

CREATE OR REPLACE FUNCTION doc_chunks_withdraw_only() RETURNS TRIGGER AS $fn$
BEGIN
    -- uploads_rw is the web-facing role. It may withdraw. It may not restore,
    -- and it may not un-supersede a revision that a person withdrew on purpose.
    IF current_user = 'uploads_rw'
       AND NEW.superseded IS DISTINCT FROM TRUE THEN
        RAISE EXCEPTION
            'uploads_rw may set superseded = TRUE only; restoring a document '
            'goes through ai-docs-worker (see db/005_doc_actions.sql)';
    END IF;
    RETURN NEW;
END;
$fn$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS doc_chunks_withdraw_only_tr ON doc_chunks;
CREATE TRIGGER doc_chunks_withdraw_only_tr BEFORE UPDATE ON doc_chunks
    FOR EACH ROW EXECUTE FUNCTION doc_chunks_withdraw_only();

-- -----------------------------------------------------------------------------
-- withdrawn_documents — the counterpart of live_documents.
--
-- What has been taken out, when and by whom. The UI needs it for the Withdrawn
-- tab, and an engineer needs it for "why can the assistant no longer find
-- WRPS-OPS-014?", which is otherwise a question with no answer anywhere.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW withdrawn_documents AS
SELECT c.doc_number,
       c.revision,
       c.doc_type,
       max(c.effective_date)  AS effective_date,
       min(c.source_file)     AS source_file,
       count(*)               AS chunk_count,
       max(a.acted_at)        AS withdrawn_at,
       max(a.actor)           AS withdrawn_by,
       max(a.reason)          AS reason
  FROM doc_chunks c
  LEFT JOIN doc_actions a
         ON a.action = 'withdraw'
        AND a.status = 'complete'
        AND (a.source_file = c.source_file
             OR (a.doc_number = c.doc_number AND a.revision = c.revision))
 WHERE c.superseded = TRUE
 GROUP BY c.doc_number, c.revision, c.doc_type;

GRANT SELECT ON withdrawn_documents TO agent_ro, uploads_rw, ingest_rw;

-- A revision superseded by the normal ingest flow, before this table existed,
-- appears here with NULL actor and reason. That is accurate: nobody recorded
-- who withdrew it, because nothing asked. Do not backfill a name.

-- =============================================================================
-- Phase 9 gate — the withdrawal half. Verify, do not assume.
--
--   As uploads_rw:
--     UPDATE doc_chunks SET superseded = TRUE  WHERE ...;   -- must work
--     UPDATE doc_chunks SET superseded = FALSE WHERE ...;   -- must be REJECTED
--     UPDATE doc_chunks SET chunk_text = 'x'   WHERE ...;   -- must be REJECTED
--     DELETE FROM doc_chunks WHERE ...;                     -- must be REJECTED
--     INSERT INTO doc_actions (...) VALUES (..., reason => '');
--                                                           -- must be REJECTED
--     DELETE FROM doc_actions;                              -- must be REJECTED
--
--   End to end: withdraw a test procedure, then ask the question that used to
--   cite it. The answer must stop citing it IMMEDIATELY - not after a restart,
--   not after a re-index. Then confirm the file left /datadisk/ai-docs, and
--   run `ai-ingest --all`, and confirm it did NOT come back.
-- =============================================================================
