-- 006: publishing pipeline (drafts -> approval -> scheduling -> publication) CREATE TABLE drafts ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, author_user_id uuid NOT NULL REFERENCES users(id), post jsonb NOT NULL, state text NOT NULL DEFAULT 'editing' CHECK (state IN ('editing', 'in_review', 'approved', 'rejected', 'scheduled', 'published')), reviewer_user_id uuid REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE scheduled_publications ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE, connection_id uuid NOT NULL REFERENCES connections(id), publish_at timestamptz NOT NULL, idempotency_key text NOT NULL UNIQUE, state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'in_flight', 'done', 'failed')), error text, UNIQUE (draft_id) ); CREATE TABLE publications ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE, connection_id uuid NOT NULL REFERENCES connections(id), idempotency_key text NOT NULL UNIQUE, remote_id text NOT NULL, remote_url text, published_at timestamptz NOT NULL, provider_metadata jsonb NOT NULL DEFAULT '{}' ); CREATE INDEX drafts_workspace_state ON drafts (workspace_id, state); CREATE INDEX scheduled_publications_due ON scheduled_publications (publish_at) WHERE state = 'pending'; CREATE INDEX publications_draft ON publications (draft_id); CREATE TRIGGER trg_draft_ws_immutable BEFORE UPDATE ON drafts FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_scheduled_pub_ws_immutable BEFORE UPDATE ON scheduled_publications FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); CREATE TRIGGER trg_publication_ws_immutable BEFORE UPDATE ON publications FOR EACH ROW EXECUTE FUNCTION reject_workspace_change(); ALTER TABLE drafts ENABLE ROW LEVEL SECURITY; ALTER TABLE scheduled_publications ENABLE ROW LEVEL SECURITY; ALTER TABLE publications ENABLE ROW LEVEL SECURITY; ALTER TABLE drafts FORCE ROW LEVEL SECURITY; ALTER TABLE scheduled_publications FORCE ROW LEVEL SECURITY; ALTER TABLE publications FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON drafts USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON scheduled_publications USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); CREATE POLICY tenant_isolation ON publications USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid); GRANT SELECT, INSERT, UPDATE, DELETE ON drafts, scheduled_publications, publications TO sp_app; -- Narrow SECURITY DEFINER lookups let the scheduler discover tenant context -- without weakening FORCE RLS for ordinary table access. CREATE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT workspace_id FROM scheduled_publications WHERE idempotency_key = p_idempotency_key $$; CREATE FUNCTION publishing_due_schedules() RETURNS TABLE(idempotency_key text, publish_at timestamptz) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$ SELECT sp.idempotency_key, sp.publish_at FROM scheduled_publications sp WHERE sp.state = 'pending' AND sp.publish_at <= now() ORDER BY sp.publish_at $$; REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC; REVOKE ALL ON FUNCTION publishing_due_schedules() FROM PUBLIC; GRANT EXECUTE ON FUNCTION publishing_schedule_workspace(text), publishing_due_schedules() TO sp_app;