-- 0009_content_drops.sql -- (Renumbered from 0007 — main already holds 0007_surface_bump_policy + 0008_fix_surface_rls_guc.) -- -- Content-drop composer schema. A "drop" sits ABOVE the existing per-surface -- content tables (content_plans / content_assets / content_posts from 0001): -- a cluster of assets + one narrative arc, split into per-surface "legs" -- (anchor NSFW set → onlyfans/fansly · ToS-safe teasers → x/instagram · -- long-form → blog) laid on a tease → drop → follow-up timeline. -- -- Spec: @platform/codebase/@features/ai-copilot/docs/content-drop-composer.screen.md -- Owned by content-social (x/ig/fansly/blog legs) + content-onlyfans (OF leg). -- -- On dispatch a leg materializes a content_plan (per surface) + content_posts -- (per asset); until then the leg holds the derived intent (plan_id NULL). The -- drop itself is a parent agent_actions row (target_kind='content_drop'); each -- leg dispatch is a child row — same lineage pattern as cross-surface-fanout. -- -- Reuses helpers from 0001: current_user_uuid() (RLS) + touch_updated_at(). -- platform.db is Postgres ≥ 14 on black:25437 (INFRA.md §1). BEGIN; -- --------------------------------------------------------------------------- -- Enum types -- --------------------------------------------------------------------------- CREATE TYPE content_drop_state AS ENUM ( 'clustering', -- assets being grouped into the drop 'arc_draft', -- narrative arc drafted; per-surface legs not yet derived 'derived', -- legs derived from the arc; ready for review/schedule 'scheduled', -- timeline set; queued for dispatch 'dispatched', -- handed to the cross-surface fanout machinery 'partial', -- some legs published, some failed 'done', -- all legs resolved 'cancelled' ); CREATE TYPE drop_leg_role AS ENUM ('anchor', 'teaser', 'longform'); CREATE TYPE drop_leg_tos_status AS ENUM ('ok', 'flagged', 'blocked'); -- --------------------------------------------------------------------------- -- content_drops: the narrative bundle (one arc, many legs). -- --------------------------------------------------------------------------- CREATE TABLE content_drops ( 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, title TEXT NOT NULL, arc TEXT NOT NULL DEFAULT '', -- the narrative spine (markdown) state content_drop_state NOT NULL DEFAULT 'clustering', cluster_source TEXT NULL, -- 'shoot:2026-10-03' | 'tag:luxe' | 'manual' tease_at TIMESTAMPTZ NULL, drop_at TIMESTAMPTZ NULL, -- NULL until scheduled followup_at TIMESTAMPTZ NULL, created_by_specialist TEXT NOT NULL DEFAULT 'content-social', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT content_drops_timeline_chk CHECK ( (tease_at IS NULL OR drop_at IS NULL OR tease_at <= drop_at) AND (followup_at IS NULL OR drop_at IS NULL OR followup_at >= drop_at) ) ); CREATE INDEX idx_content_drops_user ON content_drops(user_id, created_at DESC); CREATE INDEX idx_content_drops_org ON content_drops(org_id, created_at DESC) WHERE org_id IS NOT NULL; CREATE INDEX idx_content_drops_state ON content_drops(state); CREATE INDEX idx_content_drops_drop_at ON content_drops(drop_at) WHERE drop_at IS NOT NULL; -- --------------------------------------------------------------------------- -- content_drop_assets: the ordered asset cluster backing a drop. -- --------------------------------------------------------------------------- CREATE TABLE content_drop_assets ( drop_id UUID NOT NULL REFERENCES content_drops(id) ON DELETE CASCADE, asset_id UUID NOT NULL REFERENCES content_assets(id) ON DELETE CASCADE, sort_order INT NOT NULL DEFAULT 0, PRIMARY KEY (drop_id, asset_id) ); CREATE INDEX idx_content_drop_assets_asset ON content_drop_assets(asset_id); -- --------------------------------------------------------------------------- -- content_drop_legs: one per target surface, or a brand-site for the longform -- leg. Exactly one of (surface, brand_site_target) is set per leg. -- --------------------------------------------------------------------------- CREATE TABLE content_drop_legs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), drop_id UUID NOT NULL REFERENCES content_drops(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- denormalized for RLS org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, surface surface_kind NULL, -- external-platform target (of/fansly/x/instagram/...) brand_site_target TEXT NULL, -- longform/blog target (N3); placeholder until brand_site_kind ENUM lands (0002 deferral) role drop_leg_role NOT NULL, asset_ids UUID[] NOT NULL DEFAULT '{}', -- subset of the drop cluster this leg uses caption TEXT NOT NULL DEFAULT '', link_target TEXT NULL, -- K3b: never onlyfans.com/fansly.com from SFW surfaces (storefront redirect) ppv_price_cents INT NULL, -- anchor legs (OF/Fansly) only ppv_currency TEXT NULL, -- ISO 4217 when ppv_price_cents set tos_status drop_leg_tos_status NOT NULL DEFAULT 'ok', plan_id UUID NULL REFERENCES content_plans(id) ON DELETE SET NULL, -- set when the leg is dispatched created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT drop_leg_target_chk CHECK ( (surface IS NOT NULL AND brand_site_target IS NULL) OR (surface IS NULL AND brand_site_target IS NOT NULL) ), CONSTRAINT drop_leg_ppv_chk CHECK ( ppv_price_cents IS NULL OR (ppv_price_cents >= 0 AND ppv_currency ~ '^[A-Z]{3}$') ), -- One leg per surface per drop (NULLs distinct → multiple brand-site legs allowed). CONSTRAINT uq_drop_leg_surface UNIQUE (drop_id, surface) ); CREATE INDEX idx_content_drop_legs_drop ON content_drop_legs(drop_id); CREATE INDEX idx_content_drop_legs_user ON content_drop_legs(user_id, created_at DESC); CREATE INDEX idx_content_drop_legs_tos ON content_drop_legs(tos_status) WHERE tos_status <> 'ok'; CREATE INDEX idx_content_drop_legs_plan ON content_drop_legs(plan_id) WHERE plan_id IS NOT NULL; -- --------------------------------------------------------------------------- -- updated_at triggers (touch_updated_at() defined in 0001). -- --------------------------------------------------------------------------- CREATE TRIGGER trg_touch_content_drops BEFORE UPDATE ON content_drops FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); CREATE TRIGGER trg_touch_content_drop_legs BEFORE UPDATE ON content_drop_legs FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); -- --------------------------------------------------------------------------- -- Row-level security (defense-in-depth, per 0001 convention). -- current_user_uuid() reads the app.current_user_id GUC (defined in 0001). -- --------------------------------------------------------------------------- ALTER TABLE content_drops ENABLE ROW LEVEL SECURITY; ALTER TABLE content_drop_legs ENABLE ROW LEVEL SECURITY; ALTER TABLE content_drop_assets ENABLE ROW LEVEL SECURITY; -- user/org-scoped tables: same policy shape as 0001's content_* tables. DO $$ DECLARE t TEXT; BEGIN FOREACH t IN ARRAY ARRAY['content_drops','content_drop_legs'] 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 $$; -- content_drop_assets has no own tenancy columns: inherit from the parent drop. CREATE POLICY tenant_isolation ON content_drop_assets USING ( drop_id IN ( SELECT id FROM content_drops WHERE 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 ( drop_id IN ( SELECT id FROM content_drops WHERE 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())) ) ); COMMENT ON TABLE content_drops IS 'Narrative content drop: a cluster of assets + one arc, split into per-surface legs on a tease/drop/follow-up timeline. Sits above content_plans/posts; legs materialize plans+posts on dispatch.'; COMMIT;