diff --git a/@platform/infrastructure/sql/migrations/0001_tenancy_and_content.sql b/@platform/infrastructure/sql/migrations/0001_tenancy_and_content.sql new file mode 100644 index 0000000..1bbec1b --- /dev/null +++ b/@platform/infrastructure/sql/migrations/0001_tenancy_and_content.sql @@ -0,0 +1,279 @@ +-- 0001_tenancy_and_content.sql +-- +-- Foundational schema for platform.db (V3 — black:25437). +-- Org-aware from day one per DESIGN §5. Row-level tenancy per INFRA §6 Option A. +-- agent_actions is the audit spine — every specialist decision writes here. +-- +-- This is a NEW database. v2's quinn.db (black:25435) is untouched; V3 ↔ v2 talk +-- over HTTP/MCP only, never cross-DB SQL. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- Extensions +-- --------------------------------------------------------------------------- +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() +CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive email + +-- --------------------------------------------------------------------------- +-- Enum types +-- --------------------------------------------------------------------------- +CREATE TYPE surface_kind AS ENUM ( + 'onlyfans', 'x', 'instagram', 'tiktok', 'threads', + 'youtube', 'twitch', 'facebook', + 'tryst', 'ts4rent', 'slixa', 'eros' +); + +CREATE TYPE content_plan_status AS ENUM ( + 'draft', 'pending_approval', 'approved', 'scheduled', 'completed', 'cancelled' +); + +CREATE TYPE content_post_status AS ENUM ( + 'queued', 'scheduled', 'published', 'failed', 'cancelled' +); + +CREATE TYPE approval_state AS ENUM ( + 'not_required', 'pending', 'approved', 'rejected' +); + +CREATE TYPE action_stakes AS ENUM ('low', 'medium', 'high'); + +CREATE TYPE engagement_kind AS ENUM ( + 'dm', 'comment', 'reply', 'mention', 'follow', 'subscribe', 'tip', 'ppv_purchase' +); + +CREATE TYPE org_member_role AS ENUM ('owner', 'admin', 'member'); + +-- --------------------------------------------------------------------------- +-- Tenancy: users, orgs, org_members +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9_-]{1,62}$'), + email CITEXT UNIQUE, + display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE orgs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9_-]{1,62}$'), + name TEXT NOT NULL, + owner_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE org_members ( + org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role org_member_role NOT NULL, + joined_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, user_id) +); +CREATE INDEX idx_org_members_user ON org_members(user_id); + +-- --------------------------------------------------------------------------- +-- Personas: one row per user. JSONB facets per surface. +-- --------------------------------------------------------------------------- +CREATE TABLE personas ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + brand_voice JSONB NOT NULL DEFAULT '{}'::jsonb, -- global voice/tone defaults + facets JSONB NOT NULL DEFAULT '{}'::jsonb, -- { of: {...}, x: {...}, instagram: {...} } + off_limits JSONB NOT NULL DEFAULT '[]'::jsonb, -- array of off-limit topics/kinks + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- Content plans: calendar entries owned by a specialist for a surface. +-- --------------------------------------------------------------------------- +CREATE TABLE content_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, + surface surface_kind NOT NULL, + planned_for TIMESTAMPTZ NOT NULL, + status content_plan_status NOT NULL DEFAULT 'draft', + plan_json JSONB NOT NULL, -- structured plan body (theme, beats, refs) + created_by_specialist TEXT NOT NULL, -- e.g. 'content-onlyfans', 'ai-copilot' + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_content_plans_user_planned ON content_plans(user_id, planned_for); +CREATE INDEX idx_content_plans_org_planned ON content_plans(org_id, planned_for) WHERE org_id IS NOT NULL; +CREATE INDEX idx_content_plans_surface_status ON content_plans(surface, status); + +-- --------------------------------------------------------------------------- +-- Content assets: media files in MinIO, with tags + persona facet pre-classified. +-- --------------------------------------------------------------------------- +CREATE TABLE content_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, + source TEXT NOT NULL, -- 'ios-upload', 'shoot-import', etc. + media_ref TEXT NOT NULL, -- MinIO object key + mime_type TEXT NOT NULL, + tags TEXT[] NOT NULL DEFAULT '{}', + persona_facet TEXT NULL, -- which persona facet this fits (e.g. 'of', 'x_sfw') + captions JSONB NOT NULL DEFAULT '[]'::jsonb,-- [{surface, text, confidence, model}, ...] + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id, media_ref) +); +CREATE INDEX idx_content_assets_user ON content_assets(user_id, created_at DESC); +CREATE INDEX idx_content_assets_org ON content_assets(org_id, created_at DESC) WHERE org_id IS NOT NULL; +CREATE INDEX idx_content_assets_tags ON content_assets USING GIN (tags); +CREATE INDEX idx_content_assets_facet ON content_assets(persona_facet) WHERE persona_facet IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Content posts: scheduled/published units. Joins a plan + asset to a surface. +-- --------------------------------------------------------------------------- +CREATE TABLE content_posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plan_id UUID NOT NULL REFERENCES content_plans(id) ON DELETE CASCADE, + asset_id UUID NULL REFERENCES content_assets(id) ON DELETE SET NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, + surface surface_kind NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + published_at TIMESTAMPTZ NULL, + external_id TEXT NULL, -- platform-native post ID after publish + status content_post_status NOT NULL DEFAULT 'queued', + confidence NUMERIC(4,3) NOT NULL CHECK (confidence BETWEEN 0 AND 1), + approval_state approval_state NOT NULL DEFAULT 'pending', + approved_by UUID NULL REFERENCES users(id), + approved_at TIMESTAMPTZ NULL, + failure_reason TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_content_posts_user_scheduled ON content_posts(user_id, scheduled_for); +CREATE INDEX idx_content_posts_surface_status ON content_posts(surface, status); +CREATE INDEX idx_content_posts_approval_pending ON content_posts(user_id, scheduled_for) + WHERE approval_state = 'pending'; +CREATE UNIQUE INDEX uq_content_posts_external ON content_posts(surface, external_id) + WHERE external_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- Agent actions: append-only audit spine. Every specialist decision lands here. +-- --------------------------------------------------------------------------- +CREATE TABLE agent_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + org_id UUID NULL REFERENCES orgs(id) ON DELETE RESTRICT, + specialist_id TEXT NOT NULL, -- 'ai-copilot', 'content-onlyfans', etc. + action_type TEXT NOT NULL, -- 'publish_post', 'send_dm', 'set_ppv_price', ... + target_kind TEXT NOT NULL, -- 'content_post', 'engagement_event', 'prospect', ... + target_id UUID NULL, -- nullable: some actions target external state with no row yet + stakes action_stakes NOT NULL, + confidence NUMERIC(4,3) NOT NULL CHECK (confidence BETWEEN 0 AND 1), + auto_executed BOOLEAN NOT NULL, + approved_by UUID NULL REFERENCES users(id), + approved_at TIMESTAMPTZ NULL, + outcome_json JSONB NULL, -- result payload (success: external IDs; failure: error trace) + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- Append-only: no UPDATE/DELETE permitted (enforced via revoked grants at deploy time). +CREATE INDEX idx_agent_actions_user_created ON agent_actions(user_id, created_at DESC); +CREATE INDEX idx_agent_actions_specialist ON agent_actions(specialist_id, created_at DESC); +CREATE INDEX idx_agent_actions_target ON agent_actions(target_kind, target_id); +CREATE INDEX idx_agent_actions_pending_approval ON agent_actions(user_id, created_at DESC) + WHERE auto_executed = false AND approved_at IS NULL; + +-- --------------------------------------------------------------------------- +-- Engagement events: normalized inbound across surfaces. Joins on prospect_id. +-- prospect_id is opaque here; resolution lives in quinn-prospector (MCP service). +-- --------------------------------------------------------------------------- +CREATE TABLE engagement_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, + surface surface_kind NOT NULL, + kind engagement_kind NOT NULL, + external_event_id TEXT NOT NULL, -- platform-native event ID + external_actor_id TEXT NOT NULL, -- platform-native sender ID + prospect_id UUID NULL, -- resolved by prospect-resolver worker (P4) + payload_json JSONB NOT NULL, -- normalized event body + occurred_at TIMESTAMPTZ NOT NULL, -- when event happened on the platform + ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (surface, external_event_id) +); +CREATE INDEX idx_engagement_user_occurred ON engagement_events(user_id, occurred_at DESC); +CREATE INDEX idx_engagement_surface_actor ON engagement_events(surface, external_actor_id); +CREATE INDEX idx_engagement_prospect ON engagement_events(prospect_id, occurred_at DESC) + WHERE prospect_id IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- updated_at triggers +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS TRIGGER + LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +DO $$ +DECLARE t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'users','orgs','personas','content_plans','content_assets','content_posts' + ] LOOP + EXECUTE format( + 'CREATE TRIGGER trg_touch_%1$s BEFORE UPDATE ON %1$s + FOR EACH ROW EXECUTE FUNCTION touch_updated_at();', t); + END LOOP; +END $$; + +-- --------------------------------------------------------------------------- +-- Row-level security — defense-in-depth per INFRA §6 Option A. +-- Application layer remains the primary enforcer; RLS catches bugs. +-- Convention: app sets app.current_user_id GUC per session/connection. +-- --------------------------------------------------------------------------- +ALTER TABLE personas ENABLE ROW LEVEL SECURITY; +ALTER TABLE content_plans ENABLE ROW LEVEL SECURITY; +ALTER TABLE content_assets ENABLE ROW LEVEL SECURITY; +ALTER TABLE content_posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE agent_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE engagement_events ENABLE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION current_user_uuid() RETURNS UUID + LANGUAGE sql STABLE AS $$ + SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid +$$; + +-- personas: user-scoped only (no org_id column). +CREATE POLICY tenant_isolation ON personas + USING (user_id = current_user_uuid()) + WITH CHECK (user_id = current_user_uuid()); + +-- Tables with both user_id and org_id: user owns OR caller is a member of the org. +DO $$ +DECLARE t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'content_plans','content_assets','content_posts', + 'agent_actions','engagement_events' + ] LOOP + EXECUTE format($p$ + CREATE POLICY tenant_isolation ON %1$s + USING ( + user_id = current_user_uuid() + OR (org_id IS NOT NULL + AND org_id IN (SELECT org_id FROM org_members + WHERE user_id = current_user_uuid())) + ) + WITH CHECK ( + user_id = current_user_uuid() + OR (org_id IS NOT NULL + AND org_id IN (SELECT org_id FROM org_members + WHERE user_id = current_user_uuid())) + ); + $p$, t); + END LOOP; +END $$; + +COMMIT;