db(migrations-specific): 🗃️ Introduce migration 0001_tenancy_and_content.sql to define tenancy and content tables/constraints

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 07:41:16 -07:00
parent b3a63c7f52
commit 71d307b775

View file

@ -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;