diff --git a/src/KArtSell.DbMigrator/0009_CreateInboxTable.sql b/src/KArtSell.DbMigrator/0009_CreateInboxTable.sql new file mode 100644 index 00000000..bdf347db --- /dev/null +++ b/src/KArtSell.DbMigrator/0009_CreateInboxTable.sql @@ -0,0 +1,59 @@ +-- Migration: Create Inbox table for event-driven async coupling +-- Purpose: Deduplication and idempotent consumption of outbox events +-- PIT Safety: All records are immutable (append-only) + +CREATE TABLE outbox.inbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign key to event + outbox_id UUID NOT NULL, + + -- Consumer identification (e.g., "SignalR", "ApprovalQueue", "AuditLog") + consumer_id VARCHAR(256) NOT NULL, + + -- Event metadata + event_type VARCHAR(256) NOT NULL, + payload JSONB NOT NULL, + + -- Processing status + status VARCHAR(32) NOT NULL DEFAULT 'Pending', -- Pending, Processed, Failed + error_message TEXT, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + attempted_at TIMESTAMP, + processed_at TIMESTAMP, + + -- Constraints + CONSTRAINT inbox_outbox_fk + FOREIGN KEY (outbox_id) REFERENCES outbox.outbox(id) ON DELETE RESTRICT, + + -- Idempotency: Each consumer processes each event exactly once + CONSTRAINT inbox_idempotency + UNIQUE (outbox_id, consumer_id), + + -- Status constraint + CONSTRAINT inbox_status_valid + CHECK (status IN ('Pending', 'Processed', 'Failed')) +); + +-- Indexes for fast lookup +CREATE INDEX inbox_status_idx ON outbox.inbox(status); +CREATE INDEX inbox_created_idx ON outbox.inbox(created_at DESC); +CREATE INDEX inbox_consumer_idx ON outbox.inbox(consumer_id); + +-- Constraint: If processed, must have processed_at +CREATE OR REPLACE FUNCTION outbox.inbox_processed_check() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'Processed' AND NEW.processed_at IS NULL THEN + RAISE EXCEPTION 'processed_at must be set when status = Processed'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER inbox_processed_check_trigger +BEFORE INSERT OR UPDATE ON outbox.inbox +FOR EACH ROW +EXECUTE FUNCTION outbox.inbox_processed_check(); diff --git a/src/KArtSell.DbMigrator/0010_CreateApprovalQueueTable.sql b/src/KArtSell.DbMigrator/0010_CreateApprovalQueueTable.sql new file mode 100644 index 00000000..6419ce43 --- /dev/null +++ b/src/KArtSell.DbMigrator/0010_CreateApprovalQueueTable.sql @@ -0,0 +1,80 @@ +-- Migration: Create Approval Queue table for model activation workflow +-- Purpose: Track models awaiting human approval after shadow run validation +-- PIT Safety: Immutable workflow records (append-only status transitions) + +CREATE TABLE model_operations.approval_queue ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- References + run_id UUID NOT NULL UNIQUE, + model_id UUID NOT NULL, + + -- Workflow status + status VARCHAR(32) NOT NULL DEFAULT 'Pending', -- Pending, Approved, Rejected + requested_by UUID, + approved_by UUID, + + -- Approval details + approval_reason TEXT, + rejection_reason TEXT, + + -- Timestamps + requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + approved_at TIMESTAMP, + rejected_at TIMESTAMP, + + -- Constraints + CONSTRAINT approval_queue_run_fk + FOREIGN KEY (run_id) REFERENCES model_operations.shadow_run(run_id) ON DELETE RESTRICT, + + CONSTRAINT approval_queue_status_valid + CHECK (status IN ('Pending', 'Approved', 'Rejected')), + + CONSTRAINT approval_queue_approval_check + CHECK ( + (status = 'Approved' AND approved_by IS NOT NULL AND approved_at IS NOT NULL) + OR (status != 'Approved') + ), + + CONSTRAINT approval_queue_rejection_check + CHECK ( + (status = 'Rejected' AND rejection_reason IS NOT NULL AND rejected_at IS NOT NULL) + OR (status != 'Rejected') + ) +); + +-- Indexes +CREATE INDEX approval_queue_status_idx ON model_operations.approval_queue(status); +CREATE INDEX approval_queue_model_idx ON model_operations.approval_queue(model_id, requested_at DESC); +CREATE INDEX approval_queue_requested_idx ON model_operations.approval_queue(requested_at DESC); + +-- Trigger: Ensure approval_at is set only when status = 'Approved' +CREATE OR REPLACE FUNCTION model_operations.approval_queue_check() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'Approved' THEN + IF NEW.approved_at IS NULL THEN + NEW.approved_at := CURRENT_TIMESTAMP; + END IF; + IF NEW.approved_by IS NULL THEN + RAISE EXCEPTION 'approved_by must be set when status = Approved'; + END IF; + END IF; + + IF NEW.status = 'Rejected' THEN + IF NEW.rejected_at IS NULL THEN + NEW.rejected_at := CURRENT_TIMESTAMP; + END IF; + IF NEW.rejection_reason IS NULL THEN + RAISE EXCEPTION 'rejection_reason must be set when status = Rejected'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER approval_queue_check_trigger +BEFORE INSERT OR UPDATE ON model_operations.approval_queue +FOR EACH ROW +EXECUTE FUNCTION model_operations.approval_queue_check();