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:
parent
b3a63c7f52
commit
71d307b775
1 changed files with 279 additions and 0 deletions
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue