initial commit: rocket.new export of broadcastbeat

This commit is contained in:
Ryan Salazar
2026-05-07 16:39:17 +00:00
commit 70aa1ad46e
302 changed files with 60710 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
-- Migration: reading_list and article_analytics tables
-- Timestamp: 20260312053000
-- ============================================================
-- 1. TABLES
-- ============================================================
-- Reading List: stores articles saved by users
CREATE TABLE IF NOT EXISTS public.reading_list (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
article_slug TEXT NOT NULL,
article_title TEXT NOT NULL,
article_excerpt TEXT,
article_image TEXT,
article_image_alt TEXT,
article_category TEXT,
article_author TEXT,
article_read_time TEXT,
article_date TEXT,
saved_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Article Analytics: tracks views, read time, and engagement
CREATE TABLE IF NOT EXISTS public.article_analytics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_slug TEXT NOT NULL,
article_title TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
session_id TEXT,
event_type TEXT NOT NULL,
read_time_seconds INTEGER DEFAULT 0,
scroll_depth INTEGER DEFAULT 0,
referrer TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 2. INDEXES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_reading_list_user_id ON public.reading_list(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_list_article_slug ON public.reading_list(article_slug);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reading_list_user_article ON public.reading_list(user_id, article_slug);
CREATE INDEX IF NOT EXISTS idx_article_analytics_slug ON public.article_analytics(article_slug);
CREATE INDEX IF NOT EXISTS idx_article_analytics_user_id ON public.article_analytics(user_id);
CREATE INDEX IF NOT EXISTS idx_article_analytics_event_type ON public.article_analytics(event_type);
CREATE INDEX IF NOT EXISTS idx_article_analytics_created_at ON public.article_analytics(created_at);
-- ============================================================
-- 3. ENABLE RLS
-- ============================================================
ALTER TABLE public.reading_list ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.article_analytics ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 4. RLS POLICIES
-- ============================================================
-- Reading List: users manage their own saved articles
DROP POLICY IF EXISTS "users_manage_own_reading_list" ON public.reading_list;
CREATE POLICY "users_manage_own_reading_list"
ON public.reading_list
FOR ALL
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Article Analytics: anyone can insert (track views), users can read own data
DROP POLICY IF EXISTS "public_insert_article_analytics" ON public.article_analytics;
CREATE POLICY "public_insert_article_analytics"
ON public.article_analytics
FOR INSERT
TO public
WITH CHECK (true);
DROP POLICY IF EXISTS "users_read_own_article_analytics" ON public.article_analytics;
CREATE POLICY "users_read_own_article_analytics"
ON public.article_analytics
FOR SELECT
TO authenticated
USING (user_id = auth.uid() OR user_id IS NULL);

View File

@@ -0,0 +1,127 @@
-- ============================================================
-- Migration: user_profiles + article_comments
-- Timestamp: 20260312060000
-- ============================================================
-- 1. user_profiles table
CREATE TABLE IF NOT EXISTS public.user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT NOT NULL DEFAULT '',
avatar_url TEXT,
bio TEXT,
website TEXT,
location TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_profiles_id ON public.user_profiles(id);
-- 2. article_comments table
CREATE TABLE IF NOT EXISTS public.article_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
article_slug TEXT NOT NULL,
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_article_comments_slug ON public.article_comments(article_slug);
CREATE INDEX IF NOT EXISTS idx_article_comments_user_id ON public.article_comments(user_id);
-- 3. Functions (BEFORE RLS policies)
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
INSERT INTO public.user_profiles (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
COALESCE(NEW.raw_user_meta_data->>'avatar_url', '')
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;
-- 4. Enable RLS
ALTER TABLE public.user_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.article_comments ENABLE ROW LEVEL SECURITY;
-- 5. RLS Policies for user_profiles
DROP POLICY IF EXISTS "users_manage_own_user_profiles" ON public.user_profiles;
CREATE POLICY "users_manage_own_user_profiles"
ON public.user_profiles
FOR ALL
TO authenticated
USING (id = auth.uid())
WITH CHECK (id = auth.uid());
DROP POLICY IF EXISTS "public_read_user_profiles" ON public.user_profiles;
CREATE POLICY "public_read_user_profiles"
ON public.user_profiles
FOR SELECT
TO public
USING (true);
-- 6. RLS Policies for article_comments
DROP POLICY IF EXISTS "public_read_article_comments" ON public.article_comments;
CREATE POLICY "public_read_article_comments"
ON public.article_comments
FOR SELECT
TO public
USING (true);
DROP POLICY IF EXISTS "users_insert_own_comments" ON public.article_comments;
CREATE POLICY "users_insert_own_comments"
ON public.article_comments
FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS "users_update_own_comments" ON public.article_comments;
CREATE POLICY "users_update_own_comments"
ON public.article_comments
FOR UPDATE
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS "users_delete_own_comments" ON public.article_comments;
CREATE POLICY "users_delete_own_comments"
ON public.article_comments
FOR DELETE
TO authenticated
USING (user_id = auth.uid());
-- 7. Triggers
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
DROP TRIGGER IF EXISTS update_user_profiles_updated_at ON public.user_profiles;
CREATE TRIGGER update_user_profiles_updated_at
BEFORE UPDATE ON public.user_profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
DROP TRIGGER IF EXISTS update_article_comments_updated_at ON public.article_comments;
CREATE TRIGGER update_article_comments_updated_at
BEFORE UPDATE ON public.article_comments
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();

View File

@@ -0,0 +1,95 @@
-- ============================================================
-- Migration: wp_imported_posts
-- Timestamp: 20260320230000
-- ============================================================
-- 1. wp_imported_posts table
CREATE TABLE IF NOT EXISTS public.wp_imported_posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wp_id INTEGER NOT NULL,
wp_slug TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT,
author_name TEXT,
category TEXT,
tags TEXT[],
featured_image TEXT,
featured_image_alt TEXT,
status TEXT NOT NULL DEFAULT 'published',
wp_published_at TIMESTAMPTZ,
imported_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wp_imported_posts_wp_id ON public.wp_imported_posts(wp_id);
CREATE INDEX IF NOT EXISTS idx_wp_imported_posts_slug ON public.wp_imported_posts(wp_slug);
CREATE INDEX IF NOT EXISTS idx_wp_imported_posts_imported_at ON public.wp_imported_posts(imported_at DESC);
-- 2. wp_import_logs table (tracks import runs)
CREATE TABLE IF NOT EXISTS public.wp_import_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
total_fetched INTEGER DEFAULT 0,
total_imported INTEGER DEFAULT 0,
total_skipped INTEGER DEFAULT 0,
total_errors INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
error_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_wp_import_logs_started_at ON public.wp_import_logs(started_at DESC);
-- 3. update_updated_at function (reuse if exists)
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;
-- 4. Enable RLS
ALTER TABLE public.wp_imported_posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.wp_import_logs ENABLE ROW LEVEL SECURITY;
-- 5. RLS Policies for wp_imported_posts
DROP POLICY IF EXISTS "public_read_wp_imported_posts" ON public.wp_imported_posts;
CREATE POLICY "public_read_wp_imported_posts"
ON public.wp_imported_posts
FOR SELECT
TO public
USING (true);
DROP POLICY IF EXISTS "authenticated_manage_wp_imported_posts" ON public.wp_imported_posts;
CREATE POLICY "authenticated_manage_wp_imported_posts"
ON public.wp_imported_posts
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- 6. RLS Policies for wp_import_logs
DROP POLICY IF EXISTS "public_read_wp_import_logs" ON public.wp_import_logs;
CREATE POLICY "public_read_wp_import_logs"
ON public.wp_import_logs
FOR SELECT
TO public
USING (true);
DROP POLICY IF EXISTS "authenticated_manage_wp_import_logs" ON public.wp_import_logs;
CREATE POLICY "authenticated_manage_wp_import_logs"
ON public.wp_import_logs
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- 7. Trigger for updated_at
DROP TRIGGER IF EXISTS update_wp_imported_posts_updated_at ON public.wp_imported_posts;
CREATE TRIGGER update_wp_imported_posts_updated_at
BEFORE UPDATE ON public.wp_imported_posts
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();

View File

@@ -0,0 +1,50 @@
-- ============================================================
-- Migration: post_images_storage
-- Timestamp: 20260321065000
-- Creates storage bucket for WordPress imported post images
-- ============================================================
-- 1. Create the storage bucket for post images
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'post-images',
'post-images',
true,
10485760, -- 10MB limit
ARRAY['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
)
ON CONFLICT (id) DO NOTHING;
-- 2. RLS Policies for storage.objects (post-images bucket)
-- Public read access
DROP POLICY IF EXISTS "post_images_public_read" ON storage.objects;
CREATE POLICY "post_images_public_read"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'post-images');
-- Authenticated users can upload
DROP POLICY IF EXISTS "post_images_authenticated_insert" ON storage.objects;
CREATE POLICY "post_images_authenticated_insert"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'post-images');
-- Authenticated users can update
DROP POLICY IF EXISTS "post_images_authenticated_update" ON storage.objects;
CREATE POLICY "post_images_authenticated_update"
ON storage.objects
FOR UPDATE
TO authenticated
USING (bucket_id = 'post-images');
-- Authenticated users can delete
DROP POLICY IF EXISTS "post_images_authenticated_delete" ON storage.objects;
CREATE POLICY "post_images_authenticated_delete"
ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'post-images');

View File

@@ -0,0 +1,53 @@
-- ============================================================
-- Migration: native_articles
-- Timestamp: 20260321070000
-- ============================================================
-- 1. native_articles table
CREATE TABLE IF NOT EXISTS public.native_articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL,
title TEXT NOT NULL,
excerpt TEXT,
content TEXT,
author_name TEXT,
author_id UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
category TEXT,
tags TEXT[],
featured_image TEXT,
featured_image_alt TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_native_articles_slug ON public.native_articles(slug);
CREATE INDEX IF NOT EXISTS idx_native_articles_status ON public.native_articles(status);
CREATE INDEX IF NOT EXISTS idx_native_articles_created_at ON public.native_articles(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_native_articles_author_id ON public.native_articles(author_id);
-- 2. Enable RLS
ALTER TABLE public.native_articles ENABLE ROW LEVEL SECURITY;
-- 3. RLS Policies
DROP POLICY IF EXISTS "public_read_published_native_articles" ON public.native_articles;
CREATE POLICY "public_read_published_native_articles"
ON public.native_articles
FOR SELECT
TO public
USING (status = 'published');
DROP POLICY IF EXISTS "authenticated_manage_native_articles" ON public.native_articles;
CREATE POLICY "authenticated_manage_native_articles"
ON public.native_articles
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- 4. Trigger for updated_at
DROP TRIGGER IF EXISTS update_native_articles_updated_at ON public.native_articles;
CREATE TRIGGER update_native_articles_updated_at
BEFORE UPDATE ON public.native_articles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();

View File

@@ -0,0 +1,51 @@
-- ============================================================
-- Migration: article_status_scheduling
-- Timestamp: 20260321080000
-- Adds: pending status support + scheduled_at column to both article tables
-- ============================================================
-- 1. Add scheduled_at to wp_imported_posts
ALTER TABLE public.wp_imported_posts
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS submitted_for_review_at TIMESTAMPTZ;
-- 2. Add scheduled_at to native_articles
ALTER TABLE public.native_articles
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS submitted_for_review_at TIMESTAMPTZ;
-- 3. Add status index for pending on both tables
CREATE INDEX IF NOT EXISTS idx_wp_imported_posts_status ON public.wp_imported_posts(status);
CREATE INDEX IF NOT EXISTS idx_wp_imported_posts_scheduled_at ON public.wp_imported_posts(scheduled_at) WHERE scheduled_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_native_articles_scheduled_at ON public.native_articles(scheduled_at) WHERE scheduled_at IS NOT NULL;
-- 4. Function to auto-publish scheduled articles (called via cron or on-demand)
CREATE OR REPLACE FUNCTION public.publish_scheduled_articles()
RETURNS INTEGER
LANGUAGE plpgsql
AS $$
DECLARE
published_count INTEGER := 0;
native_count INTEGER := 0;
BEGIN
-- Publish scheduled wp_imported_posts
UPDATE public.wp_imported_posts
SET status = 'published', scheduled_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE status = 'draft'
AND scheduled_at IS NOT NULL
AND scheduled_at <= CURRENT_TIMESTAMP;
GET DIAGNOSTICS published_count = ROW_COUNT;
-- Publish scheduled native_articles
UPDATE public.native_articles
SET status = 'published', scheduled_at = NULL, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE status = 'draft'
AND scheduled_at IS NOT NULL
AND scheduled_at <= CURRENT_TIMESTAMP;
GET DIAGNOSTICS native_count = ROW_COUNT;
published_count := published_count + native_count;
RETURN published_count;
END;
$$;

View File

@@ -0,0 +1,93 @@
-- ============================================================
-- Migration: user_roles_permissions + contributor_invitations
-- Timestamp: 20260321090000
-- ============================================================
-- 1. Add role column to user_profiles
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'reader';
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true;
-- 2. contributor_invitations table
CREATE TABLE IF NOT EXISTS public.contributor_invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
invited_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
role TEXT NOT NULL DEFAULT 'contributor',
token TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
message TEXT,
expires_at TIMESTAMPTZ NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '7 days'),
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_contributor_invitations_email ON public.contributor_invitations(email);
CREATE INDEX IF NOT EXISTS idx_contributor_invitations_token ON public.contributor_invitations(token);
CREATE INDEX IF NOT EXISTS idx_contributor_invitations_status ON public.contributor_invitations(status);
-- 3. Functions (BEFORE RLS policies)
CREATE OR REPLACE FUNCTION public.is_admin_user()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM auth.users au
WHERE au.id = auth.uid()
AND (
au.raw_user_meta_data->>'role' = 'admin'
OR au.raw_app_meta_data->>'role' = 'admin'
)
)
$$;
CREATE OR REPLACE FUNCTION public.get_user_role()
RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT COALESCE(
(SELECT role FROM public.user_profiles WHERE id = auth.uid() LIMIT 1),
'reader'
)
$$;
-- 4. Enable RLS
ALTER TABLE public.contributor_invitations ENABLE ROW LEVEL SECURITY;
-- 5. RLS Policies for contributor_invitations
DROP POLICY IF EXISTS "admin_manage_invitations" ON public.contributor_invitations;
CREATE POLICY "admin_manage_invitations"
ON public.contributor_invitations
FOR ALL
TO authenticated
USING (public.is_admin_user())
WITH CHECK (public.is_admin_user());
DROP POLICY IF EXISTS "public_read_own_invitation_by_token" ON public.contributor_invitations;
CREATE POLICY "public_read_own_invitation_by_token"
ON public.contributor_invitations
FOR SELECT
TO public
USING (true);
-- 6. RLS Policies for user_profiles (admin can manage all)
DROP POLICY IF EXISTS "admin_manage_all_user_profiles" ON public.user_profiles;
CREATE POLICY "admin_manage_all_user_profiles"
ON public.user_profiles
FOR ALL
TO authenticated
USING (public.is_admin_user())
WITH CHECK (public.is_admin_user());
-- 7. Trigger for updated_at on invitations
DROP TRIGGER IF EXISTS update_contributor_invitations_updated_at ON public.contributor_invitations;
CREATE TRIGGER update_contributor_invitations_updated_at
BEFORE UPDATE ON public.contributor_invitations
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();

View File

@@ -0,0 +1,97 @@
-- Audit Logs Migration
-- Tracks all admin actions: article edits, user role changes, publish events, deletions
-- 1. Create audit_action_type enum
DROP TYPE IF EXISTS public.audit_action_type CASCADE;
CREATE TYPE public.audit_action_type AS ENUM (
'article_created',
'article_edited',
'article_published',
'article_unpublished',
'article_deleted',
'article_archived',
'article_status_changed',
'user_role_changed',
'user_activated',
'user_deactivated',
'user_invited',
'user_invitation_revoked',
'import_completed',
'import_failed'
);
-- 2. Create audit_logs table
CREATE TABLE IF NOT EXISTS public.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action public.audit_action_type NOT NULL,
performed_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
performed_by_email TEXT,
affected_item_id TEXT,
affected_item_type TEXT,
affected_item_title TEXT,
old_value JSONB,
new_value JSONB,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- 3. Indexes
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON public.audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_performed_by ON public.audit_logs(performed_by);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON public.audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_affected_item_id ON public.audit_logs(affected_item_id);
-- 4. Enable RLS
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
-- 5. RLS Policies — only admins and editors can read; system inserts via service role
DROP POLICY IF EXISTS "admins_read_audit_logs" ON public.audit_logs;
CREATE POLICY "admins_read_audit_logs"
ON public.audit_logs
FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('admin', 'editor')
)
);
DROP POLICY IF EXISTS "authenticated_insert_audit_logs" ON public.audit_logs;
CREATE POLICY "authenticated_insert_audit_logs"
ON public.audit_logs
FOR INSERT
TO authenticated
WITH CHECK (true);
-- 6. Seed sample audit log data for demonstration
DO $$
DECLARE
existing_user_id UUID;
existing_user_email TEXT;
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_profiles'
) THEN
SELECT id, email INTO existing_user_id, existing_user_email
FROM public.user_profiles LIMIT 1;
IF existing_user_id IS NOT NULL THEN
INSERT INTO public.audit_logs (id, action, performed_by, performed_by_email, affected_item_id, affected_item_type, affected_item_title, old_value, new_value, created_at)
VALUES
(gen_random_uuid(), 'article_published', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'article', 'Broadcast Technology Trends 2026', NULL, jsonb_build_object('status', 'published'), NOW() - INTERVAL '2 hours'),
(gen_random_uuid(), 'user_role_changed', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'user', 'jane.doe@example.com', jsonb_build_object('role', 'reader'), jsonb_build_object('role', 'contributor'), NOW() - INTERVAL '4 hours'),
(gen_random_uuid(), 'article_edited', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'article', 'IP Video Production Guide', jsonb_build_object('status', 'draft'), jsonb_build_object('status', 'draft', 'title', 'IP Video Production Guide Updated'), NOW() - INTERVAL '6 hours'),
(gen_random_uuid(), 'article_deleted', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'article', 'Old NAB Show Coverage 2020', jsonb_build_object('status', 'archived'), NULL, NOW() - INTERVAL '1 day'),
(gen_random_uuid(), 'user_invited', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'user', 'newcontributor@example.com', NULL, jsonb_build_object('role', 'contributor'), NOW() - INTERVAL '1 day 2 hours'),
(gen_random_uuid(), 'article_status_changed', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'article', 'Cloud Production Workflows', jsonb_build_object('status', 'pending'), jsonb_build_object('status', 'published'), NOW() - INTERVAL '2 days'),
(gen_random_uuid(), 'import_completed', existing_user_id, existing_user_email, NULL, 'import', 'WordPress Import Batch', NULL, jsonb_build_object('imported_count', 42, 'failed_count', 2), NOW() - INTERVAL '3 days'),
(gen_random_uuid(), 'user_deactivated', existing_user_id, existing_user_email, gen_random_uuid()::TEXT, 'user', 'spammer@example.com', jsonb_build_object('is_active', true), jsonb_build_object('is_active', false), NOW() - INTERVAL '4 days')
ON CONFLICT (id) DO NOTHING;
END IF;
END IF;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Audit log seed data failed: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,33 @@
-- Newsletter subscribers table for SMTP-based email system
CREATE TABLE IF NOT EXISTS public.newsletter_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
topics TEXT[] DEFAULT ARRAY[]::TEXT[],
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'unsubscribed')),
subscribed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
unsubscribed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_email ON public.newsletter_subscribers(email);
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_status ON public.newsletter_subscribers(status);
ALTER TABLE public.newsletter_subscribers ENABLE ROW LEVEL SECURITY;
-- Only authenticated users (admins) can read/manage subscribers
DROP POLICY IF EXISTS "admin_manage_newsletter_subscribers" ON public.newsletter_subscribers;
CREATE POLICY "admin_manage_newsletter_subscribers"
ON public.newsletter_subscribers
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- Allow public inserts (signup form)
DROP POLICY IF EXISTS "public_subscribe_newsletter" ON public.newsletter_subscribers;
CREATE POLICY "public_subscribe_newsletter"
ON public.newsletter_subscribers
FOR INSERT
TO public
WITH CHECK (true);

View File

@@ -0,0 +1,91 @@
-- Newsletter digest templates
CREATE TABLE IF NOT EXISTS public.newsletter_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
name text NOT NULL,
description text,
layout text NOT NULL DEFAULT 'standard', -- standard | featured | minimal | two-column
style text NOT NULL DEFAULT 'dark', -- dark | light | branded
subject_prefix text DEFAULT '',
header_text text DEFAULT '',
footer_text text DEFAULT '',
accent_color text DEFAULT '#3b82f6',
article_blocks jsonb DEFAULT '[]'::jsonb,
is_preset boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- RLS
ALTER TABLE public.newsletter_templates ENABLE ROW LEVEL SECURITY;
-- Authenticated users (admins) can do everything
CREATE POLICY "Authenticated users can manage templates"
ON public.newsletter_templates
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- Seed preset templates
INSERT INTO public.newsletter_templates (name, description, layout, style, subject_prefix, header_text, footer_text, accent_color, article_blocks, is_preset)
VALUES
(
'Weekly Digest',
'Classic weekly roundup with featured lead story and supporting articles',
'featured',
'dark',
'BroadcastBeat Weekly —',
'Your weekly roundup of the most important stories in broadcast engineering.',
'Thanks for reading. Forward this to a colleague who''d find it useful.',
'#3b82f6',
'[{"title":"","excerpt":"","url":"","category":"","featured":true},{"title":"","excerpt":"","url":"","category":"","featured":false},{"title":"","excerpt":"","url":"","category":"","featured":false}]'::jsonb,
true
),
(
'Breaking News Flash',
'Urgent single-story alert with bold header and call-to-action',
'minimal',
'dark',
'BREAKING:',
'Important update from BroadcastBeat.',
'Stay tuned for further updates.',
'#ef4444',
'[{"title":"","excerpt":"","url":"","category":"","featured":true}]'::jsonb,
true
),
(
'Product Spotlight',
'Two-column layout showcasing new products and gear reviews',
'two-column',
'dark',
'New in Broadcast Tech —',
'The latest products, gear reviews, and technology launches.',
'Have a product to feature? Contact our editorial team.',
'#8b5cf6',
'[{"title":"","excerpt":"","url":"","category":""},{"title":"","excerpt":"","url":"","category":""},{"title":"","excerpt":"","url":"","category":""},{"title":"","excerpt":"","url":"","category":""}]'::jsonb,
true
),
(
'Event Roundup',
'Post-event summary with highlights and key takeaways',
'standard',
'dark',
'Event Recap —',
'Here''s what happened and what it means for the industry.',
'See you at the next event.',
'#10b981',
'[{"title":"","excerpt":"","url":"","category":""},{"title":"","excerpt":"","url":"","category":""},{"title":"","excerpt":"","url":"","category":""}]'::jsonb,
true
),
(
'Monthly Industry Report',
'Comprehensive monthly digest with multiple categories and analysis',
'standard',
'dark',
'BroadcastBeat Monthly —',
'Your comprehensive monthly briefing on broadcast engineering trends, products, and industry news.',
'This report is compiled by the BroadcastBeat editorial team. Share with your network.',
'#f59e0b',
'[{"title":"","excerpt":"","url":"","category":"Industry News"},{"title":"","excerpt":"","url":"","category":"Products"},{"title":"","excerpt":"","url":"","category":"Technology"},{"title":"","excerpt":"","url":"","category":"Events"},{"title":"","excerpt":"","url":"","category":"Analysis"}]'::jsonb,
true
);

View File

@@ -0,0 +1,454 @@
-- Community Forum Migration for BroadcastBeat
-- Tables: forum_categories, forum_threads, forum_replies
-- ============================================================
-- 1. TYPES
-- ============================================================
DROP TYPE IF EXISTS public.forum_thread_status CASCADE;
CREATE TYPE public.forum_thread_status AS ENUM ('open', 'closed', 'pinned');
-- ============================================================
-- 2. TABLES
-- ============================================================
CREATE TABLE IF NOT EXISTS public.forum_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT '💬',
display_order INTEGER DEFAULT 0,
thread_count INTEGER DEFAULT 0,
post_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS public.forum_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES public.forum_categories(id) ON DELETE CASCADE,
author_id UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
author_name TEXT NOT NULL DEFAULT 'Anonymous',
title TEXT NOT NULL,
body TEXT NOT NULL,
status public.forum_thread_status DEFAULT 'open'::public.forum_thread_status,
reply_count INTEGER DEFAULT 0,
view_count INTEGER DEFAULT 0,
is_ai_seeded BOOLEAN DEFAULT false,
last_reply_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS public.forum_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES public.forum_threads(id) ON DELETE CASCADE,
author_id UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
author_name TEXT NOT NULL DEFAULT 'Anonymous',
body TEXT NOT NULL,
is_ai_response BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 3. INDEXES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_forum_threads_category_id ON public.forum_threads(category_id);
CREATE INDEX IF NOT EXISTS idx_forum_threads_created_at ON public.forum_threads(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_forum_threads_last_reply_at ON public.forum_threads(last_reply_at DESC);
CREATE INDEX IF NOT EXISTS idx_forum_replies_thread_id ON public.forum_replies(thread_id);
CREATE INDEX IF NOT EXISTS idx_forum_categories_slug ON public.forum_categories(slug);
-- ============================================================
-- 4. FUNCTIONS
-- ============================================================
-- Update thread reply count and last_reply_at when a reply is added/deleted
CREATE OR REPLACE FUNCTION public.update_thread_reply_stats()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE public.forum_threads
SET reply_count = reply_count + 1,
last_reply_at = NEW.created_at,
updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.thread_id;
UPDATE public.forum_categories
SET post_count = post_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = (SELECT category_id FROM public.forum_threads WHERE id = NEW.thread_id LIMIT 1);
ELSIF TG_OP = 'DELETE' THEN
UPDATE public.forum_threads
SET reply_count = GREATEST(reply_count - 1, 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = OLD.thread_id;
UPDATE public.forum_categories
SET post_count = GREATEST(post_count - 1, 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = (SELECT category_id FROM public.forum_threads WHERE id = OLD.thread_id LIMIT 1);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
-- Update category thread count when a thread is added/deleted
CREATE OR REPLACE FUNCTION public.update_category_thread_count()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE public.forum_categories
SET thread_count = thread_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.category_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE public.forum_categories
SET thread_count = GREATEST(thread_count - 1, 0),
updated_at = CURRENT_TIMESTAMP
WHERE id = OLD.category_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
-- ============================================================
-- 5. ENABLE RLS
-- ============================================================
ALTER TABLE public.forum_categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.forum_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.forum_replies ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 6. RLS POLICIES
-- ============================================================
-- Forum categories: public read, no direct write (managed via API)
DROP POLICY IF EXISTS "public_read_forum_categories" ON public.forum_categories;
CREATE POLICY "public_read_forum_categories"
ON public.forum_categories FOR SELECT TO public USING (true);
-- Forum threads: public read, authenticated users can create
DROP POLICY IF EXISTS "public_read_forum_threads" ON public.forum_threads;
CREATE POLICY "public_read_forum_threads"
ON public.forum_threads FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "authenticated_create_forum_threads" ON public.forum_threads;
CREATE POLICY "authenticated_create_forum_threads"
ON public.forum_threads FOR INSERT TO authenticated
WITH CHECK (true);
DROP POLICY IF EXISTS "authors_update_own_forum_threads" ON public.forum_threads;
CREATE POLICY "authors_update_own_forum_threads"
ON public.forum_threads FOR UPDATE TO authenticated
USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());
-- Forum replies: public read, authenticated users can create
DROP POLICY IF EXISTS "public_read_forum_replies" ON public.forum_replies;
CREATE POLICY "public_read_forum_replies"
ON public.forum_replies FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "authenticated_create_forum_replies" ON public.forum_replies;
CREATE POLICY "authenticated_create_forum_replies"
ON public.forum_replies FOR INSERT TO authenticated
WITH CHECK (true);
DROP POLICY IF EXISTS "authors_update_own_forum_replies" ON public.forum_replies;
CREATE POLICY "authors_update_own_forum_replies"
ON public.forum_replies FOR UPDATE TO authenticated
USING (author_id = auth.uid())
WITH CHECK (author_id = auth.uid());
-- Service role can do everything (for seeding and AI responses)
DROP POLICY IF EXISTS "service_role_manage_forum_categories" ON public.forum_categories;
CREATE POLICY "service_role_manage_forum_categories"
ON public.forum_categories FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "service_role_manage_forum_threads" ON public.forum_threads;
CREATE POLICY "service_role_manage_forum_threads"
ON public.forum_threads FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "service_role_manage_forum_replies" ON public.forum_replies;
CREATE POLICY "service_role_manage_forum_replies"
ON public.forum_replies FOR ALL TO service_role USING (true) WITH CHECK (true);
-- ============================================================
-- 7. TRIGGERS
-- ============================================================
DROP TRIGGER IF EXISTS on_forum_reply_change ON public.forum_replies;
CREATE TRIGGER on_forum_reply_change
AFTER INSERT OR DELETE ON public.forum_replies
FOR EACH ROW EXECUTE FUNCTION public.update_thread_reply_stats();
DROP TRIGGER IF EXISTS on_forum_thread_change ON public.forum_threads;
CREATE TRIGGER on_forum_thread_change
AFTER INSERT OR DELETE ON public.forum_threads
FOR EACH ROW EXECUTE FUNCTION public.update_category_thread_count();
-- ============================================================
-- 8. SEED CATEGORIES
-- ============================================================
DO $$
BEGIN
INSERT INTO public.forum_categories (id, slug, name, description, icon, display_order) VALUES
(gen_random_uuid(), 'live-production', 'Live Production', 'Discussions on live broadcast workflows, switching, graphics, and production techniques.', '🎬', 1),
(gen_random_uuid(), 'ip-cloud', 'IP & Cloud', 'IP-based production, cloud workflows, remote production, and SMPTE ST 2110.', '☁️', 2),
(gen_random_uuid(), 'audio', 'Audio', 'Broadcast audio engineering, mixing consoles, codecs, and monitoring.', '🎙️', 3),
(gen_random_uuid(), 'cameras', 'Cameras & Lenses', 'Broadcast cameras, cinema cameras, lenses, and acquisition formats.', '📷', 4),
(gen_random_uuid(), 'storage-mam', 'Storage & MAM', 'Media asset management, storage systems, archiving, and nearline/offline workflows.', '💾', 5),
(gen_random_uuid(), 'streaming', 'Streaming & OTT', 'Video streaming, OTT platforms, encoding, CDN, and delivery.', '📡', 6),
(gen_random_uuid(), 'ai-automation', 'AI & Automation', 'AI in broadcast, automated production, machine learning, and workflow automation.', '🤖', 7),
(gen_random_uuid(), 'post-production', 'Post Production', 'Editing, color grading, VFX, finishing, and delivery workflows.', '🎞️', 8),
(gen_random_uuid(), 'nab-ibc', 'NAB & IBC', 'Trade show coverage, product announcements, and industry events.', '🏛️', 9),
(gen_random_uuid(), 'career-jobs', 'Career & Jobs', 'Career advice, job postings, freelance tips, and professional development.', '💼', 10),
(gen_random_uuid(), 'gear-reviews', 'Gear Reviews', 'User reviews, comparisons, and hands-on experiences with broadcast equipment.', '', 11),
(gen_random_uuid(), 'general', 'General Discussion', 'Off-topic conversations, introductions, and anything broadcast-related.', '💬', 12)
ON CONFLICT (slug) DO NOTHING;
END $$;
-- ============================================================
-- 9. SEED EDITORIAL STARTER THREADS
-- ============================================================
DO $$
DECLARE
cat_live UUID;
cat_ip UUID;
cat_audio UUID;
cat_cameras UUID;
cat_storage UUID;
cat_streaming UUID;
cat_ai UUID;
cat_post UUID;
cat_nab UUID;
cat_career UUID;
cat_reviews UUID;
cat_general UUID;
t_id UUID;
BEGIN
SELECT id INTO cat_live FROM public.forum_categories WHERE slug = 'live-production' LIMIT 1;
SELECT id INTO cat_ip FROM public.forum_categories WHERE slug = 'ip-cloud' LIMIT 1;
SELECT id INTO cat_audio FROM public.forum_categories WHERE slug = 'audio' LIMIT 1;
SELECT id INTO cat_cameras FROM public.forum_categories WHERE slug = 'cameras' LIMIT 1;
SELECT id INTO cat_storage FROM public.forum_categories WHERE slug = 'storage-mam' LIMIT 1;
SELECT id INTO cat_streaming FROM public.forum_categories WHERE slug = 'streaming' LIMIT 1;
SELECT id INTO cat_ai FROM public.forum_categories WHERE slug = 'ai-automation' LIMIT 1;
SELECT id INTO cat_post FROM public.forum_categories WHERE slug = 'post-production' LIMIT 1;
SELECT id INTO cat_nab FROM public.forum_categories WHERE slug = 'nab-ibc' LIMIT 1;
SELECT id INTO cat_career FROM public.forum_categories WHERE slug = 'career-jobs' LIMIT 1;
SELECT id INTO cat_reviews FROM public.forum_categories WHERE slug = 'gear-reviews' LIMIT 1;
SELECT id INTO cat_general FROM public.forum_categories WHERE slug = 'general' LIMIT 1;
-- Live Production threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_live, 'TechDirectorMike', 'Best practices for multi-camera live switching in 2025?',
'We are upgrading our live production setup for a major sports network. Currently running an older Ross Carbonite but considering moving to a newer platform. What are people using for large-scale live events these days? Particularly interested in how others handle instant replay integration and graphics playout from a single switcher workflow.',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'BroadcastVet_Sarah', 'We moved to the Grass Valley Korona last year for our NFL coverage. The integration with Viz graphics is seamless and the replay workflow with EVS is much cleaner than what we had before. The learning curve for operators was about two weeks.', false),
(t_id, 'LiveProdGuru', 'Ross Carbonite Black is still solid if you are already in that ecosystem. The latest firmware added a lot of IP features. But if you are doing a full refresh, the Sony XVS-G1 is worth a serious look for the price point.', false),
(t_id, 'SwitcherTech_Dan', 'Do not overlook the Blackmagic ATEM Constellation for mid-tier productions. We use it for college sports and it handles 4K HDR without breaking the bank. The software control panel is surprisingly capable.', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_live, 'RemoteProdEngineer', 'REMI vs full remote production — where is the industry heading?',
'Been having a lot of internal debates about whether to invest in a full REMI (Remote Integration Model) setup or go all-in on cloud-based remote production. The latency improvements in the last two years have been significant but we still have clients who are nervous about cloud reliability for live events. What is your experience?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'CloudBroadcaster_J', 'We have been doing full cloud production for tier-2 sports for 18 months. The key is redundant connectivity — we run dual bonded cellular as backup to fiber. Had zero on-air failures since we added that layer.', false),
(t_id, 'TechDirectorMike', 'REMI still makes sense when you need the tactile control of physical hardware at a central hub. Full cloud is great for lower-complexity shows but for a 12-camera live drama with complex audio, I still want engineers in a truck.', false)
ON CONFLICT DO NOTHING;
-- IP & Cloud threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_ip, 'IPEngineer_Tom', 'ST 2110 migration — lessons learned from our facility upgrade',
'Just completed a full ST 2110 migration at our broadcast center after 18 months of planning and 6 months of execution. Happy to share what went wrong and what went right. The biggest surprise was how much time we spent on PTP synchronization issues. Anyone else find that the timing infrastructure was the hardest part?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'NetworkBroadcast_K', 'PTP was our nightmare too. We ended up deploying a dedicated grandmaster clock per building and that solved 90% of our timing issues. The Meinberg LANTIME series has been rock solid for us.', false),
(t_id, 'ST2110_Specialist', 'What switch fabric did you end up with? We are evaluating Arista vs Cisco Nexus for our migration and the multicast configuration differences are significant.', false),
(t_id, 'IPEngineer_Tom', 'We went Arista 7050CX3. The IGMP snooping configuration was well documented and their broadcast support team actually knows what ST 2110 is, which was refreshing.', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_ip, 'CloudArchitect_V', 'AWS vs Azure vs GCP for broadcast cloud production — real-world comparison',
'We have been running workloads on all three major clouds for different clients over the past two years. Each has distinct strengths for broadcast. AWS Elemental is mature but expensive. Azure has better enterprise integration. GCP has interesting live streaming primitives. What is everyone else seeing?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'BroadcastCloud_R', 'AWS Elemental MediaLive is still the most feature-complete for live broadcast. The SCTE-35 handling alone makes it worth the premium for ad-supported content.', false),
(t_id, 'MultiCloudEng', 'We run a hybrid — AWS for live encoding, Azure for MAM and archive because of the Active Directory integration, and GCP for ML-based content analysis. No single cloud does everything best.', false)
ON CONFLICT DO NOTHING;
-- Audio threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_audio, 'AudioEngineer_Lisa', 'Dante vs AES67 vs MADI — which audio networking standard are you standardizing on?',
'Our facility is due for an audio infrastructure refresh and we are trying to decide between going all-in on Dante, adopting AES67 for interoperability, or sticking with MADI for its simplicity and reliability. We do a mix of live production and post. What are the real-world trade-offs people are experiencing?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'SoundDesigner_P', 'Dante for anything that needs to be flexible and reconfigurable quickly. AES67 when you need to interop with non-Audinate gear. MADI only if you have a specific legacy reason. That is the hierarchy I use.', false),
(t_id, 'LiveAudioMixer', 'We standardized on Dante three years ago and have not looked back. The Dante Controller software makes routing changes that used to take an hour take five minutes. The latency is predictable and the redundancy options are solid.', false),
(t_id, 'AudioEngineer_Lisa', 'Good points. Our concern with Dante is vendor lock-in. Has anyone had issues with the licensing costs as you scale up device count?', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_audio, 'MixerTech_B', 'Loudness compliance headaches — EBU R128 vs ATSC A/85 in a global workflow',
'We deliver content to both European and North American broadcasters and managing loudness compliance across both standards is a constant pain. We are using iZotope RX for post but for live we are still doing it manually. Anyone found a good automated solution that handles both standards simultaneously?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'LoudnessGuru', 'Orban Optimod-PC is what we use for live. It handles both R128 and A/85 profiles and you can switch between them on the fly. The automatic gain riding is transparent enough that most clients cannot tell it is engaged.', false),
(t_id, 'PostAudioEng', 'For post we use Nugen Audio VisLM for monitoring and MasterCheck for final compliance verification. The dual-standard metering in a single view saves a lot of time.', false)
ON CONFLICT DO NOTHING;
-- Cameras threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_cameras, 'CameraOp_James', 'Sony HDC-5500 vs Grass Valley LDX 150 for studio sports — which would you choose?',
'We are spec-ing out a new studio for a regional sports network. The two cameras on our shortlist are the Sony HDC-5500 and the Grass Valley LDX 150. Both are excellent but the price difference is significant. For a studio environment doing primarily basketball and hockey, is the GV worth the premium?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'StudioCamTech', 'The LDX 150 has better highlight handling for ice hockey — the specular reflections off the ice are brutal and the GV sensor handles them more gracefully. For basketball the Sony is fine. Depends on your primary sport.', false),
(t_id, 'BroadcastDP_A', 'Do not forget to factor in the CCU ecosystem. If you are already Sony house, the HDC-5500 integrates better with your existing infrastructure. Mixing CCU systems is a headache you do not want.', false),
(t_id, 'CameraOp_James', 'We are currently a mixed shop — some Sony, some Ikegami. Leaning toward standardizing on one platform with this purchase. The Sony ecosystem argument is compelling.', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_cameras, 'FilmToBroadcast_C', 'Cinema cameras in broadcast — where does it make sense?',
'More and more broadcast productions are using cinema cameras like the ARRI Alexa 35 or RED Monstro for certain applications. We are considering them for our documentary unit. What are the practical challenges of integrating cinema cameras into a broadcast workflow — particularly around color management and metadata?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'ColoristPro_N', 'The color management pipeline is the biggest challenge. You need a solid LUT strategy and everyone in the chain needs to understand it. ACES is theoretically the answer but in practice most broadcast facilities are not set up for it end to end.', false),
(t_id, 'DocCameraOp', 'We use ARRI Amiras for our doc unit. The key is having a dedicated DIT on set who manages the color pipeline from capture to delivery. Without that discipline the footage becomes a nightmare in post.', false)
ON CONFLICT DO NOTHING;
-- Storage & MAM threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_storage, 'MAMAdmin_Greg', 'Evaluating MAM systems for a mid-size broadcaster — Iconik vs Cantemo vs Dalet',
'We are a regional broadcaster with about 50TB of active content and 500TB in archive. Currently using a homegrown system that is falling apart. Evaluating Iconik, Cantemo Portal, and Dalet Galaxy. Anyone have real-world experience with these at similar scale? Particularly interested in the AI metadata features.',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'MediaOps_H', 'Iconik is excellent for cloud-native workflows and the API is well documented. If you have developers who can build integrations it is very flexible. The AI tagging via Google Video Intelligence is genuinely useful.', false),
(t_id, 'ArchivistPro', 'Dalet Galaxy is the most feature-complete but also the most complex to implement and maintain. For your scale I would look seriously at Iconik first — it is much faster to get running and the ongoing cost is more predictable.', false),
(t_id, 'MAMAdmin_Greg', 'Thanks for the Iconik recommendation. We did a demo last week and the cloud-first architecture appeals to us. Did you find the on-premise proxy generation adequate or did you need to supplement it?', false)
ON CONFLICT DO NOTHING;
-- Streaming threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_streaming, 'StreamingEng_F', 'HEVC vs AV1 for OTT delivery in 2025 — is AV1 ready for prime time?',
'We are planning a codec refresh for our OTT platform. AV1 offers significant bitrate savings over HEVC but the encoding complexity and device support have been concerns. With the latest hardware encoder support from Intel and NVIDIA, has the calculus changed? What are people actually deploying?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'OTTPlatformEng', 'AV1 is ready for VOD. For live it is still challenging because of encoding latency even with hardware acceleration. We use AV1 for our VOD catalog and HEVC for live. The bitrate savings on VOD are 30-40% at equivalent quality.', false),
(t_id, 'CDNArchitect', 'Device support is the real constraint. Smart TVs from 2020 and earlier often do not support AV1 hardware decode. If your audience skews older devices, HEVC is still the safer choice for live.', false),
(t_id, 'StreamingEng_F', 'Good point on device support. Our analytics show 15% of viewers on devices that do not support AV1. We are thinking about a dual-encode strategy but the cost implications are significant.', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_streaming, 'LatencyObsessed', 'Sub-second latency for live sports streaming — what is actually achievable?',
'Our sports rights holders are demanding sub-second latency to match linear broadcast. We are currently at 4-6 seconds with HLS. We have been evaluating WebRTC and HESP. Has anyone deployed either at scale for sports? What were the trade-offs in quality and reliability?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'WebRTCExpert', 'WebRTC can get you under 500ms but scaling beyond 10,000 concurrent viewers requires a sophisticated SFU architecture. Cloudflare Stream and Mux both offer managed WebRTC that takes the infrastructure burden off you.', false),
(t_id, 'HESPEvangelst', 'HESP (High Efficiency Streaming Protocol) is worth evaluating. It achieves sub-second latency with better quality than WebRTC at scale. THEOplayer has the most mature implementation.', false)
ON CONFLICT DO NOTHING;
-- AI & Automation threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_ai, 'AIBroadcast_W', 'AI-powered automated camera direction — hype or reality?',
'Vendors are pitching AI camera direction as a way to reduce crew costs for tier-2 sports and corporate events. We have piloted two systems and the results were mixed. The AI is good at tracking action but struggles with editorial judgment — knowing when to cut away, when to stay wide, how to build tension. What has your experience been?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'AutoProdTech', 'We use Vizrt Viz Mosart for automated studio production and it is genuinely good for structured formats like news. For sports the editorial judgment problem you describe is real — it is great at following the ball but terrible at storytelling.', false),
(t_id, 'SportsTVDirector', 'The best implementations I have seen use AI as a tool for the director, not a replacement. AI handles the routine coverage — wide shots, replays — while the human director focuses on the key moments. That hybrid approach works well.', false),
(t_id, 'AIBroadcast_W', 'That hybrid model is where we are landing too. The ROI case is harder to make when you still need a director, but the AI does reduce the number of camera operators needed significantly.', false)
ON CONFLICT DO NOTHING;
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_ai, 'MLEngineer_Broadcast', 'Using LLMs for automated closed captioning and transcript generation — accuracy benchmarks',
'We have been benchmarking several AI captioning solutions against our manual captioning accuracy standards. OpenAI Whisper, AWS Transcribe, and Google Speech-to-Text all perform differently on broadcast content — especially with sports commentary, technical jargon, and accented speech. Happy to share our benchmark data if there is interest.',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'AccessibilityEng', 'Please share the benchmark data — this is exactly what we need. We are under regulatory pressure to improve our captioning accuracy and the vendor claims are all over the place.', false),
(t_id, 'CaptioningPro', 'Whisper large-v3 is the best open-source option but it is not real-time without significant GPU investment. For live we use AWS Transcribe with a custom vocabulary for sports terms and it gets us to about 96% accuracy on clear speech.', false)
ON CONFLICT DO NOTHING;
-- Post Production threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_post, 'EditorPro_M', 'Avid vs Premiere vs Resolve for broadcast post — 2025 state of the market',
'The NLE landscape has shifted significantly. Avid is still dominant in high-end broadcast but DaVinci Resolve has made serious inroads, especially for facilities that want integrated color and audio. Premiere is everywhere but the stability issues have driven some shops away. Where is your facility landing?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'PostFacilityMgr', 'We moved from Avid to Resolve for our documentary and long-form work two years ago. The integrated color pipeline is a genuine productivity gain. We kept Avid for our news operation because the shared storage integration is still better.', false),
(t_id, 'NewsEditor_T', 'Avid is not going anywhere in news. The iNEWS integration and the shared bin workflow for breaking news is irreplaceable. Resolve is great for long-form but it is not built for the chaos of a live news operation.', false),
(t_id, 'IndiePostPro', 'Resolve is eating Premiere''s lunch at the indie and mid-tier level. The free version is genuinely professional-grade now and the Studio version is a fraction of the cost of Avid. For anything that is not news or high-end drama, it is hard to justify the alternatives.', false)
ON CONFLICT DO NOTHING;
-- NAB & IBC threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_nab, 'NABRegular_E', 'NAB 2025 — what are you most excited to see on the show floor?',
'NAB is coming up and I am trying to plan my time efficiently. The show has gotten so large that you really need a strategy. I am particularly interested in IP infrastructure, AI production tools, and the latest camera systems. What is on your must-see list this year?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'ShowFloorVet', 'The Central Hall IP pavilion has gotten much better organized. I always start there to get a sense of where the infrastructure market is heading. The Sony and Grass Valley booths are worth the time for camera announcements.', false),
(t_id, 'StartupWatcher', 'The startup pavilion in the South Hall has had some genuinely interesting companies the last few years. That is where I found the AI captioning vendor we ended up deploying. Worth a few hours if you can spare them.', false)
ON CONFLICT DO NOTHING;
-- Career threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_career, 'NewToBC_Q', 'Breaking into broadcast engineering — advice for someone coming from IT?',
'I have a strong IT background (networking, Linux, virtualization) and I am trying to transition into broadcast engineering. The IP transition in broadcast seems like a natural fit for my skills. What certifications or experience would make me most attractive to broadcast employers? Is the SBE certification worth pursuing?',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'BroadcastHiringMgr', 'Your IT background is genuinely valuable right now. SBE certification shows commitment to the field and the CBTE (Certified Broadcast Television Engineer) is the most recognized. But honestly, hands-on experience with broadcast-specific gear matters more than certifications.', false),
(t_id, 'CareerBroadcaster', 'Look for opportunities at local stations or production companies that are doing IP migrations. They need people who understand networking and are willing to learn the broadcast-specific layer on top. That combination is hard to find.', false),
(t_id, 'NewToBC_Q', 'Thank you both. I have been studying SMPTE ST 2110 documentation on my own. Is there a good lab environment I can set up at home to get hands-on experience without access to professional gear?', false)
ON CONFLICT DO NOTHING;
-- Gear Reviews threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_reviews, 'GearReviewer_Z', 'Six months with the Blackmagic ATEM Constellation 8K — honest review',
'We purchased two ATEM Constellation 8K units six months ago for our live event production company. Here is my honest assessment after real-world use: the value proposition is extraordinary, the software control panel is more capable than I expected, but there are some reliability quirks that Blackmagic needs to address. Full review in the thread.',
true, 'open') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'LiveEventPro', 'What reliability quirks are you seeing? We are considering the same unit for a similar application and I want to know what to watch out for.', false),
(t_id, 'GearReviewer_Z', 'The main issue is occasional macro execution failures under heavy load — the switcher gets busy and macros do not fire reliably. Blackmagic acknowledged it and said a firmware fix is coming. Also the HDMI inputs are more fragile than the SDI inputs in my experience.', false),
(t_id, 'BMDUser_X', 'We had the same macro issue. The workaround is to add a small delay between macro steps. Not ideal but it works until the firmware fix arrives. Overall still the best value in that class.', false)
ON CONFLICT DO NOTHING;
-- General threads
t_id := gen_random_uuid();
INSERT INTO public.forum_threads (id, category_id, author_name, title, body, is_ai_seeded, status)
VALUES (t_id, cat_general, 'BroadcastBeat_Team', 'Welcome to the BroadcastBeat Community Forum!',
'Welcome to the BroadcastBeat Community Forum — the place for broadcast engineering professionals to connect, share knowledge, and discuss the technology shaping our industry. Whether you are a seasoned chief engineer or just starting your career, this is your community. Introduce yourself below and tell us what you work on!',
true, 'pinned') ON CONFLICT (id) DO NOTHING;
INSERT INTO public.forum_replies (thread_id, author_name, body, is_ai_response)
VALUES (t_id, 'ChiefEngineer_NY', 'Great to see BroadcastBeat launching a forum. I am a chief engineer at a major network affiliate in New York. Looking forward to connecting with peers here.', false),
(t_id, 'FreelanceBroadcast', 'Freelance broadcast engineer based in LA. I work across live events, sports, and some scripted production. Always looking to learn from others in the field.', false)
ON CONFLICT DO NOTHING;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Forum seed data error: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,185 @@
-- Forum Votes & Reputation Migration for BroadcastBeat
-- Adds upvote/downvote on threads and replies, and reputation points on user_profiles
-- ============================================================
-- 1. ADD REPUTATION COLUMN TO USER_PROFILES
-- ============================================================
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS reputation INTEGER DEFAULT 0;
-- ============================================================
-- 2. FORUM VOTES TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS public.forum_votes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
target_type TEXT NOT NULL CHECK (target_type IN ('thread', 'reply')),
target_id UUID NOT NULL,
vote_value SMALLINT NOT NULL CHECK (vote_value IN (1, -1)),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Unique: one vote per user per target
CREATE UNIQUE INDEX IF NOT EXISTS idx_forum_votes_unique
ON public.forum_votes (user_id, target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_forum_votes_target
ON public.forum_votes (target_type, target_id);
CREATE INDEX IF NOT EXISTS idx_forum_votes_user_id
ON public.forum_votes (user_id);
-- ============================================================
-- 3. ADD VOTE SCORE COLUMNS TO THREADS AND REPLIES
-- ============================================================
ALTER TABLE public.forum_threads
ADD COLUMN IF NOT EXISTS upvotes INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS downvotes INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS vote_score INTEGER DEFAULT 0;
ALTER TABLE public.forum_replies
ADD COLUMN IF NOT EXISTS upvotes INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS downvotes INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS vote_score INTEGER DEFAULT 0;
-- ============================================================
-- 4. FUNCTIONS
-- ============================================================
-- Recalculate vote counts on thread/reply after vote change
CREATE OR REPLACE FUNCTION public.update_vote_counts()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_target_type TEXT;
v_target_id UUID;
v_up INTEGER;
v_down INTEGER;
v_author_id UUID;
v_old_vote SMALLINT := 0;
v_new_vote SMALLINT := 0;
v_rep_delta INTEGER := 0;
BEGIN
-- Determine target info
IF TG_OP = 'DELETE' THEN
v_target_type := OLD.target_type;
v_target_id := OLD.target_id;
v_old_vote := OLD.vote_value;
ELSIF TG_OP = 'INSERT' THEN
v_target_type := NEW.target_type;
v_target_id := NEW.target_id;
v_new_vote := NEW.vote_value;
ELSIF TG_OP = 'UPDATE' THEN
v_target_type := NEW.target_type;
v_target_id := NEW.target_id;
v_old_vote := OLD.vote_value;
v_new_vote := NEW.vote_value;
END IF;
-- Recalculate vote totals
SELECT
COALESCE(SUM(CASE WHEN vote_value = 1 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN vote_value = -1 THEN 1 ELSE 0 END), 0)
INTO v_up, v_down
FROM public.forum_votes
WHERE target_type = v_target_type AND target_id = v_target_id;
-- Update the appropriate table
IF v_target_type = 'thread' THEN
UPDATE public.forum_threads
SET upvotes = v_up,
downvotes = v_down,
vote_score = v_up - v_down,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_target_id;
SELECT author_id INTO v_author_id
FROM public.forum_threads WHERE id = v_target_id LIMIT 1;
ELSIF v_target_type = 'reply' THEN
UPDATE public.forum_replies
SET upvotes = v_up,
downvotes = v_down,
vote_score = v_up - v_down,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_target_id;
SELECT author_id INTO v_author_id
FROM public.forum_replies WHERE id = v_target_id LIMIT 1;
END IF;
-- Update author reputation (only for authenticated authors)
IF v_author_id IS NOT NULL THEN
-- Calculate reputation delta: upvote = +10, downvote = -2
v_rep_delta := (v_new_vote - v_old_vote) * CASE
WHEN (v_new_vote > 0 OR v_old_vote > 0) THEN 10
ELSE 2
END;
-- More precise: +10 per upvote gained, -10 per upvote lost, -2 per downvote gained, +2 per downvote lost
v_rep_delta := 0;
IF v_new_vote = 1 AND v_old_vote = 0 THEN v_rep_delta := 10; END IF;
IF v_new_vote = 0 AND v_old_vote = 1 THEN v_rep_delta := -10; END IF;
IF v_new_vote = -1 AND v_old_vote = 0 THEN v_rep_delta := -2; END IF;
IF v_new_vote = 0 AND v_old_vote = -1 THEN v_rep_delta := 2; END IF;
IF v_new_vote = 1 AND v_old_vote = -1 THEN v_rep_delta := 12; END IF;
IF v_new_vote = -1 AND v_old_vote = 1 THEN v_rep_delta := -12; END IF;
IF v_rep_delta != 0 THEN
UPDATE public.user_profiles
SET reputation = GREATEST(0, COALESCE(reputation, 0) + v_rep_delta)
WHERE id = v_author_id;
END IF;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
-- ============================================================
-- 5. ENABLE RLS
-- ============================================================
ALTER TABLE public.forum_votes ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 6. RLS POLICIES
-- ============================================================
-- Public can read all votes (for counts)
DROP POLICY IF EXISTS "public_read_forum_votes" ON public.forum_votes;
CREATE POLICY "public_read_forum_votes"
ON public.forum_votes FOR SELECT TO public USING (true);
-- Authenticated users can insert their own votes
DROP POLICY IF EXISTS "authenticated_insert_forum_votes" ON public.forum_votes;
CREATE POLICY "authenticated_insert_forum_votes"
ON public.forum_votes FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
-- Users can update their own votes
DROP POLICY IF EXISTS "users_update_own_forum_votes" ON public.forum_votes;
CREATE POLICY "users_update_own_forum_votes"
ON public.forum_votes FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Users can delete their own votes
DROP POLICY IF EXISTS "users_delete_own_forum_votes" ON public.forum_votes;
CREATE POLICY "users_delete_own_forum_votes"
ON public.forum_votes FOR DELETE TO authenticated
USING (user_id = auth.uid());
-- Service role full access
DROP POLICY IF EXISTS "service_role_manage_forum_votes" ON public.forum_votes;
CREATE POLICY "service_role_manage_forum_votes"
ON public.forum_votes FOR ALL TO service_role USING (true) WITH CHECK (true);
-- ============================================================
-- 7. TRIGGERS
-- ============================================================
DROP TRIGGER IF EXISTS on_forum_vote_change ON public.forum_votes;
CREATE TRIGGER on_forum_vote_change
AFTER INSERT OR UPDATE OR DELETE ON public.forum_votes
FOR EACH ROW EXECUTE FUNCTION public.update_vote_counts();

View File

@@ -0,0 +1,144 @@
-- Notifications Migration for BroadcastBeat
-- Tracks thread replies, mentions, upvotes, and follow activity
-- ============================================================
-- 1. NOTIFICATIONS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS public.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('thread_reply', 'mention', 'upvote', 'follow')),
title TEXT NOT NULL,
body TEXT,
link TEXT,
actor_id UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
actor_name TEXT,
is_read BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON public.notifications(user_id, is_read) WHERE is_read = false;
CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON public.notifications(created_at DESC);
-- ============================================================
-- 2. RLS POLICIES
-- ============================================================
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "Users can view own notifications" ON public.notifications;
CREATE POLICY "Users can view own notifications"
ON public.notifications FOR SELECT
USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Users can update own notifications" ON public.notifications;
CREATE POLICY "Users can update own notifications"
ON public.notifications FOR UPDATE
USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Users can delete own notifications" ON public.notifications;
CREATE POLICY "Users can delete own notifications"
ON public.notifications FOR DELETE
USING (auth.uid() = user_id);
DROP POLICY IF EXISTS "Service role can insert notifications" ON public.notifications;
CREATE POLICY "Service role can insert notifications"
ON public.notifications FOR INSERT
WITH CHECK (true);
-- ============================================================
-- 3. AUTO-NOTIFY ON FORUM REPLY
-- ============================================================
CREATE OR REPLACE FUNCTION public.notify_thread_reply()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_thread_author UUID;
v_thread_title TEXT;
v_thread_id UUID;
BEGIN
SELECT author_id, title, id INTO v_thread_author, v_thread_title, v_thread_id
FROM public.forum_threads
WHERE id = NEW.thread_id;
-- Notify thread author (if not replying to own thread and not AI)
IF v_thread_author IS NOT NULL
AND v_thread_author <> NEW.author_id
AND NEW.is_ai_response = false THEN
INSERT INTO public.notifications (user_id, type, title, body, link, actor_id, actor_name)
VALUES (
v_thread_author,
'thread_reply',
'New reply to your thread',
v_thread_title,
'/forum/thread/' || v_thread_id::TEXT,
NEW.author_id,
NEW.author_name
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_notify_thread_reply ON public.forum_replies;
CREATE TRIGGER trg_notify_thread_reply
AFTER INSERT ON public.forum_replies
FOR EACH ROW EXECUTE FUNCTION public.notify_thread_reply();
-- ============================================================
-- 4. AUTO-NOTIFY ON UPVOTE
-- ============================================================
CREATE OR REPLACE FUNCTION public.notify_upvote()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_target_author UUID;
v_target_title TEXT;
v_link TEXT;
v_voter_name TEXT;
BEGIN
-- Only notify on upvotes (vote_value = 1)
IF NEW.vote_value <> 1 THEN
RETURN NEW;
END IF;
SELECT username INTO v_voter_name FROM public.user_profiles WHERE id = NEW.user_id;
IF NEW.target_type = 'thread' THEN
SELECT author_id, title INTO v_target_author, v_target_title
FROM public.forum_threads WHERE id = NEW.target_id;
v_link := '/forum/thread/' || NEW.target_id::TEXT;
ELSE
SELECT fr.author_id, ft.title INTO v_target_author, v_target_title
FROM public.forum_replies fr
JOIN public.forum_threads ft ON ft.id = fr.thread_id
WHERE fr.id = NEW.target_id;
v_link := '/forum/thread/' || (
SELECT thread_id FROM public.forum_replies WHERE id = NEW.target_id
)::TEXT;
END IF;
IF v_target_author IS NOT NULL AND v_target_author <> NEW.user_id THEN
INSERT INTO public.notifications (user_id, type, title, body, link, actor_id, actor_name)
VALUES (
v_target_author,
'upvote',
'Someone upvoted your ' || NEW.target_type,
v_target_title,
v_link,
NEW.user_id,
COALESCE(v_voter_name, 'A member')
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_notify_upvote ON public.forum_votes;
CREATE TRIGGER trg_notify_upvote
AFTER INSERT ON public.forum_votes
FOR EACH ROW EXECUTE FUNCTION public.notify_upvote();

View File

@@ -0,0 +1,8 @@
-- Weekly Digest Email Preferences Migration for BroadcastBeat
-- Adds weekly_digest_enabled opt-in column to user_profiles
-- ============================================================
-- 1. ADD WEEKLY DIGEST PREFERENCE COLUMN
-- ============================================================
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS weekly_digest_enabled BOOLEAN DEFAULT false;

View File

@@ -0,0 +1,51 @@
-- Forum Moderation Migration for BroadcastBeat
-- Adds moderation fields to forum_threads and forum_replies
-- ============================================================
-- 1. ADD MODERATION COLUMNS TO forum_threads
-- ============================================================
ALTER TABLE public.forum_threads
ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_locked BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_flagged BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS flag_reason TEXT,
ADD COLUMN IF NOT EXISTS moderated_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS moderated_at TIMESTAMPTZ;
-- ============================================================
-- 2. ADD MODERATION COLUMNS TO forum_replies
-- ============================================================
ALTER TABLE public.forum_replies
ADD COLUMN IF NOT EXISTS is_hidden BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS is_flagged BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS flag_reason TEXT,
ADD COLUMN IF NOT EXISTS moderated_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS moderated_at TIMESTAMPTZ;
-- ============================================================
-- 3. INDEXES FOR MODERATION QUERIES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_forum_threads_is_pinned ON public.forum_threads(is_pinned) WHERE is_pinned = true;
CREATE INDEX IF NOT EXISTS idx_forum_threads_is_featured ON public.forum_threads(is_featured) WHERE is_featured = true;
CREATE INDEX IF NOT EXISTS idx_forum_threads_is_flagged ON public.forum_threads(is_flagged) WHERE is_flagged = true;
CREATE INDEX IF NOT EXISTS idx_forum_replies_is_flagged ON public.forum_replies(is_flagged) WHERE is_flagged = true;
CREATE INDEX IF NOT EXISTS idx_forum_replies_is_hidden ON public.forum_replies(is_hidden) WHERE is_hidden = true;
-- ============================================================
-- 4. RLS POLICIES FOR MODERATION (admin/service role)
-- ============================================================
-- Allow authenticated users (admins) to update moderation fields on threads
DROP POLICY IF EXISTS "admin_moderate_forum_threads" ON public.forum_threads;
CREATE POLICY "admin_moderate_forum_threads"
ON public.forum_threads FOR UPDATE TO authenticated
USING (true)
WITH CHECK (true);
-- Allow authenticated users (admins) to update moderation fields on replies
DROP POLICY IF EXISTS "admin_moderate_forum_replies" ON public.forum_replies;
CREATE POLICY "admin_moderate_forum_replies"
ON public.forum_replies FOR UPDATE TO authenticated
USING (true)
WITH CHECK (true);

View File

@@ -0,0 +1,156 @@
-- User Badges Migration for BroadcastBeat
-- Adds earned badges (Contributor, Mentor, Expert, Moderator) based on reputation and activity
-- ============================================================
-- 1. USER BADGES TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS public.user_badges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
badge_type TEXT NOT NULL CHECK (badge_type IN ('contributor', 'mentor', 'expert', 'moderator')),
awarded_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
awarded_reason TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_badges_unique
ON public.user_badges (user_id, badge_type);
CREATE INDEX IF NOT EXISTS idx_user_badges_user_id
ON public.user_badges (user_id);
-- ============================================================
-- 2. FUNCTION: EVALUATE AND AWARD BADGES
-- ============================================================
CREATE OR REPLACE FUNCTION public.evaluate_user_badges(p_user_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_reputation INTEGER;
v_thread_count INTEGER;
v_reply_count INTEGER;
v_upvotes_received INTEGER;
v_is_moderator BOOLEAN := false;
BEGIN
-- Get reputation
SELECT COALESCE(reputation, 0) INTO v_reputation
FROM public.user_profiles WHERE id = p_user_id;
-- Get thread count
SELECT COUNT(*) INTO v_thread_count
FROM public.forum_threads WHERE author_id = p_user_id;
-- Get reply count
SELECT COUNT(*) INTO v_reply_count
FROM public.forum_replies WHERE author_id = p_user_id;
-- Get total upvotes received
SELECT COALESCE(SUM(upvotes), 0) INTO v_upvotes_received
FROM (
SELECT upvotes FROM public.forum_threads WHERE author_id = p_user_id
UNION ALL
SELECT upvotes FROM public.forum_replies WHERE author_id = p_user_id
) AS all_posts;
-- Check moderator role (if user_roles table exists)
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_roles'
) THEN
SELECT EXISTS(
SELECT 1 FROM public.user_roles
WHERE user_id = p_user_id AND role IN ('moderator', 'admin')
) INTO v_is_moderator;
END IF;
-- Award CONTRIBUTOR badge: reputation >= 100 OR (threads >= 5 AND replies >= 10)
IF v_reputation >= 100 OR (v_thread_count >= 5 AND v_reply_count >= 10) THEN
INSERT INTO public.user_badges (user_id, badge_type, awarded_reason)
VALUES (p_user_id, 'contributor', 'Reached 100 reputation or posted 5+ threads and 10+ replies')
ON CONFLICT (user_id, badge_type) DO NOTHING;
END IF;
-- Award MENTOR badge: reputation >= 300 OR upvotes_received >= 50
IF v_reputation >= 300 OR v_upvotes_received >= 50 THEN
INSERT INTO public.user_badges (user_id, badge_type, awarded_reason)
VALUES (p_user_id, 'mentor', 'Reached 300 reputation or received 50+ upvotes')
ON CONFLICT (user_id, badge_type) DO NOTHING;
END IF;
-- Award EXPERT badge: reputation >= 1000 OR (upvotes_received >= 200 AND thread_count >= 20)
IF v_reputation >= 1000 OR (v_upvotes_received >= 200 AND v_thread_count >= 20) THEN
INSERT INTO public.user_badges (user_id, badge_type, awarded_reason)
VALUES (p_user_id, 'expert', 'Reached 1000 reputation or received 200+ upvotes with 20+ threads')
ON CONFLICT (user_id, badge_type) DO NOTHING;
END IF;
-- Award MODERATOR badge: has moderator/admin role
IF v_is_moderator THEN
INSERT INTO public.user_badges (user_id, badge_type, awarded_reason)
VALUES (p_user_id, 'moderator', 'Assigned moderator or admin role')
ON CONFLICT (user_id, badge_type) DO NOTHING;
END IF;
END;
$$;
-- ============================================================
-- 3. TRIGGER FUNCTION: AUTO-EVALUATE BADGES ON REPUTATION CHANGE
-- ============================================================
CREATE OR REPLACE FUNCTION public.trigger_evaluate_badges()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
PERFORM public.evaluate_user_badges(NEW.id);
RETURN NEW;
END;
$$;
-- ============================================================
-- 4. ENABLE RLS
-- ============================================================
ALTER TABLE public.user_badges ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 5. RLS POLICIES
-- ============================================================
DROP POLICY IF EXISTS "public_read_user_badges" ON public.user_badges;
CREATE POLICY "public_read_user_badges"
ON public.user_badges FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "service_role_manage_user_badges" ON public.user_badges;
CREATE POLICY "service_role_manage_user_badges"
ON public.user_badges FOR ALL TO service_role USING (true) WITH CHECK (true);
-- ============================================================
-- 6. TRIGGER: EVALUATE BADGES WHEN REPUTATION CHANGES
-- ============================================================
DROP TRIGGER IF EXISTS on_reputation_change_evaluate_badges ON public.user_profiles;
CREATE TRIGGER on_reputation_change_evaluate_badges
AFTER UPDATE OF reputation ON public.user_profiles
FOR EACH ROW
WHEN (OLD.reputation IS DISTINCT FROM NEW.reputation)
EXECUTE FUNCTION public.trigger_evaluate_badges();
-- ============================================================
-- 7. BACKFILL BADGES FOR EXISTING USERS
-- ============================================================
DO $$
DECLARE
v_user_id UUID;
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_profiles'
) THEN
FOR v_user_id IN SELECT id FROM public.user_profiles LOOP
PERFORM public.evaluate_user_badges(v_user_id);
END LOOP;
RAISE NOTICE 'Badge backfill completed for all existing users.';
END IF;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Badge backfill failed: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,40 @@
-- User Follows Migration for BroadcastBeat
-- Adds follow/unfollow functionality between users
-- ============================================================
-- 1. USER FOLLOWS TABLE
-- ============================================================
CREATE TABLE IF NOT EXISTS public.user_follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
follower_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
following_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT user_follows_no_self_follow CHECK (follower_id != following_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_follows_unique
ON public.user_follows (follower_id, following_id);
CREATE INDEX IF NOT EXISTS idx_user_follows_follower_id
ON public.user_follows (follower_id);
CREATE INDEX IF NOT EXISTS idx_user_follows_following_id
ON public.user_follows (following_id);
-- ============================================================
-- 2. ENABLE RLS
-- ============================================================
ALTER TABLE public.user_follows ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 3. RLS POLICIES
-- ============================================================
DROP POLICY IF EXISTS "public_read_user_follows" ON public.user_follows;
CREATE POLICY "public_read_user_follows"
ON public.user_follows FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "users_manage_own_follows" ON public.user_follows;
CREATE POLICY "users_manage_own_follows"
ON public.user_follows FOR ALL TO authenticated
USING (follower_id = auth.uid())
WITH CHECK (follower_id = auth.uid());

View File

@@ -0,0 +1,82 @@
-- ============================================================
-- Migration: role_permissions + role activity log extensions
-- Timestamp: 20260322190000
-- ============================================================
-- 1. role_permissions table: stores per-role permission flags
CREATE TABLE IF NOT EXISTS public.role_permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role TEXT NOT NULL UNIQUE,
can_publish_articles BOOLEAN NOT NULL DEFAULT false,
can_edit_articles BOOLEAN NOT NULL DEFAULT false,
can_delete_articles BOOLEAN NOT NULL DEFAULT false,
can_manage_comments BOOLEAN NOT NULL DEFAULT false,
can_moderate_forum BOOLEAN NOT NULL DEFAULT false,
can_pin_threads BOOLEAN NOT NULL DEFAULT false,
can_lock_threads BOOLEAN NOT NULL DEFAULT false,
can_flag_content BOOLEAN NOT NULL DEFAULT false,
can_view_analytics BOOLEAN NOT NULL DEFAULT false,
can_manage_newsletter BOOLEAN NOT NULL DEFAULT false,
can_invite_users BOOLEAN NOT NULL DEFAULT false,
can_manage_users BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON public.role_permissions(role);
-- 2. Enable RLS
ALTER TABLE public.role_permissions ENABLE ROW LEVEL SECURITY;
-- 3. RLS Policies
DROP POLICY IF EXISTS "admin_manage_role_permissions" ON public.role_permissions;
CREATE POLICY "admin_manage_role_permissions"
ON public.role_permissions
FOR ALL
TO authenticated
USING (public.is_admin_user())
WITH CHECK (public.is_admin_user());
DROP POLICY IF EXISTS "authenticated_read_role_permissions" ON public.role_permissions;
CREATE POLICY "authenticated_read_role_permissions"
ON public.role_permissions
FOR SELECT
TO authenticated
USING (true);
-- 4. Trigger for updated_at
DROP TRIGGER IF EXISTS update_role_permissions_updated_at ON public.role_permissions;
CREATE TRIGGER update_role_permissions_updated_at
BEFORE UPDATE ON public.role_permissions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- 5. Extend audit_action_type enum with role-related actions
-- We add new values to the existing enum safely
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'role_permission_updated'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'audit_action_type')
) THEN
ALTER TYPE public.audit_action_type ADD VALUE 'role_permission_updated';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'role_assigned'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'audit_action_type')
) THEN
ALTER TYPE public.audit_action_type ADD VALUE 'role_assigned';
END IF;
END $$;
-- 6. Seed default role permissions
INSERT INTO public.role_permissions (role, can_publish_articles, can_edit_articles, can_delete_articles, can_manage_comments, can_moderate_forum, can_pin_threads, can_lock_threads, can_flag_content, can_view_analytics, can_manage_newsletter, can_invite_users, can_manage_users)
VALUES
('admin', true, true, true, true, true, true, true, true, true, true, true, true),
('editor', true, true, false, true, false, false, false, true, true, false, false, false),
('moderator', false, false, false, true, true, true, true, true, false, false, false, false),
('contributor', false, true, false, false, false, false, false, false, false, false, false, false),
('reader', false, false, false, false, false, false, false, false, false, false, false, false)
ON CONFLICT (role) DO NOTHING;

View File

@@ -0,0 +1,129 @@
-- User Suspensions Migration
-- Adds user_suspensions table for admin suspend/ban controls
-- 1. Create suspension type enum
DROP TYPE IF EXISTS public.suspension_type CASCADE;
CREATE TYPE public.suspension_type AS ENUM ('suspended', 'banned');
-- 2. Create user_suspensions table
CREATE TABLE IF NOT EXISTS public.user_suspensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
suspended_by UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
suspension_type public.suspension_type NOT NULL DEFAULT 'suspended',
reason TEXT NOT NULL,
duration_days INTEGER NULL, -- NULL = permanent (ban)
expires_at TIMESTAMPTZ NULL, -- NULL = permanent
is_active BOOLEAN NOT NULL DEFAULT true,
lifted_at TIMESTAMPTZ NULL,
lifted_by UUID NULL REFERENCES public.user_profiles(id) ON DELETE SET NULL,
lift_reason TEXT NULL,
email_sent BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 3. Indexes
CREATE INDEX IF NOT EXISTS idx_user_suspensions_user_id ON public.user_suspensions(user_id);
CREATE INDEX IF NOT EXISTS idx_user_suspensions_active ON public.user_suspensions(user_id, is_active);
CREATE INDEX IF NOT EXISTS idx_user_suspensions_expires_at ON public.user_suspensions(expires_at) WHERE is_active = true;
-- 4. Add suspension_status column to user_profiles if not exists
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS suspension_status TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS suspension_reason TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS suspended_until TIMESTAMPTZ DEFAULT NULL;
-- 5. Helper function: check if user is currently suspended
CREATE OR REPLACE FUNCTION public.is_user_suspended(p_user_id UUID)
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_suspensions
WHERE user_id = p_user_id
AND is_active = true
AND (expires_at IS NULL OR expires_at > now())
)
$$;
-- 6. Helper function: get active suspension for user
CREATE OR REPLACE FUNCTION public.get_active_suspension(p_user_id UUID)
RETURNS TABLE(
suspension_id UUID,
suspension_type TEXT,
reason TEXT,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ
)
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT id, suspension_type::TEXT, reason, expires_at, created_at
FROM public.user_suspensions
WHERE user_id = p_user_id
AND is_active = true
AND (expires_at IS NULL OR expires_at > now())
ORDER BY created_at DESC
LIMIT 1
$$;
-- 7. Function to auto-expire suspensions
CREATE OR REPLACE FUNCTION public.expire_suspensions()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
UPDATE public.user_suspensions
SET is_active = false, updated_at = now()
WHERE is_active = true
AND expires_at IS NOT NULL
AND expires_at <= now();
-- Sync user_profiles suspension_status
UPDATE public.user_profiles
SET suspension_status = NULL,
suspension_reason = NULL,
suspended_until = NULL
WHERE id IN (
SELECT DISTINCT user_id FROM public.user_suspensions
WHERE is_active = false
AND expires_at IS NOT NULL
AND expires_at <= now()
)
AND NOT public.is_user_suspended(id);
END;
$$;
-- 8. Enable RLS
ALTER TABLE public.user_suspensions ENABLE ROW LEVEL SECURITY;
-- 9. RLS Policies
DROP POLICY IF EXISTS "admins_manage_suspensions" ON public.user_suspensions;
CREATE POLICY "admins_manage_suspensions"
ON public.user_suspensions
FOR ALL
TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid() AND role IN ('admin', 'moderator')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid() AND role IN ('admin', 'moderator')
)
);
DROP POLICY IF EXISTS "users_view_own_suspension" ON public.user_suspensions;
CREATE POLICY "users_view_own_suspension"
ON public.user_suspensions
FOR SELECT
TO authenticated
USING (user_id = auth.uid());

View File

@@ -0,0 +1,269 @@
-- ============================================================
-- Migration: WordPress User Migration & Permissions
-- Timestamp: 20260322210000
-- Covers: wp_legacy_users table, phpass validation, role upgrades,
-- featured category lock, post scheduling states
-- ============================================================
-- 1. Add legacy WordPress user ID column to user_profiles
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS wp_user_id INTEGER,
ADD COLUMN IF NOT EXISTS wp_username TEXT,
ADD COLUMN IF NOT EXISTS wp_hashed_password TEXT,
ADD COLUMN IF NOT EXISTS password_upgraded BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS display_name TEXT;
CREATE INDEX IF NOT EXISTS idx_user_profiles_wp_user_id ON public.user_profiles(wp_user_id) WHERE wp_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_user_profiles_wp_username ON public.user_profiles(wp_username) WHERE wp_username IS NOT NULL;
-- 2. wp_legacy_users staging table for import
CREATE TABLE IF NOT EXISTS public.wp_legacy_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wp_id INTEGER NOT NULL UNIQUE,
wp_username TEXT NOT NULL,
wp_hashed_password TEXT NOT NULL,
email TEXT NOT NULL,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'contributor',
is_active BOOLEAN NOT NULL DEFAULT true,
auth_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
imported_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMPTZ,
password_upgraded BOOLEAN NOT NULL DEFAULT false,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_wp_legacy_users_email ON public.wp_legacy_users(email);
CREATE INDEX IF NOT EXISTS idx_wp_legacy_users_auth_user_id ON public.wp_legacy_users(auth_user_id) WHERE auth_user_id IS NOT NULL;
-- 3. Enable RLS on wp_legacy_users
ALTER TABLE public.wp_legacy_users ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_wp_legacy_users" ON public.wp_legacy_users;
CREATE POLICY "admin_manage_wp_legacy_users"
ON public.wp_legacy_users
FOR ALL
TO authenticated
USING (public.is_admin_user())
WITH CHECK (public.is_admin_user());
-- 4. Function: validate phpass password ($P$B... format)
-- phpass uses MD5-based iterated hashing. We store the hash and validate server-side via API.
-- This function records a login attempt and returns the stored hash for server-side validation.
CREATE OR REPLACE FUNCTION public.get_wp_user_hash(p_email TEXT)
RETURNS TABLE(
wp_id INTEGER,
wp_username TEXT,
wp_hashed_password TEXT,
role TEXT,
is_active BOOLEAN,
auth_user_id UUID,
password_upgraded BOOLEAN
)
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT
wlu.wp_id,
wlu.wp_username,
wlu.wp_hashed_password,
wlu.role,
wlu.is_active,
wlu.auth_user_id,
wlu.password_upgraded
FROM public.wp_legacy_users wlu
WHERE wlu.email = p_email
LIMIT 1;
$$;
-- 5. Function: mark password as upgraded after successful phpass login
CREATE OR REPLACE FUNCTION public.mark_wp_password_upgraded(p_wp_id INTEGER, p_auth_user_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
UPDATE public.wp_legacy_users
SET password_upgraded = true,
auth_user_id = p_auth_user_id,
last_login_at = CURRENT_TIMESTAMP
WHERE wp_id = p_wp_id;
UPDATE public.user_profiles
SET password_upgraded = true,
wp_user_id = p_wp_id
WHERE id = p_auth_user_id;
END;
$$;
-- 6. Function: get user role (extended to support new roles)
CREATE OR REPLACE FUNCTION public.get_user_role()
RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT COALESCE(
(SELECT role FROM public.user_profiles WHERE id = auth.uid() LIMIT 1),
'reader'
)
$$;
-- 7. Function: check if current user can post to Featured category
CREATE OR REPLACE FUNCTION public.can_post_to_featured()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid()
AND role IN ('administrator', 'admin', 'editor')
)
$$;
-- 8. Function: check if user is editor or admin
CREATE OR REPLACE FUNCTION public.is_editor_or_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid()
AND role IN ('administrator', 'admin', 'editor')
)
$$;
-- 9. Function: strip Featured category from contributor/author posts on save
CREATE OR REPLACE FUNCTION public.enforce_featured_category_lock()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_role TEXT;
BEGIN
-- Get the author's role
SELECT role INTO v_role
FROM public.user_profiles
WHERE id = NEW.author_id
LIMIT 1;
-- If contributor or author tries to post to Featured, strip it and set pending
IF v_role IN ('contributor', 'author') AND LOWER(COALESCE(NEW.category, '')) = 'featured' THEN
NEW.category := 'Uncategorized';
NEW.status := 'pending';
END IF;
-- Contributors cannot publish directly
IF v_role = 'contributor' AND NEW.status = 'published' THEN
NEW.status := 'pending';
END IF;
RETURN NEW;
END;
$$;
-- 10. Apply Featured category lock trigger to native_articles
DROP TRIGGER IF EXISTS enforce_featured_lock_native ON public.native_articles;
CREATE TRIGGER enforce_featured_lock_native
BEFORE INSERT OR UPDATE ON public.native_articles
FOR EACH ROW EXECUTE FUNCTION public.enforce_featured_category_lock();
-- 11. Add 'scheduled_pending_review' status support via check on native_articles
-- (status column is TEXT, so we just document the allowed values via comment)
COMMENT ON COLUMN public.native_articles.status IS
'Allowed values: draft, pending, scheduled, scheduled_pending_review, published, archived';
-- 12. Update role_permissions to include new BB roles
-- Upsert permissions for administrator role
INSERT INTO public.role_permissions (role, can_publish_articles, can_edit_articles, can_delete_articles,
can_manage_comments, can_moderate_forum, can_pin_threads, can_lock_threads, can_flag_content,
can_view_analytics, can_manage_newsletter, can_invite_users, can_manage_users)
VALUES
('administrator', true, true, true, true, true, true, true, true, true, true, true, true),
('editor', true, true, true, true, true, true, true, true, true, true, true, false),
('author', true, true, true, false, false, false, false, true, false, false, false, false),
('contributor', false, false, false, false, false, false, false, true, false, false, false, false)
ON CONFLICT (role) DO UPDATE SET
can_publish_articles = EXCLUDED.can_publish_articles,
can_edit_articles = EXCLUDED.can_edit_articles,
can_delete_articles = EXCLUDED.can_delete_articles,
can_manage_comments = EXCLUDED.can_manage_comments,
can_moderate_forum = EXCLUDED.can_moderate_forum,
can_pin_threads = EXCLUDED.can_pin_threads,
can_lock_threads = EXCLUDED.can_lock_threads,
can_flag_content = EXCLUDED.can_flag_content,
can_view_analytics = EXCLUDED.can_view_analytics,
can_manage_newsletter = EXCLUDED.can_manage_newsletter,
can_invite_users = EXCLUDED.can_invite_users,
can_manage_users = EXCLUDED.can_manage_users,
updated_at = CURRENT_TIMESTAMP;
-- 13. Insert all WordPress users into wp_legacy_users staging table
-- These users will be authenticated via phpass validation in the API layer
-- Test accounts (IDs 757, 760, 761) are set to inactive
DO $$
BEGIN
INSERT INTO public.wp_legacy_users (wp_id, wp_username, wp_hashed_password, email, display_name, role, is_active) VALUES
-- ADMINISTRATORS
(1, 'broadcastbeat', '$wp$2y$10$KelwIGTptpx3IlfaF7Anu.Ud8seDIsd726PV4Ts6CT9d.rWCpsba6', 'ryan.salazar@relevantmediaproperties.com', 'Broadcast Beat Magazine', 'administrator', true),
(684, 'fiverrdeveloper', '$P$BnfPwlEqjA67bRABKPY9AHFNz3zDQN.', 'ryan.salazar@broadcastbeatstudios.com', 'developer', 'administrator', true),
(728, 'deanna.jones', '$wp$2y$10$ycNw2SQELJKwhTpDbskVuO9tCmdacvoYg.9JSGtmczupiCFLFhUjK', 'deanna@broadcastbeatstudios.com', 'Broadcast Beat', 'administrator', true),
(732, 'ruelwebinaradmin', '$P$Be2.Iyxq0QZSS.wNS5gzGzoq0eB3uz1', 'ruel@broadcastbeatstudios.com', 'ruelwebinaradmin', 'administrator', true),
(776, 'chad.salazar', '$P$BDKUJTjLa6KwISuYfvLNg8VW0bZ5500', 'chad.salazar@broadcastbeatstudios.com', 'Chad Salazar', 'administrator', true),
-- KEY STAFF (ryansalazar upgraded to administrator per instructions)
(21, 'ryansalazar', '$P$BYVa0rEPUaKxZ.bZKWyhQdlGbhmNTg1', 'ryan@ryansalazar.net', 'Ryan Salazar', 'administrator', true),
(50, 'pthompson', '$P$BhS7FLOqwYd9/0yKz7BNDmvy07SoYS0', 'deanna@broadcastbeat.com', 'Pamela Thompson', 'author', true),
-- PR FIRM CONTRIBUTORS
(6, 'desertmoon', '$wp$2y$10$vwAvPaqdJX/KHu.q/oIydeZGVtqKC7R3eRCf3/XGPcW6/bWIHe1qi', 'harriet@desertmoon.tv', 'Desert Moon Communications', 'contributor', true),
(7, 'wallstcom', '$P$B.GJa2yl0pI2uxOSSJgKcV2T/4li5V.', 'sunnybranson@wallstcom.com', 'Wall Street Communications', 'contributor', true),
(9, 'artisanspr', '$wp$2y$10$ZHzmzzO1lyuIFWwQf57jBOOa23wS29B6qq2xWIVrhvaXObarfwSZa', 'kgayhart@artisanspr.com', 'Keith Gayhart', 'contributor', true),
(10, 'hummingbird', '$P$B61xZrfoctgjldBhkNvMWJ3C1H8Eol.', 'jeff@hummingbirdmedia.com', 'Hummingbird Media', 'contributor', true),
(12, 'atomicpr', '$P$B4Y3L2aDEweGP7rEyk6wb0ajEvQsut/', 'jason@atomicpr.com', 'Atomic PR', 'contributor', true),
(13, 'ingearpr', '$wp$2y$10$E1NonQFDeiXuWjGj8rPhC.fZ5.usgf57Q2guf3e8LkFmUVPEvvGgW', 'markops@ingearpr.com', 'InGear', 'contributor', true),
(14, 'crossroads', '$P$BQg/o.gb.ycsdpsy2AgaOBBUnwAdri1', 'jharris@crossroads.com', 'Jennifer Harris', 'contributor', true),
(15, 'ignite', '$wp$2y$10$kGW7PB3ga1dnNyDPO0NpMezSwyTWCerwI/Y0vW6Wq8WVxekoeyBpO', 'mimi@ignite.bz', 'Ignite Strategic Comms', 'contributor', true),
(18, 'highrezpr', '$wp$2y$10$VBtrrNIZYwj80LCXU57tdOvqD/xM6Z/BVp6l.YADi0D/9cx8pi6em', 'veronique@highrezpr.com', 'High Rez PR', 'contributor', true),
(19, 'redpinesgroup', '$P$BUSHOVcp6rPGe2EPijD/0doRClwmD0.', 'raemorrow@redpinesgroup.com', 'redpinesgroup', 'contributor', true),
(20, 'toddsoundelux', '$P$BqGsvlrPM0MLAeLoJGuE3H4mAKdzc0.', 'CHellum@toddsoundelux.com', 'TODD-SOUNDELUX', 'contributor', true),
(23, 'smpte', '$P$BMWB3H14zTZ1dZM2ciorIdnz6iqT7h.', 'aricca@smpte.org', 'smpte', 'contributor', true),
(29, 'bourkepr', '$wp$2y$10$Dq9pQfWqyvkcZDxMjXB16.g0O337JE1if.iq1B/RO6SiMLroT34AG', 'kbourke@bourkepr.com', 'Kevin Bourke', 'contributor', true),
(31, 'quantel', '$P$BB/jv2X305dCC1GBaTIOFullbUE0.41', 'kim.chaplin@s-a-m.com', 'Snell Advanced Media', 'contributor', true),
(32, 'telestream', '$P$B4K1Nyqb/WvlNvNwxdiCN.yJbXt1xE1', 'janets@telestream.net', 'Telestream', 'contributor', true),
(35, 'nab', '$P$BeB8/kbps7wUsZRRV5yCJnFZq8MKCa/', 'nab@broadcastbeat.com', 'NAB', 'contributor', true),
(36, 'matrox', '$P$BPfT6ZHonQHEbTz90Bi73srj7sVR8r.', 'jmatey@matrox.com', 'matrox', 'contributor', true),
(38, 'Quantum', '$P$B1DT1vWgF7R1rJL6hIYCfLj43j49ms/', 'quantum@broadcastbeat.com', 'Quantum', 'contributor', true),
(40, 'ensembledesigns', '$P$BdXeAPVobF4PoMDBJTsPTWDlCt00Nn0', 'cindy@ensembledesigns.com', 'ensembledesigns', 'contributor', true),
(41, 'cinegy', '$P$BWFW7U1wikUMe5EIr9Gr3fQGo2YDDC/', 'troy@cinegy.com', 'cinegy', 'contributor', true),
(735, 'CWPR', '$wp$2y$10$Lx8eslb.PBltQdWwAI3d1.Hffv6LIt8Mj1C11dmOdQJATj7.xonxC', 'info@carriewick.com', 'Emma Buckley', 'contributor', true),
(736, 'RISE Media Strategy','$wp$2y$10$l2PEnkFWi9Ycx2k78D96g.xukFgJg8PRXyp7H4hmsa.Tp/sjtmYwG', 'krissy@risemediastrategy.com', 'Krissy Rushing Tomlin', 'contributor', true),
(738, 'EBComs', '$wp$2y$10$b8nSRpgP6JImRPtroRI6KezNxIdH.pkwjXXbYZZvl21znmch0oLgW', 'news@ebcoms.com', 'EBC', 'contributor', true),
(749, 'Bitmovin', '$P$B.38uMQUYsqhvDQdP68hjvrc1nNKrL/', 'zoe.mumba@bitmovin.com', 'Zoe Mumba', 'contributor', true),
(762, 'MASV', '$P$Bp7IacrIO6kePSk0ZTH1Gqe4f5Y43E1', 'anna@masv.io', 'Anna Mroczkowski', 'contributor', true),
(763, 'Caster Comms', '$P$BZcmEVXD2.GVhcxoqRVLmc2rKqXDX0.', 'rachel@castercomm.com', 'Rachel Bradshaw', 'contributor', true),
(765, 'VisualOn', '$wp$2y$10$T4MV.YCspYjX7PoqVynIo.A00w3044DrqrxY4FCElW1xC6lhgPnIm', 'savi@visualon.com', 'Savi Shi', 'contributor', true),
(770, 'Radical Moves', '$wp$2y$10$Jq9.TDKaXrRyFwnXJED1d.W1ARQwiCUF2aE8UyMICILbrflDWvLuC', 'Helen@radicalmoves.co.uk', 'Radical Moves', 'contributor', true),
(772, 'Humans Not Robots', '$wp$2y$10$VGyksF0RXSSyLYeyuvCxUug84vgdxJ6ifSdarPEBg/S/9OxfNPN4.', 'bea@humansnotrobots.com', 'Bea Alonso', 'contributor', true),
(773, 'Atka', '$wp$2y$10$H9YE9ZCL28qGc6vsgyx3qOTsmM4lTVVH2h7wK7z4KSGC3r6vTNlrG', 'matt_smith@akta.tech', 'Matt Smith', 'contributor', true),
(779, 'Projective', '$wp$2y$10$btb6I0.6NHcIitibnmnjXOxynqC/hEb8zbn.qWIM8IYQiisjmuKAq', 'bea.alonso@projective.io', 'Bea Alonso', 'contributor', true),
(783, 'dimension', '$wp$2y$10$ZrnIyaWiVwb3hKx4yM8wm.d6qPNTAa5v.XRKB/PTdn.NkMSUhLtUG', 'alicia@dimensionpronline.com', 'dimension', 'contributor', true),
(784, 'Julie Van Roy', '$P$B16qQBtKrVlG.or2Kwr2Rj40RnGc4j0', 'j.vanroy@intopix.com', 'Julie Van Roy', 'contributor', true),
(777, 'Platform Comms', '$P$BREKNqKduOaVNa0PGTVd2pmDoZ38F/', 'darnell@platformcomms.com', 'Darnell Odeghe', 'contributor', true),
(782, 'PRandMe', '$P$BobDp18rFSfMhkcOmj3pseSJd49ern0', 'denise@prandme.com', 'Denise Williams', 'contributor', true),
-- TEST ACCOUNTS — imported but set inactive
(757, 'haley_creed_test', '$P$BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'haleykcreed@gmail.com', 'haley creed test', 'contributor', false),
(760, 'testaccount', '$P$BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'scheduling2@broadcastbeatstudios.com', 'testaccount', 'contributor', false),
(761, 'haley_testing', '$P$BXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'hkbarron30@gmail.com', 'haley testing', 'contributor', false)
ON CONFLICT (wp_id) DO UPDATE SET
wp_username = EXCLUDED.wp_username,
wp_hashed_password = EXCLUDED.wp_hashed_password,
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
role = EXCLUDED.role,
is_active = EXCLUDED.is_active;
END $$;

View File

@@ -0,0 +1,81 @@
-- ============================================================
-- Migration: company_coverage
-- Timestamp: 20260322220000
-- ============================================================
-- 1. company_coverage_queue table
CREATE TABLE IF NOT EXISTS public.company_coverage_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name TEXT NOT NULL,
source_article_id TEXT,
source_article_title TEXT,
source_article_slug TEXT,
source_type TEXT DEFAULT 'native', -- 'native' or 'imported'
ai_draft_title TEXT,
ai_draft_content TEXT,
ai_draft_excerpt TEXT,
status TEXT NOT NULL DEFAULT 'queued', -- 'queued', 'approved', 'published', 'dismissed'
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_sent_at TIMESTAMPTZ,
reviewed_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
published_article_id UUID,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_company_coverage_status ON public.company_coverage_queue(status);
CREATE INDEX IF NOT EXISTS idx_company_coverage_company ON public.company_coverage_queue(company_name);
CREATE INDEX IF NOT EXISTS idx_company_coverage_created ON public.company_coverage_queue(created_at DESC);
-- 2. autopilot_settings table (single-row config)
CREATE TABLE IF NOT EXISTS public.autopilot_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
autopilot_enabled BOOLEAN NOT NULL DEFAULT FALSE,
reminder_email TEXT NOT NULL DEFAULT 'ryan.salazar@relevantmediaproperties.com',
scan_interval_hours INTEGER NOT NULL DEFAULT 24,
last_scan_at TIMESTAMPTZ,
last_scan_articles_processed INTEGER DEFAULT 0,
last_scan_companies_found INTEGER DEFAULT 0,
updated_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings row
INSERT INTO public.autopilot_settings (autopilot_enabled, reminder_email)
VALUES (FALSE, 'ryan.salazar@relevantmediaproperties.com')
ON CONFLICT DO NOTHING;
-- 3. Enable RLS
ALTER TABLE public.company_coverage_queue ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.autopilot_settings ENABLE ROW LEVEL SECURITY;
-- 4. RLS Policies for company_coverage_queue
DROP POLICY IF EXISTS "admin_manage_company_coverage" ON public.company_coverage_queue;
CREATE POLICY "admin_manage_company_coverage"
ON public.company_coverage_queue
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- 5. RLS Policies for autopilot_settings
DROP POLICY IF EXISTS "admin_manage_autopilot_settings" ON public.autopilot_settings;
CREATE POLICY "admin_manage_autopilot_settings"
ON public.autopilot_settings
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- 6. Trigger for updated_at on company_coverage_queue
DROP TRIGGER IF EXISTS update_company_coverage_updated_at ON public.company_coverage_queue;
CREATE TRIGGER update_company_coverage_updated_at
BEFORE UPDATE ON public.company_coverage_queue
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- 7. Trigger for updated_at on autopilot_settings
DROP TRIGGER IF EXISTS update_autopilot_settings_updated_at ON public.autopilot_settings;
CREATE TRIGGER update_autopilot_settings_updated_at
BEFORE UPDATE ON public.autopilot_settings
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();

View File

@@ -0,0 +1,403 @@
-- ============================================================
-- Migration: Multi-Site System (BB + AV Beat)
-- Timestamp: 20260322230000
-- Covers: site_id on articles/categories, AV Beat categories,
-- display_name_overrides, banned_terms, tracked_companies,
-- auto_story_queue, pen_names
-- ============================================================
-- ─── 1. Add site_id to native_articles ───────────────────────────────────────
ALTER TABLE public.native_articles
ADD COLUMN IF NOT EXISTS site_id INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS scope_flagged BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS banned_term_flagged BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS banned_term_matched TEXT,
ADD COLUMN IF NOT EXISTS editor_note TEXT,
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS submitted_for_review_at TIMESTAMPTZ;
COMMENT ON COLUMN public.native_articles.site_id IS '1 = Broadcast Beat, 2 = AV Beat';
CREATE INDEX IF NOT EXISTS idx_native_articles_site_id ON public.native_articles(site_id);
-- ─── 2. Add site_id to wp_imported_posts ─────────────────────────────────────
ALTER TABLE public.wp_imported_posts
ADD COLUMN IF NOT EXISTS site_id INTEGER NOT NULL DEFAULT 1;
-- ─── 3. Sites reference table ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
domain TEXT,
badge_color TEXT NOT NULL,
badge_label TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO public.sites (id, name, slug, domain, badge_color, badge_label) VALUES
(1, 'Broadcast Beat', 'broadcastbeat', 'broadcastbeat.com', '#0ea5e9', 'BB'),
(2, 'AV Beat', 'avbeat', 'avbeat.com', '#f97316', 'AV')
ON CONFLICT (id) DO NOTHING;
-- ─── 4. Categories table with site_id ────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.site_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
site_id INTEGER NOT NULL REFERENCES public.sites(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
is_featured BOOLEAN NOT NULL DEFAULT false,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(site_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_site_categories_site_id ON public.site_categories(site_id);
-- Broadcast Beat categories (site_id = 1)
INSERT INTO public.site_categories (site_id, name, slug, is_featured, sort_order) VALUES
(1, 'Featured', 'featured', true, 0),
(1, 'Audio', 'audio', false, 1),
(1, 'Broadcast', 'broadcast', false, 2),
(1, 'Business', 'business', false, 3),
(1, 'Camera', 'camera', false, 4),
(1, 'Cloud', 'cloud', false, 5),
(1, 'Content Creation', 'content-creation', false, 6),
(1, 'Distribution', 'distribution', false, 7),
(1, 'Education', 'education', false, 8),
(1, 'Events', 'events', false, 9),
(1, 'IP & Networking', 'ip-networking', false, 10),
(1, 'Live Production', 'live-production', false, 11),
(1, 'Media Management', 'media-management', false, 12),
(1, 'News', 'news', false, 13),
(1, 'OTT', 'ott', false, 14),
(1, 'Post Production', 'post-production', false, 15),
(1, 'Radio', 'radio', false, 16),
(1, 'Sports', 'sports', false, 17),
(1, 'Streaming', 'streaming', false, 18),
(1, 'Technology', 'technology', false, 19),
(1, 'Uncategorized', 'uncategorized', false, 20)
ON CONFLICT (site_id, slug) DO NOTHING;
-- AV Beat categories (site_id = 2)
INSERT INTO public.site_categories (site_id, name, slug, is_featured, sort_order) VALUES
(2, 'Featured', 'featured', true, 0),
(2, 'AV Integration', 'av-integration', false, 1),
(2, 'Digital Signage', 'digital-signage', false, 2),
(2, 'Unified Communications', 'unified-communications', false, 3),
(2, 'Streaming Technology', 'streaming-technology', false, 4),
(2, 'Conferencing & Collaboration','conferencing-collaboration', false, 5),
(2, 'Houses of Worship', 'houses-of-worship', false, 6),
(2, 'Education AV', 'education-av', false, 7),
(2, 'Control Systems', 'control-systems', false, 8),
(2, 'Display Technology', 'display-technology', false, 9),
(2, 'Sound & Audio', 'sound-audio', false, 10),
(2, 'Trade Shows & Events', 'trade-shows-events', false, 11),
(2, 'Product News', 'product-news', false, 12),
(2, 'Industry News', 'industry-news', false, 13)
ON CONFLICT (site_id, slug) DO NOTHING;
-- ─── 5. Display name overrides ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.display_name_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.user_profiles(id) ON DELETE CASCADE,
site_id INTEGER NOT NULL REFERENCES public.sites(id) ON DELETE CASCADE,
public_display_name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, site_id)
);
CREATE INDEX IF NOT EXISTS idx_display_name_overrides_user_site ON public.display_name_overrides(user_id, site_id);
-- Enable RLS
ALTER TABLE public.display_name_overrides ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_display_name_overrides" ON public.display_name_overrides;
CREATE POLICY "admin_manage_display_name_overrides"
ON public.display_name_overrides
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- ─── 6. Banned terms ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.banned_terms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
term TEXT NOT NULL,
ban_level TEXT NOT NULL DEFAULT 'soft' CHECK (ban_level IN ('soft', 'hard')),
site_scope TEXT NOT NULL DEFAULT 'both' CHECK (site_scope IN ('broadcastbeat', 'avbeat', 'both')),
is_active BOOLEAN NOT NULL DEFAULT true,
notes TEXT,
added_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_banned_terms_active ON public.banned_terms(is_active) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_banned_terms_term ON public.banned_terms(LOWER(term));
ALTER TABLE public.banned_terms ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_banned_terms" ON public.banned_terms;
CREATE POLICY "admin_manage_banned_terms"
ON public.banned_terms
FOR ALL
TO authenticated
USING (public.is_admin_user())
WITH CHECK (public.is_admin_user());
-- ─── 7. Tracked companies ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.tracked_companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_name TEXT NOT NULL UNIQUE,
company_website TEXT,
site_scope TEXT NOT NULL DEFAULT 'both' CHECK (site_scope IN ('broadcastbeat', 'avbeat', 'both')),
first_mentioned TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_mentioned TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
mention_count INTEGER NOT NULL DEFAULT 1,
auto_story_enabled BOOLEAN NOT NULL DEFAULT true,
last_crawled TIMESTAMPTZ,
next_crawl_scheduled TIMESTAMPTZ,
crawl_priority TEXT NOT NULL DEFAULT 'standard' CHECK (crawl_priority IN ('standard', 'high', 'critical')),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tracked_companies_name ON public.tracked_companies(LOWER(company_name));
CREATE INDEX IF NOT EXISTS idx_tracked_companies_auto_story ON public.tracked_companies(auto_story_enabled) WHERE auto_story_enabled = true;
CREATE INDEX IF NOT EXISTS idx_tracked_companies_mention_count ON public.tracked_companies(mention_count DESC);
ALTER TABLE public.tracked_companies ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_tracked_companies" ON public.tracked_companies;
CREATE POLICY "admin_manage_tracked_companies"
ON public.tracked_companies
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- ─── 8. Auto story queue ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.auto_story_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES public.tracked_companies(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
story_type TEXT NOT NULL DEFAULT 'company_news' CHECK (story_type IN (
'product_announcement','press_release_rewrite','company_news',
'product_update','event_coverage','executive_news','thought_leadership'
)),
source_url TEXT,
source_title TEXT,
source_content_summary TEXT,
assigned_pen_name TEXT,
site_id INTEGER NOT NULL DEFAULT 1 REFERENCES public.sites(id),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending','generated','in_review','approved','published','rejected'
)),
priority INTEGER NOT NULL DEFAULT 5,
scheduled_publish_time TIMESTAMPTZ,
generated_title TEXT,
generated_content TEXT,
generated_excerpt TEXT,
review_status TEXT,
rejection_reason TEXT,
quality_confidence NUMERIC(3,2),
reviewed_by UUID REFERENCES public.user_profiles(id) ON DELETE SET NULL,
reviewed_at TIMESTAMPTZ,
published_article_id UUID,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_status ON public.auto_story_queue(status);
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_site_id ON public.auto_story_queue(site_id);
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_company_id ON public.auto_story_queue(company_id);
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_created ON public.auto_story_queue(created_at DESC);
ALTER TABLE public.auto_story_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_auto_story_queue" ON public.auto_story_queue;
CREATE POLICY "admin_manage_auto_story_queue"
ON public.auto_story_queue
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- ─── 9. Crawl log ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.crawl_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES public.tracked_companies(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
source_url TEXT,
items_found INTEGER DEFAULT 0,
stories_queued INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'success' CHECK (status IN ('success','failed','partial')),
error_message TEXT,
crawled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_crawl_log_company_id ON public.crawl_log(company_id);
CREATE INDEX IF NOT EXISTS idx_crawl_log_crawled_at ON public.crawl_log(crawled_at DESC);
ALTER TABLE public.crawl_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_crawl_log" ON public.crawl_log;
CREATE POLICY "admin_manage_crawl_log"
ON public.crawl_log
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- ─── 10. Company tracker settings ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.company_tracker_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
auto_story_global_enabled BOOLEAN NOT NULL DEFAULT true,
mention_threshold INTEGER NOT NULL DEFAULT 2,
max_stories_per_company_per_week INTEGER NOT NULL DEFAULT 3,
max_stories_per_day_per_site INTEGER NOT NULL DEFAULT 20,
auto_publish_enabled BOOLEAN NOT NULL DEFAULT false,
auto_publish_confidence_threshold NUMERIC(3,2) NOT NULL DEFAULT 0.85,
image_download_enabled BOOLEAN NOT NULL DEFAULT true,
blocked_sources TEXT[] DEFAULT ARRAY[
'tvtechnology.com','broadcastingcable.com','sportsvideo.org','ibc.org',
'digitaltveurope.com','sportspromedia.com','ravepubs.com','avnetwork.com',
'avtechnology.com','commercialintegrator.com','systemscontractor.com',
'infocommshow.org','avinteriors.com'
],
crawl_frequency_high_hours INTEGER NOT NULL DEFAULT 24,
crawl_frequency_medium_hours INTEGER NOT NULL DEFAULT 72,
crawl_frequency_standard_hours INTEGER NOT NULL DEFAULT 168,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO public.company_tracker_settings DEFAULT VALUES
ON CONFLICT DO NOTHING;
ALTER TABLE public.company_tracker_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_tracker_settings" ON public.company_tracker_settings;
CREATE POLICY "admin_manage_tracker_settings"
ON public.company_tracker_settings
FOR ALL
TO authenticated
USING (true)
WITH CHECK (true);
-- ─── 11. Pen names ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.pen_names (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
site_id INTEGER REFERENCES public.sites(id),
beat TEXT,
bio TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
last_assigned TIMESTAMPTZ,
consecutive_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- BB pen names (site_id = 1) — from existing content engine
INSERT INTO public.pen_names (name, site_id, beat, is_active) VALUES
('Alex Morgan', 1, 'technology', true),
('Jordan Blake', 1, 'business', true),
('Sam Rivera', 1, 'live-production',true),
('Casey Quinn', 1, 'streaming', true),
('Taylor Frost', 1, 'post-production',true),
('Morgan Lee', 1, 'broadcast', true),
('Riley Stone', 1, 'events', true),
('Drew Callahan', 1, 'audio', true),
('Avery Walsh', 1, 'camera', true),
('Quinn Harmon', 1, 'ip-networking', true),
('Blake Sutton', 1, 'ott', true),
('Reese Donovan', 1, 'media-management',true)
ON CONFLICT (name) DO NOTHING;
-- AV Beat pen names (site_id = 2)
INSERT INTO public.pen_names (name, site_id, beat, is_active) VALUES
('Rex Chandler', 2, 'av-integration', true),
('Dana Flux', 2, 'digital-signage', true),
('Mitch Boardroom', 2, 'unified-communications', true),
('Sloane Rigging', 2, 'sound-audio', true),
('Chip Crosspoint', 2, 'streaming-technology', true),
('Blair Presenter', 2, 'education-av', true),
('Jordan Lumen', 2, 'display-technology', true)
ON CONFLICT (name) DO NOTHING;
-- ─── 12. Triggers for updated_at ─────────────────────────────────────────────
DROP TRIGGER IF EXISTS update_tracked_companies_updated_at ON public.tracked_companies;
CREATE TRIGGER update_tracked_companies_updated_at
BEFORE UPDATE ON public.tracked_companies
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
DROP TRIGGER IF EXISTS update_auto_story_queue_updated_at ON public.auto_story_queue;
CREATE TRIGGER update_auto_story_queue_updated_at
BEFORE UPDATE ON public.auto_story_queue
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
DROP TRIGGER IF EXISTS update_banned_terms_updated_at ON public.banned_terms;
CREATE TRIGGER update_banned_terms_updated_at
BEFORE UPDATE ON public.banned_terms
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
DROP TRIGGER IF EXISTS update_display_name_overrides_updated_at ON public.display_name_overrides;
CREATE TRIGGER update_display_name_overrides_updated_at
BEFORE UPDATE ON public.display_name_overrides
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- ─── 13. Helper function: get public display name ────────────────────────────
CREATE OR REPLACE FUNCTION public.get_public_display_name(p_user_id UUID, p_site_id INTEGER)
RETURNS TEXT
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT COALESCE(
(SELECT public_display_name FROM public.display_name_overrides
WHERE user_id = p_user_id AND site_id = p_site_id LIMIT 1),
(SELECT full_name FROM public.user_profiles WHERE id = p_user_id LIMIT 1)
)
$$;
-- ─── 14. Helper function: check banned terms ─────────────────────────────────
CREATE OR REPLACE FUNCTION public.check_banned_terms(
p_text TEXT,
p_site_id INTEGER
)
RETURNS TABLE(term TEXT, ban_level TEXT)
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT bt.term, bt.ban_level
FROM public.banned_terms bt
WHERE bt.is_active = true
AND (
bt.site_scope = 'both'
OR (bt.site_scope = 'broadcastbeat' AND p_site_id = 1)
OR (bt.site_scope = 'avbeat' AND p_site_id = 2)
)
AND LOWER(p_text) LIKE '%' || LOWER(bt.term) || '%'
LIMIT 1;
$$;
-- ─── 15. RLS for sites and site_categories (public read) ─────────────────────
ALTER TABLE public.sites ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.site_categories ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public_read_sites" ON public.sites;
CREATE POLICY "public_read_sites" ON public.sites FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "admin_manage_sites" ON public.sites;
CREATE POLICY "admin_manage_sites" ON public.sites FOR ALL TO authenticated
USING (public.is_admin_user()) WITH CHECK (public.is_admin_user());
DROP POLICY IF EXISTS "public_read_site_categories" ON public.site_categories;
CREATE POLICY "public_read_site_categories" ON public.site_categories FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "admin_manage_site_categories" ON public.site_categories;
CREATE POLICY "admin_manage_site_categories" ON public.site_categories FOR ALL TO authenticated
USING (public.is_admin_user()) WITH CHECK (public.is_admin_user());

View File

@@ -0,0 +1,599 @@
-- ============================================================
-- Migration: Sections 15-19
-- Timestamp: 20260322240000
-- Covers: event_calendar, author_style_profiles, show_story_queue,
-- translations, ui_translations, AV Beat seed companies,
-- pen name roster update (authorized rosters only),
-- blocked sources update (remove infocommshow.org),
-- language preferences
-- ============================================================
-- ─── 1. Event Calendar ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.event_calendar (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_name TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT 'trade_show' CHECK (event_type IN (
'trade_show','conference','regional_expo','awards','virtual'
)),
site_id INTEGER NOT NULL DEFAULT 1 REFERENCES public.sites(id),
city TEXT,
venue TEXT,
country TEXT,
start_date DATE,
end_date DATE,
story_engine_start_date DATE,
story_engine_end_date DATE,
official_site TEXT,
seo_tier INTEGER NOT NULL DEFAULT 3 CHECK (seo_tier BETWEEN 1 AND 4),
status TEXT NOT NULL DEFAULT 'upcoming' CHECK (status IN (
'upcoming','active','post_show','off_season','cancelled','unconfirmed'
)),
year INTEGER NOT NULL DEFAULT EXTRACT(YEAR FROM CURRENT_DATE)::INTEGER,
phase1_volume_min INTEGER NOT NULL DEFAULT 1,
phase1_volume_max INTEGER NOT NULL DEFAULT 3,
phase2_volume_min INTEGER NOT NULL DEFAULT 3,
phase2_volume_max INTEGER NOT NULL DEFAULT 5,
phase3_volume_min INTEGER NOT NULL DEFAULT 5,
phase3_volume_max INTEGER NOT NULL DEFAULT 8,
phase4_volume_min INTEGER NOT NULL DEFAULT 1,
phase4_volume_max INTEGER NOT NULL DEFAULT 3,
phase5_volume_min INTEGER NOT NULL DEFAULT 1,
phase5_volume_max INTEGER NOT NULL DEFAULT 2,
auto_publish_enabled BOOLEAN NOT NULL DEFAULT false,
engine_paused BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_event_calendar_site_id ON public.event_calendar(site_id);
CREATE INDEX IF NOT EXISTS idx_event_calendar_year ON public.event_calendar(year);
CREATE INDEX IF NOT EXISTS idx_event_calendar_status ON public.event_calendar(status);
CREATE INDEX IF NOT EXISTS idx_event_calendar_start_date ON public.event_calendar(start_date);
ALTER TABLE public.event_calendar ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_event_calendar" ON public.event_calendar;
CREATE POLICY "admin_manage_event_calendar"
ON public.event_calendar FOR ALL TO authenticated
USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "public_read_event_calendar" ON public.event_calendar;
CREATE POLICY "public_read_event_calendar"
ON public.event_calendar FOR SELECT TO public USING (true);
-- ─── 2. Seed Broadcast Beat shows (site_id = 1) ──────────────────────────────
INSERT INTO public.event_calendar (
event_name, event_type, site_id, city, venue, country,
start_date, end_date, story_engine_start_date, story_engine_end_date,
official_site, seo_tier, year,
phase1_volume_min, phase1_volume_max,
phase2_volume_min, phase2_volume_max,
phase3_volume_min, phase3_volume_max,
phase4_volume_min, phase4_volume_max,
phase5_volume_min, phase5_volume_max,
notes
) VALUES
-- NAB Show Las Vegas
(
'NAB Show Las Vegas', 'trade_show', 1,
'Las Vegas', 'Las Vegas Convention Center', 'USA',
'2026-04-25', '2026-04-30',
'2026-02-24', '2026-05-14',
'nabshow.com', 1, 2026,
3, 3, 5, 8, 8, 12, 3, 5, 1, 2,
'Primary NAB Show. James Mercer primary byline for pillar content.'
),
-- NAB Show New York
(
'NAB Show New York', 'trade_show', 1,
'New York', 'Javits Center', 'USA',
'2026-10-19', '2026-10-21',
'2026-08-20', '2026-10-28',
'nabshow.com', 2, 2026,
3, 3, 5, 8, 8, 12, 3, 5, 1, 2,
'NAB Show New York. James Mercer primary byline.'
),
-- IBC Show
(
'IBC Show', 'trade_show', 1,
'Amsterdam', 'RAI Amsterdam', 'Netherlands',
'2026-09-11', '2026-09-14',
'2026-07-13', '2026-09-28',
'ibc.org', 1, 2026,
3, 3, 5, 8, 8, 10, 3, 5, 1, 2,
'European equivalent of NAB Las Vegas. Ellen Forsythe primary byline.'
),
-- BroadcastAsia / ATxSG
(
'BroadcastAsia / ATxSG', 'trade_show', 1,
'Singapore', 'Singapore Expo', 'Singapore',
'2026-05-27', '2026-05-29',
'2026-03-28', '2026-06-05',
'broadcastasia.com', 2, 2026,
2, 2, 4, 6, 6, 8, 2, 3, 1, 1,
'Cover as both BroadcastAsia and ATxSG/Asia Tech x Singapore. Ellen Forsythe primary byline.'
),
-- CABSAT
(
'CABSAT', 'trade_show', 1,
'Dubai', 'Dubai World Trade Centre', 'UAE',
'2026-03-10', '2026-03-12',
'2026-01-09', '2026-03-19',
'cabsat.com', 2, 2026,
2, 2, 3, 5, 5, 7, 2, 3, 1, 1,
'Premier broadcast/satellite event for Middle East, Africa, South Asia.'
),
-- MPTS
(
'MPTS — Media Production & Technology Show', 'trade_show', 1,
'London', 'Olympia London', 'UK',
'2026-05-13', '2026-05-14',
'2026-03-14', '2026-05-21',
'thempts.co.uk', 3, 2026,
1, 2, 3, 4, 4, 6, 1, 2, 1, 1,
'UK broadcast and media production technology event.'
),
-- NATPE Global
(
'NATPE Global', 'conference', 1,
'TBD', 'TBD', 'TBD',
NULL, NULL, NULL, NULL,
'natpe.org', 3, 2026,
1, 2, 2, 3, 3, 5, 1, 2, 1, 1,
'Confirm dates annually. Format and location varies. Content/programming focus.'
),
-- SET Expo
(
'SET Expo', 'trade_show', 1,
'São Paulo', 'São Paulo Expo', 'Brazil',
'2026-08-18', '2026-08-20',
'2026-07-04', '2026-08-27',
'setexpo.com.br', 3, 2026,
1, 2, 2, 4, 4, 6, 1, 2, 1, 1,
'Latin America largest broadcast tech event. English stories from Portuguese sources.'
),
-- NewsTECHForum
(
'NewsTECHForum', 'conference', 1,
'New York', 'TBD', 'USA',
'2026-01-14', '2026-01-15',
'2025-12-15', '2026-01-20',
'newstechforum.net', 4, 2026,
1, 1, 2, 3, 3, 4, 1, 1, 1, 1,
'News broadcasting technology. Smaller targeted event.'
),
-- Silicon Valley Video Summit
(
'Silicon Valley Video Summit (SVVS)', 'conference', 1,
'San Francisco Bay Area', 'TBD', 'USA',
NULL, NULL, NULL, NULL,
NULL, 4, 2026,
1, 1, 2, 2, 2, 3, 1, 1, 1, 1,
'Confirm dates annually. Video technology innovation and streaming.'
),
-- TAB Show
(
'TAB Show', 'trade_show', 1,
'Austin', 'Austin Convention Center', 'USA',
'2026-08-12', '2026-08-13',
'2026-07-13', '2026-08-18',
'tab.org', 4, 2026,
1, 1, 2, 2, 3, 3, 1, 1, 1, 1,
'Texas Association of Broadcasters. Regional US broadcast show.'
),
-- Hamburg Open
(
'Hamburg Open', 'conference', 1,
'Hamburg', 'TBD', 'Germany',
NULL, NULL, NULL, NULL,
NULL, 4, 2026,
1, 1, 2, 2, 2, 3, 1, 1, 1, 1,
'Confirm dates annually. European broadcast/media tech. DACH market.'
),
-- AES Convention (Fall NY)
(
'AES Convention — New York', 'trade_show', 1,
'New York', 'Javits Center', 'USA',
'2026-10-28', '2026-10-31',
'2026-09-13', '2026-11-07',
'aes.org/events/conventions/', 2, 2026,
1, 2, 3, 4, 4, 6, 1, 2, 1, 1,
'Fall NY convention — primary AES event. Audio-focused pen names preferred. Thomas Reeves primary byline.'
),
-- AES Convention (Spring Europe)
(
'AES Convention — Europe', 'trade_show', 1,
'TBD', 'TBD', 'Europe',
NULL, NULL, NULL, NULL,
'aes.org/events/conventions/', 2, 2026,
1, 2, 3, 4, 4, 6, 1, 2, 1, 1,
'Spring European convention. Confirm city annually. Thomas Reeves primary byline.'
)
ON CONFLICT DO NOTHING;
-- ─── 3. Seed AV Beat shows (site_id = 2) ─────────────────────────────────────
INSERT INTO public.event_calendar (
event_name, event_type, site_id, city, venue, country,
start_date, end_date, story_engine_start_date, story_engine_end_date,
official_site, seo_tier, year,
phase1_volume_min, phase1_volume_max,
phase2_volume_min, phase2_volume_max,
phase3_volume_min, phase3_volume_max,
phase4_volume_min, phase4_volume_max,
phase5_volume_min, phase5_volume_max,
notes
) VALUES
-- InfoComm
(
'InfoComm', 'trade_show', 2,
'Las Vegas', 'Las Vegas Convention Center', 'USA',
'2026-06-17', '2026-06-19',
'2026-04-18', '2026-07-03',
'infocommshow.org', 1, 2026,
3, 3, 5, 8, 8, 12, 3, 5, 1, 2,
'InfoComm 2026 Las Vegas June 13-19 (exhibits June 17-19). Rex Chandler primary byline. Cover AVIXA XL Awards and FORTÉ Smart Workplace hub.'
),
-- ISE
(
'ISE — Integrated Systems Europe', 'trade_show', 2,
'Barcelona', 'Fira Barcelona Gran Vía', 'Spain',
'2026-02-03', '2026-02-06',
'2025-12-05', '2026-02-20',
'iseurope.org', 1, 2026,
3, 3, 5, 8, 8, 10, 3, 5, 1, 2,
'World largest pro AV show. 92,000+ attendees. Cover ISE Innovation Awards. Rex Chandler / Dana Flux primary bylines.'
),
-- InfoComm EDGE Dubai
(
'InfoComm EDGE Dubai', 'trade_show', 2,
'Dubai', 'Festival Arena', 'UAE',
'2026-10-27', '2026-10-28',
'2026-09-12', '2026-11-04',
'avixa.org', 2, 2026,
2, 2, 3, 4, 4, 5, 1, 2, 1, 1,
'New AVIXA event launching 2026. Experiential format. GCC/Middle East focus. Chip Crosspoint / Derek Wainwright bylines.'
),
-- InfoComm China
(
'InfoComm China — Beijing', 'trade_show', 2,
'Beijing', 'TBD', 'China',
'2026-04-15', '2026-04-17',
'2026-03-16', '2026-04-24',
'infocommchina.com', 2, 2026,
1, 2, 2, 3, 3, 5, 1, 2, 1, 1,
'Asia leading pro AV show. Chinese AV manufacturers and Asia-Pacific market.'
),
-- InfoComm Latin America
(
'InfoComm América Latina', 'trade_show', 2,
'Mexico City', 'WTC Convention Center', 'Mexico',
'2026-10-21', '2026-10-23',
'2026-09-21', '2026-10-30',
'infocommshow.org', 2, 2026,
1, 2, 2, 3, 3, 5, 1, 2, 1, 1,
'Latin America leading pro AV. 5,000+ attendees. Congreso AVIXA co-located. Blair Presenter primary byline.'
),
-- InfoComm India
(
'InfoComm India', 'trade_show', 2,
'Mumbai', 'TBD', 'India',
'2026-09-10', '2026-09-11',
'2026-08-11', '2026-09-16',
'avixa.org', 3, 2026,
1, 1, 2, 2, 2, 3, 1, 1, 1, 1,
'India leading pro AV event. Fastest-growing AV market globally per AVIXA.'
),
-- FORTÉ LIVE
(
'FORTÉ LIVE Regional Expos', 'regional_expo', 2,
'Multiple US Cities', 'Various', 'USA',
NULL, NULL, NULL, NULL,
'ourforte.com/events', 3, 2026,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
'Per-city: 1 preview + 1 recap. Monitor ourforte.com/events for dates. Dallas, Milwaukee, Atlanta, DC area confirmed. FORTÉ is Premier Sponsor of InfoComm 2026.'
),
-- AVIXA Regional Events
(
'AVIXA Regional Events', 'regional_expo', 2,
'Various', 'Various', 'Various',
NULL, NULL, NULL, NULL,
'avixa.org/events', 4, 2026,
1, 1, 1, 1, 1, 2, 1, 1, 1, 1,
'Monitor avixa.org/events. Cover events with exhibition component. Skip pure training events.'
)
ON CONFLICT DO NOTHING;
-- ─── 4. Author Style Profiles ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.author_style_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author_username TEXT NOT NULL,
event_type TEXT NOT NULL DEFAULT 'general',
event_name TEXT,
style_guide_text TEXT,
source_article_count INTEGER NOT NULL DEFAULT 0,
last_updated TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_author_style_profiles_unique
ON public.author_style_profiles(author_username, event_type, COALESCE(event_name, ''));
CREATE INDEX IF NOT EXISTS idx_author_style_profiles_username ON public.author_style_profiles(author_username);
CREATE INDEX IF NOT EXISTS idx_author_style_profiles_event ON public.author_style_profiles(event_type);
ALTER TABLE public.author_style_profiles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_author_style_profiles" ON public.author_style_profiles;
CREATE POLICY "admin_manage_author_style_profiles"
ON public.author_style_profiles FOR ALL TO authenticated
USING (true) WITH CHECK (true);
-- ─── 5. Show Story Queue (extends auto_story_queue for show-specific stories) ─
ALTER TABLE public.auto_story_queue
ADD COLUMN IF NOT EXISTS event_id UUID REFERENCES public.event_calendar(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS show_phase TEXT CHECK (show_phase IN (
'phase1_buildup','phase2_surge','phase3_show_week','phase4_post_show','phase5_off_season'
)),
ADD COLUMN IF NOT EXISTS show_story_type TEXT CHECK (show_story_type IN (
'exhibitor_preview','award_announcement','show_preview_guide','product_announcement',
'keynote_preview','travel_logistics','breaking_announcement','show_floor_report',
'award_winner','best_of_roundup','recap','product_availability','nab_cross_post'
)),
ADD COLUMN IF NOT EXISTS source_show TEXT,
ADD COLUMN IF NOT EXISTS nab_exhibitor_id UUID,
ADD COLUMN IF NOT EXISTS seo_primary_keyword TEXT,
ADD COLUMN IF NOT EXISTS seo_meta_description TEXT;
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_event_id ON public.auto_story_queue(event_id);
CREATE INDEX IF NOT EXISTS idx_auto_story_queue_show_phase ON public.auto_story_queue(show_phase);
-- ─── 6. NAB Exhibitors ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.nab_exhibitors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES public.tracked_companies(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
event_id UUID REFERENCES public.event_calendar(id) ON DELETE CASCADE,
booth_number TEXT,
preview_story_status TEXT NOT NULL DEFAULT 'pending' CHECK (preview_story_status IN (
'pending','generated','published','skipped'
)),
announcement_count INTEGER NOT NULL DEFAULT 0,
is_av_eligible BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(company_name, event_id)
);
CREATE INDEX IF NOT EXISTS idx_nab_exhibitors_event_id ON public.nab_exhibitors(event_id);
CREATE INDEX IF NOT EXISTS idx_nab_exhibitors_company_id ON public.nab_exhibitors(company_id);
ALTER TABLE public.nab_exhibitors ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_nab_exhibitors" ON public.nab_exhibitors;
CREATE POLICY "admin_manage_nab_exhibitors"
ON public.nab_exhibitors FOR ALL TO authenticated
USING (true) WITH CHECK (true);
-- ─── 7. Translations table ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.translations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL,
post_type TEXT NOT NULL DEFAULT 'native' CHECK (post_type IN ('native','imported')),
language_code TEXT NOT NULL CHECK (language_code IN ('en','es','pt','fr','de','zh','ja','ar','hi')),
title_translated TEXT,
content_translated TEXT,
excerpt_translated TEXT,
meta_description_translated TEXT,
slug_translated TEXT,
generated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
is_stale BOOLEAN NOT NULL DEFAULT false,
word_count INTEGER,
UNIQUE(post_id, language_code)
);
CREATE INDEX IF NOT EXISTS idx_translations_post_id ON public.translations(post_id);
CREATE INDEX IF NOT EXISTS idx_translations_language ON public.translations(language_code);
CREATE INDEX IF NOT EXISTS idx_translations_stale ON public.translations(is_stale) WHERE is_stale = true;
ALTER TABLE public.translations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public_read_translations" ON public.translations;
CREATE POLICY "public_read_translations"
ON public.translations FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "admin_manage_translations" ON public.translations;
CREATE POLICY "admin_manage_translations"
ON public.translations FOR ALL TO authenticated
USING (true) WITH CHECK (true);
-- ─── 8. UI Translations (admin-generated public labels) ─────────────────────
CREATE TABLE IF NOT EXISTS public.ui_translations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
language_code TEXT NOT NULL CHECK (language_code IN ('en','es','pt','fr','de','zh','ja','ar','hi')),
value TEXT NOT NULL,
context TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(key, language_code)
);
CREATE INDEX IF NOT EXISTS idx_ui_translations_key ON public.ui_translations(key);
CREATE INDEX IF NOT EXISTS idx_ui_translations_lang ON public.ui_translations(language_code);
ALTER TABLE public.ui_translations ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public_read_ui_translations" ON public.ui_translations;
CREATE POLICY "public_read_ui_translations"
ON public.ui_translations FOR SELECT TO public USING (true);
DROP POLICY IF EXISTS "admin_manage_ui_translations" ON public.ui_translations;
CREATE POLICY "admin_manage_ui_translations"
ON public.ui_translations FOR ALL TO authenticated
USING (true) WITH CHECK (true);
-- ─── 9. Translation Queue ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.translation_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL,
post_type TEXT NOT NULL DEFAULT 'native',
language_code TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 5,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','completed','failed')),
error_message TEXT,
queued_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMPTZ,
UNIQUE(post_id, language_code, status)
);
CREATE INDEX IF NOT EXISTS idx_translation_queue_status ON public.translation_queue(status);
CREATE INDEX IF NOT EXISTS idx_translation_queue_priority ON public.translation_queue(priority DESC, queued_at ASC);
ALTER TABLE public.translation_queue ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_translation_queue" ON public.translation_queue;
CREATE POLICY "admin_manage_translation_queue"
ON public.translation_queue FOR ALL TO authenticated
USING (true) WITH CHECK (true);
-- ─── 10. User Language Preferences ──────────────────────────────────────────
ALTER TABLE public.user_profiles
ADD COLUMN IF NOT EXISTS lang_pref TEXT DEFAULT 'en' CHECK (lang_pref IN ('en','es','pt','fr','de','zh','ja','ar','hi'));
-- ─── 11. Update pen_names table with authorized rosters ──────────────────────
-- Remove old placeholder BB pen names and insert authorized roster
DELETE FROM public.pen_names WHERE site_id = 1;
INSERT INTO public.pen_names (name, site_id, beat, bio, is_active) VALUES
('Michael Strand', 1, 'broadcast-technology', 'Senior Technology Editor. Broadcast technology, industry news, NAB Show primary correspondent.', true),
('David Harlow', 1, 'broadcast-engineering', 'Broadcast Engineering Correspondent. Broadcast engineering, transmission, RF, satellite, signal distribution.', true),
('Karen Fielding', 1, 'post-production', 'Post Production Editor. Post production, editing, color grading, VFX, media asset management.', true),
('James Mercer', 1, 'nab-show', 'NAB Show Senior Correspondent. NAB Show Las Vegas, NAB Show New York, show floor coverage, exhibitor news. PRIMARY byline for all major NAB pillar content, day recaps, and best-of roundups.', true),
('Peter Calloway', 1, 'radio-transmission', 'Radio & Transmission Reporter. Radio broadcasting, AM/FM/DAB, transmission infrastructure, tower and antenna technology.', true),
('Sandra Voss', 1, 'production-technology', 'Production Technology Editor. Studio production, cameras, switchers, graphics systems, live production.', true),
('Brian Kowalski', 1, 'streaming-ott', 'Streaming & OTT Correspondent. Streaming technology, OTT platforms, CDN, video delivery, cloud broadcast.', true),
('Laura Pennington', 1, 'industry-news', 'Industry News Editor. Company news, acquisitions, appointments, executive moves, industry business coverage.', true),
('Thomas Reeves', 1, 'audio-technology', 'Audio Technology Correspondent. Broadcast audio, mixing consoles, audio processing, AES Convention coverage.', true),
('Christine Vale', 1, 'business-industry', 'Business & Industry Reporter. Industry analysis, market trends, opinion, business of broadcast.', true),
('Marcus Webb', 1, 'live-production', 'Live Production Correspondent. Live events, sports broadcasting, remote production, REMI and IP production workflows.', true),
('Ellen Forsythe', 1, 'international-markets', 'International Markets Editor. IBC Show, BroadcastAsia, CABSAT, international broadcast markets, European and Asian coverage.', true)
ON CONFLICT (name) DO UPDATE SET
site_id = EXCLUDED.site_id,
beat = EXCLUDED.beat,
bio = EXCLUDED.bio,
is_active = EXCLUDED.is_active;
-- Update AV Beat pen names with corrected roster (replace Mitch Boardroom with Derek Wainwright)
DELETE FROM public.pen_names WHERE site_id = 2;
INSERT INTO public.pen_names (name, site_id, beat, bio, is_active) VALUES
('Rex Chandler', 2, 'av-integration', 'AV Integration Senior Correspondent. AV systems integration, integrator news, company announcements, InfoComm coverage.', true),
('Dana Flux', 2, 'digital-signage', 'Digital Signage & Display Editor. Digital signage, LED walls, display technology, CMS platforms.', true),
('Derek Wainwright', 2, 'unified-communications', 'Unified Communications Correspondent. UC&C, video conferencing, hybrid meeting rooms, Microsoft Teams Rooms, Zoom Rooms, enterprise collaboration technology.', true),
('Sloane Rigging', 2, 'sound-audio', 'Installed AV & Audio Editor. Installed audio, sound reinforcement, houses of worship AV, DSP, loudspeakers, microphone systems.', true),
('Chip Crosspoint', 2, 'av-networking', 'AV Networking & Technology Correspondent. AV-over-IP, Dante, IPMX, control systems, IT/AV convergence, networked AV.', true),
('Blair Presenter', 2, 'education-corporate-av', 'Education & Corporate AV Editor. Education AV, classroom technology, corporate AV, government AV, ISE coverage.', true),
('Jordan Lumen', 2, 'display-visualization', 'Display & Visualization Correspondent. Projectors, large-venue displays, video walls, visualization technology, simulation.', true)
ON CONFLICT (name) DO UPDATE SET
site_id = EXCLUDED.site_id,
beat = EXCLUDED.beat,
bio = EXCLUDED.bio,
is_active = EXCLUDED.is_active;
-- ─── 12. Fix blocked_sources: remove infocommshow.org, add correct list ──────
UPDATE public.company_tracker_settings
SET blocked_sources = ARRAY[
'tvtechnology.com','broadcastingcable.com','sportsvideo.org',
'digitaltveurope.com','sportspromedia.com','nexttv.com',
'ravepubs.com','avnetwork.com','avtechnology.com',
'commercialintegrator.com','systemscontractor.com',
'avinteriors.com','avinteractive.com','avmagazine.net'
]
WHERE blocked_sources IS NOT NULL;
-- ─── 13. Seed AV Beat tracked companies (Section 19F) ────────────────────────
INSERT INTO public.tracked_companies (company_name, company_website, site_scope, auto_story_enabled, crawl_priority, mention_count)
VALUES
('Crestron', 'crestron.com', 'avbeat', true, 'high', 5),
('Extron', 'extron.com', 'avbeat', true, 'high', 5),
('AMX', 'amx.com', 'avbeat', true, 'standard', 3),
('QSC', 'qsc.com', 'avbeat', true, 'high', 5),
('Biamp', 'biamp.com', 'avbeat', true, 'standard', 3),
('Shure', 'shure.com', 'both', true, 'high', 5),
('Sennheiser', 'sennheiser.com', 'both', true, 'standard', 3),
('Audio-Technica', 'audio-technica.com', 'both', true, 'standard', 3),
('Bose Professional', 'pro.bose.com', 'avbeat', true, 'standard', 3),
('JBL Professional', 'jblpro.com', 'avbeat', true, 'standard', 3),
('Crown Audio', 'crownaudio.com', 'avbeat', true, 'standard', 3),
('BSS Audio', 'bssaudio.com', 'avbeat', true, 'standard', 3),
('Audinate', 'audinate.com', 'avbeat', true, 'standard', 3),
('Samsung Professional', 'samsung.com/business', 'avbeat', true, 'high', 5),
('LG Business Solutions', 'lg.com/business', 'avbeat', true, 'high', 5),
('Sony Professional', 'pro.sony', 'both', true, 'high', 5),
('Panasonic Business', 'business.panasonic.com', 'both', true, 'standard', 3),
('Sharp NEC', 'sharpnecdisplays.com', 'avbeat', true, 'standard', 3),
('Barco', 'barco.com', 'avbeat', true, 'high', 5),
('Christie', 'christiedigital.com', 'avbeat', true, 'standard', 3),
('Epson Professional', 'epson.com/professional', 'avbeat', true, 'standard', 3),
('BenQ', 'benq.com', 'avbeat', true, 'standard', 3),
('Optoma', 'optoma.com', 'avbeat', true, 'standard', 3),
('Planar', 'planar.com', 'avbeat', true, 'standard', 3),
('Leyard', 'leyard.com', 'avbeat', true, 'standard', 3),
('Absen', 'absen.com', 'avbeat', true, 'standard', 3),
('Daktronics', 'daktronics.com', 'avbeat', true, 'standard', 3),
('Primeview', 'primeviewglobal.com', 'avbeat', true, 'standard', 3),
('Unilumin', 'unilumin.com', 'avbeat', true, 'standard', 3),
('Peerless-AV', 'peerless-av.com', 'avbeat', true, 'standard', 3),
('Chief', 'chiefmfg.com', 'avbeat', true, 'standard', 3),
('Middle Atlantic', 'middleatlantic.com', 'avbeat', true, 'standard', 3),
('Legrand AV', 'legrandav.com', 'avbeat', true, 'standard', 3),
('Atlona', 'atlona.com', 'avbeat', true, 'standard', 3),
('Kramer', 'kramerav.com', 'avbeat', true, 'standard', 3),
('ATEN', 'aten.com', 'avbeat', true, 'standard', 3),
('Black Box', 'blackbox.com', 'avbeat', true, 'standard', 3),
('Mersive', 'mersive.com', 'avbeat', true, 'standard', 3),
('Logitech Video Collaboration', 'logitech.com/video-collaboration', 'avbeat', true, 'high', 5),
('Poly', 'poly.com', 'avbeat', true, 'standard', 3),
('Jabra', 'jabra.com', 'avbeat', true, 'standard', 3),
('DTEN', 'dten.com', 'avbeat', true, 'standard', 3),
('Neat', 'neat.no', 'avbeat', true, 'standard', 3),
('Yealink', 'yealink.com', 'avbeat', true, 'standard', 3),
('Cisco Webex', 'webex.com', 'avbeat', true, 'high', 5),
('Crestron Flex', 'crestron.com/flex', 'avbeat', true, 'standard', 3),
('AVI-SPL', 'avispl.com', 'avbeat', true, 'standard', 3),
('Diversified', 'diversifiedus.com', 'avbeat', true, 'standard', 3),
('AVI Systems', 'avisystems.com', 'avbeat', true, 'standard', 3),
('FORTÉ', 'ourforte.com', 'avbeat', true, 'high', 5)
ON CONFLICT (company_name) DO UPDATE SET
site_scope = EXCLUDED.site_scope,
auto_story_enabled = EXCLUDED.auto_story_enabled,
crawl_priority = EXCLUDED.crawl_priority;
-- ─── 14. Triggers ────────────────────────────────────────────────────────────
DROP TRIGGER IF EXISTS update_event_calendar_updated_at ON public.event_calendar;
CREATE TRIGGER update_event_calendar_updated_at
BEFORE UPDATE ON public.event_calendar
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
DROP TRIGGER IF EXISTS update_ui_translations_updated_at ON public.ui_translations;
CREATE TRIGGER update_ui_translations_updated_at
BEFORE UPDATE ON public.ui_translations
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- ─── 15. Seed UI translations (English base keys) ────────────────────────────
INSERT INTO public.ui_translations (key, language_code, value, context) VALUES
('nav.home', 'en', 'Home', 'Navigation'),
('nav.forum', 'en', 'Forum', 'Navigation'),
('nav.advertise', 'en', 'Advertise', 'Navigation'),
('nav.about', 'en', 'About', 'Navigation'),
('nav.subscribe', 'en', 'Subscribe', 'Navigation'),
('lang.en', 'en', 'English', 'Language switcher'),
('lang.es', 'en', 'Español', 'Language switcher'),
('lang.pt', 'en', 'Português', 'Language switcher'),
('lang.fr', 'en', 'Français', 'Language switcher'),
('lang.de', 'en', 'Deutsch', 'Language switcher'),
('lang.zh', 'en', '中文', 'Language switcher'),
('lang.ja', 'en', '日本語', 'Language switcher'),
('lang.ar', 'en', 'العربية', 'Language switcher'),
('lang.hi', 'en', 'हिंदी', 'Language switcher')
ON CONFLICT (key, language_code) DO NOTHING;

View File

@@ -0,0 +1,164 @@
-- ============================================================
-- AI Console & Hybrid AI Routing — Database Schema
-- Sections A-D: AI Console, Sessions, Health Log, Settings
-- ============================================================
-- ─── AI Console Settings ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ai_console_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
ollama_endpoint text NOT NULL DEFAULT '',
ollama_model_default text NOT NULL DEFAULT '',
ollama_model_translation text NOT NULL DEFAULT '',
ollama_model_background text NOT NULL DEFAULT '',
auto_failover boolean NOT NULL DEFAULT true,
failover_timeout_seconds integer NOT NULL DEFAULT 8,
default_site_id integer NOT NULL DEFAULT 1,
default_pen_name_bb text NOT NULL DEFAULT 'Michael Strand',
default_pen_name_av text NOT NULL DEFAULT 'Rex Chandler',
save_session_history boolean NOT NULL DEFAULT true,
history_retention_days integer NOT NULL DEFAULT 30,
streaming_enabled boolean NOT NULL DEFAULT true,
background_use_local boolean NOT NULL DEFAULT true,
background_timeout_seconds integer NOT NULL DEFAULT 120,
priority_queue_enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ─── AI Console Sessions ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ai_console_sessions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
site_id integer NOT NULL DEFAULT 1,
pen_name text,
first_message_preview text NOT NULL DEFAULT '',
provider_used text NOT NULL DEFAULT 'anthropic',
model_used text NOT NULL DEFAULT 'claude-sonnet-4-20250514',
last_message_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ai_console_sessions_user_id
ON public.ai_console_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_console_sessions_last_message
ON public.ai_console_sessions(last_message_at DESC);
-- ─── AI Console Messages ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ai_console_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
session_id uuid NOT NULL REFERENCES public.ai_console_sessions(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content text NOT NULL DEFAULT '',
provider_used text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ai_console_messages_session_id
ON public.ai_console_messages(session_id);
-- ─── AI Health Log ────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ai_health_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
provider text NOT NULL CHECK (provider IN ('ollama', 'anthropic')),
success boolean NOT NULL DEFAULT true,
latency_ms integer NOT NULL DEFAULT 0,
model_used text NOT NULL DEFAULT '',
priority integer NOT NULL DEFAULT 4,
error_message text,
is_failover boolean NOT NULL DEFAULT false,
logged_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_ai_health_log_logged_at
ON public.ai_health_log(logged_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_health_log_provider
ON public.ai_health_log(provider, logged_at DESC);
-- Auto-purge logs older than 30 days (keep table lean)
-- This is handled by a periodic cleanup; no trigger needed for now.
-- ─── RLS Policies ────────────────────────────────────────────
-- ai_console_settings: admin read/write only
ALTER TABLE public.ai_console_settings ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'ai_console_settings' AND policyname = 'admin_all'
) THEN
CREATE POLICY admin_all ON public.ai_console_settings
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid() AND role IN ('administrator', 'admin')
)
);
END IF;
END $$;
-- ai_console_sessions: users own their sessions
ALTER TABLE public.ai_console_sessions ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'ai_console_sessions' AND policyname = 'owner_all'
) THEN
CREATE POLICY owner_all ON public.ai_console_sessions
FOR ALL
USING (user_id = auth.uid());
END IF;
END $$;
-- ai_console_messages: users own messages via session ownership
ALTER TABLE public.ai_console_messages ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'ai_console_messages' AND policyname = 'owner_via_session'
) THEN
CREATE POLICY owner_via_session ON public.ai_console_messages
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.ai_console_sessions
WHERE id = ai_console_messages.session_id AND user_id = auth.uid()
)
);
END IF;
END $$;
-- ai_health_log: admin read, service role write (health-log route uses service role implicitly)
ALTER TABLE public.ai_health_log ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'ai_health_log' AND policyname = 'admin_read'
) THEN
CREATE POLICY admin_read ON public.ai_health_log
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.user_profiles
WHERE id = auth.uid() AND role IN ('administrator', 'admin')
)
);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies WHERE tablename = 'ai_health_log' AND policyname = 'service_insert'
) THEN
CREATE POLICY service_insert ON public.ai_health_log
FOR INSERT
WITH CHECK (true);
END IF;
END $$;

View File

@@ -0,0 +1,343 @@
-- ============================================================
-- Migration: Unified Billing System
-- Timestamp: 20260322260000
-- Covers: billing_companies, billing_clients, billing_invoices,
-- billing_line_items, billing_payments, billing_sync_log,
-- ad_campaigns table, billing settings
-- ============================================================
-- ─── 1. billing_companies ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
legal_name TEXT NOT NULL,
llc_entity TEXT,
address TEXT,
email TEXT,
invoice_prefix TEXT NOT NULL DEFAULT 'INV-',
stripe_account_id TEXT,
mooninvoice_company_id TEXT,
mooninvoice_api_key TEXT,
stripe_secret_key TEXT,
stripe_publishable_key TEXT,
default_payment_terms TEXT NOT NULL DEFAULT 'Net 30',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_companies_active ON public.billing_companies(is_active);
ALTER TABLE public.billing_companies ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_companies" ON public.billing_companies;
CREATE POLICY "admin_manage_billing_companies"
ON public.billing_companies FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 2. billing_clients ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES public.billing_companies(id) ON DELETE CASCADE,
name TEXT NOT NULL,
organization TEXT,
email TEXT,
phone TEXT,
billing_address TEXT,
notes TEXT,
mooninvoice_contact_id TEXT,
stripe_customer_id TEXT,
total_billed NUMERIC(12,2) NOT NULL DEFAULT 0,
total_paid NUMERIC(12,2) NOT NULL DEFAULT 0,
invoice_consolidation BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_clients_company_id ON public.billing_clients(company_id);
CREATE INDEX IF NOT EXISTS idx_billing_clients_mooninvoice_id ON public.billing_clients(mooninvoice_contact_id);
ALTER TABLE public.billing_clients ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_clients" ON public.billing_clients;
CREATE POLICY "admin_manage_billing_clients"
ON public.billing_clients FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 3. billing_invoices ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID REFERENCES public.billing_companies(id) ON DELETE CASCADE,
client_id UUID REFERENCES public.billing_clients(id) ON DELETE SET NULL,
invoice_number TEXT NOT NULL,
issue_date DATE,
due_date DATE,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','sent','viewed','paid','overdue','cancelled')),
subtotal NUMERIC(12,2) NOT NULL DEFAULT 0,
tax NUMERIC(12,2) NOT NULL DEFAULT 0,
total NUMERIC(12,2) NOT NULL DEFAULT 0,
amount_paid NUMERIC(12,2) NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
notes TEXT,
internal_memo TEXT,
stripe_payment_link TEXT,
mooninvoice_invoice_id TEXT,
pdf_url TEXT,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_company_id ON public.billing_invoices(company_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_client_id ON public.billing_invoices(client_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_status ON public.billing_invoices(status);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_mooninvoice_id ON public.billing_invoices(mooninvoice_invoice_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_due_date ON public.billing_invoices(due_date);
ALTER TABLE public.billing_invoices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_invoices" ON public.billing_invoices;
CREATE POLICY "admin_manage_billing_invoices"
ON public.billing_invoices FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 4. billing_line_items ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID REFERENCES public.billing_invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
quantity NUMERIC(10,2) NOT NULL DEFAULT 1,
rate NUMERIC(12,2) NOT NULL DEFAULT 0,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
campaign_id UUID,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_line_items_invoice_id ON public.billing_line_items(invoice_id);
CREATE INDEX IF NOT EXISTS idx_billing_line_items_campaign_id ON public.billing_line_items(campaign_id);
ALTER TABLE public.billing_line_items ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_line_items" ON public.billing_line_items;
CREATE POLICY "admin_manage_billing_line_items"
ON public.billing_line_items FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 5. billing_payments ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID REFERENCES public.billing_invoices(id) ON DELETE SET NULL,
client_id UUID REFERENCES public.billing_clients(id) ON DELETE SET NULL,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
payment_date DATE,
payment_method TEXT,
stripe_charge_id TEXT,
stripe_payment_intent_id TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'succeeded' CHECK (status IN ('succeeded','failed','pending','refunded','disputed')),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_billing_payments_invoice_id ON public.billing_payments(invoice_id);
CREATE INDEX IF NOT EXISTS idx_billing_payments_client_id ON public.billing_payments(client_id);
CREATE INDEX IF NOT EXISTS idx_billing_payments_stripe_charge ON public.billing_payments(stripe_charge_id);
ALTER TABLE public.billing_payments ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_payments" ON public.billing_payments;
CREATE POLICY "admin_manage_billing_payments"
ON public.billing_payments FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 6. billing_sync_log ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_sync_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sync_type TEXT NOT NULL,
company_id UUID REFERENCES public.billing_companies(id) ON DELETE SET NULL,
records_synced INTEGER NOT NULL DEFAULT 0,
errors JSONB,
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','failed'))
);
CREATE INDEX IF NOT EXISTS idx_billing_sync_log_company_id ON public.billing_sync_log(company_id);
CREATE INDEX IF NOT EXISTS idx_billing_sync_log_started_at ON public.billing_sync_log(started_at);
ALTER TABLE public.billing_sync_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_sync_log" ON public.billing_sync_log;
CREATE POLICY "admin_manage_billing_sync_log"
ON public.billing_sync_log FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 7. billing_settings ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.billing_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
billing_mode TEXT NOT NULL DEFAULT 'mooninvoice' CHECK (billing_mode IN ('mooninvoice','native')),
sync_frequency_minutes INTEGER NOT NULL DEFAULT 15,
last_sync_rmp TIMESTAMPTZ,
last_sync_jtr TIMESTAMPTZ,
default_payment_terms TEXT NOT NULL DEFAULT 'Net 30',
default_currency TEXT NOT NULL DEFAULT 'USD',
late_payment_reminder_enabled BOOLEAN NOT NULL DEFAULT false,
late_payment_reminder_days INTEGER NOT NULL DEFAULT 7,
auto_send_reminders BOOLEAN NOT NULL DEFAULT false,
stripe_test_mode BOOLEAN NOT NULL DEFAULT true,
migration_import_status JSONB,
migration_last_run TIMESTAMPTZ,
migration_verified BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE public.billing_settings ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_billing_settings" ON public.billing_settings;
CREATE POLICY "admin_manage_billing_settings"
ON public.billing_settings FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 8. ad_campaigns table ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ad_campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
client_name TEXT,
campaign_type TEXT NOT NULL DEFAULT 'banner' CHECK (campaign_type IN ('banner','email','sponsored','newsletter')),
site_id INTEGER REFERENCES public.sites(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','pending_approval','approved','active','paused','completed','cancelled')),
start_date DATE,
end_date DATE,
budget NUMERIC(12,2),
rate NUMERIC(12,2),
notes TEXT,
billing_invoice_id UUID REFERENCES public.billing_invoices(id) ON DELETE SET NULL,
billing_company_id UUID REFERENCES public.billing_companies(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_status ON public.ad_campaigns(status);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_billing_invoice ON public.ad_campaigns(billing_invoice_id);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_billing_company ON public.ad_campaigns(billing_company_id);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_site_id ON public.ad_campaigns(site_id);
ALTER TABLE public.ad_campaigns ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_ad_campaigns" ON public.ad_campaigns;
CREATE POLICY "admin_manage_ad_campaigns"
ON public.ad_campaigns FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 9. Seed default companies and settings ──────────────────────────────────
DO $$
DECLARE
rmp_id UUID := gen_random_uuid();
jtr_id UUID := gen_random_uuid();
BEGIN
INSERT INTO public.billing_companies (id, name, legal_name, llc_entity, address, email, invoice_prefix, default_payment_terms, is_active)
VALUES
(rmp_id, 'Relevant Media Properties', 'Relevant Media Properties, LLC', 'RMP',
'1309 Coffeen Ave, Suite 1200, Sheridan, WY 82801',
'ryan.salazar@relevantmediaproperties.com', 'RMP-', 'Net 30', true),
(jtr_id, 'Just Two Roommates', 'Just Two Roommates, LLC', 'JTR',
'1309 Coffeen Ave, Suite 1200, Sheridan, WY 82801',
'ryan.salazar@relevantmediaproperties.com', 'JTR-', 'Net 30', true)
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.billing_settings (billing_mode, sync_frequency_minutes, default_payment_terms, default_currency)
VALUES ('mooninvoice', 15, 'Net 30', 'USD')
ON CONFLICT (id) DO NOTHING;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Seed data insertion failed: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,403 @@
-- ============================================================
-- Migration: Ad Ops & Email Marketing System
-- Timestamp: 20260322270000
-- Extends: ad_campaigns (from 20260322260000)
-- Adds: ad_placements, documents, email_lists, email_subscribers,
-- email_campaigns, email_events, email_suppressions,
-- adops_inbox_log, adops_pending_invoices
-- ============================================================
-- ─── 1. Extend ad_campaigns with ad ops fields ───────────────────────────────
ALTER TABLE public.ad_campaigns
ADD COLUMN IF NOT EXISTS campaign_name TEXT,
ADD COLUMN IF NOT EXISTS publication_id TEXT,
ADD COLUMN IF NOT EXISTS placement_zone TEXT,
ADD COLUMN IF NOT EXISTS banner_url TEXT,
ADD COLUMN IF NOT EXISTS destination_url TEXT,
ADD COLUMN IF NOT EXISTS agreed_rate NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS impressions BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS clicks BIGINT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ctr NUMERIC(6,4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS created_by TEXT,
ADD COLUMN IF NOT EXISTS insertion_order_file TEXT,
ADD COLUMN IF NOT EXISTS invoice_id TEXT,
ADD COLUMN IF NOT EXISTS blackmagic_excluded BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS mooninvoice_invoice_id TEXT,
ADD COLUMN IF NOT EXISTS mooninvoice_contact_id TEXT,
ADD COLUMN IF NOT EXISTS approval_screenshot_url TEXT,
ADD COLUMN IF NOT EXISTS live_screenshot_url TEXT,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS approved_by TEXT,
ADD COLUMN IF NOT EXISTS deployed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS deactivated_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS io_waived BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS pending_info_fields JSONB;
-- Update status check to include ad ops statuses
ALTER TABLE public.ad_campaigns
DROP CONSTRAINT IF EXISTS ad_campaigns_status_check;
ALTER TABLE public.ad_campaigns
ADD CONSTRAINT ad_campaigns_status_check
CHECK (status IN (
'draft','pending_info','pending_approval','approved',
'active','paused','ended','cancelled','completed'
));
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_publication ON public.ad_campaigns(publication_id);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_start_date ON public.ad_campaigns(start_date);
CREATE INDEX IF NOT EXISTS idx_ad_campaigns_end_date ON public.ad_campaigns(end_date);
-- ─── 2. ad_placements ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.ad_placements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES public.ad_campaigns(id) ON DELETE CASCADE,
publication_id TEXT NOT NULL,
zone_id TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT false,
deployed_at TIMESTAMPTZ,
deactivated_at TIMESTAMPTZ,
screenshot_url TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ad_placements_campaign_id ON public.ad_placements(campaign_id);
CREATE INDEX IF NOT EXISTS idx_ad_placements_active ON public.ad_placements(is_active);
CREATE INDEX IF NOT EXISTS idx_ad_placements_publication ON public.ad_placements(publication_id);
ALTER TABLE public.ad_placements ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_ad_placements" ON public.ad_placements;
CREATE POLICY "admin_manage_ad_placements"
ON public.ad_placements FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 3. documents ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.adops_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES public.ad_campaigns(id) ON DELETE SET NULL,
email_campaign_id UUID,
document_type TEXT NOT NULL DEFAULT 'io' CHECK (document_type IN ('io','invoice','contract','other')),
file_url TEXT NOT NULL,
file_name TEXT,
uploaded_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
uploaded_by TEXT,
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_adops_documents_campaign_id ON public.adops_documents(campaign_id);
ALTER TABLE public.adops_documents ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_adops_documents" ON public.adops_documents;
CREATE POLICY "admin_manage_adops_documents"
ON public.adops_documents FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 4. email_lists ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.email_lists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_name TEXT NOT NULL,
publication_id TEXT NOT NULL,
subscriber_count INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
last_updated TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_email_lists_publication ON public.email_lists(publication_id);
ALTER TABLE public.email_lists ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_email_lists" ON public.email_lists;
CREATE POLICY "admin_manage_email_lists"
ON public.email_lists FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 5. email_subscribers ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.email_subscribers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
list_id UUID REFERENCES public.email_lists(id) ON DELETE CASCADE,
email TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
subscribed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','unsubscribed','bounced','complained')),
source TEXT,
last_opened TIMESTAMPTZ,
last_clicked TIMESTAMPTZ,
soft_bounce_count INTEGER NOT NULL DEFAULT 0,
UNIQUE(list_id, email)
);
CREATE INDEX IF NOT EXISTS idx_email_subscribers_list_id ON public.email_subscribers(list_id);
CREATE INDEX IF NOT EXISTS idx_email_subscribers_email ON public.email_subscribers(email);
CREATE INDEX IF NOT EXISTS idx_email_subscribers_status ON public.email_subscribers(status);
ALTER TABLE public.email_subscribers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_email_subscribers" ON public.email_subscribers;
CREATE POLICY "admin_manage_email_subscribers"
ON public.email_subscribers FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 6. email_campaigns (distributions) ─────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.adops_email_campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_name TEXT NOT NULL,
client_name TEXT,
publication_id TEXT NOT NULL,
list_id UUID REFERENCES public.email_lists(id) ON DELETE SET NULL,
subject TEXT NOT NULL,
from_name TEXT NOT NULL,
from_email TEXT NOT NULL,
reply_to TEXT,
html_content TEXT,
send_date DATE,
send_time_et TEXT DEFAULT '11:00',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN (
'draft','pending_info','pending_test','test_sent','pending_approval',
'approved','scheduled','sending','sent','cancelled','held'
)),
approved_by TEXT,
approved_at TIMESTAMPTZ,
sent_count INTEGER NOT NULL DEFAULT 0,
test_sent_at TIMESTAMPTZ,
insertion_order_file TEXT,
invoice_id TEXT,
agreed_rate NUMERIC(12,2),
mooninvoice_invoice_id TEXT,
is_newsletter BOOLEAN NOT NULL DEFAULT false,
newsletter_issue_number INTEGER,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_adops_email_campaigns_publication ON public.adops_email_campaigns(publication_id);
CREATE INDEX IF NOT EXISTS idx_adops_email_campaigns_status ON public.adops_email_campaigns(status);
CREATE INDEX IF NOT EXISTS idx_adops_email_campaigns_send_date ON public.adops_email_campaigns(send_date);
ALTER TABLE public.adops_email_campaigns ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_adops_email_campaigns" ON public.adops_email_campaigns;
CREATE POLICY "admin_manage_adops_email_campaigns"
ON public.adops_email_campaigns FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 7. email_events ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.email_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES public.adops_email_campaigns(id) ON DELETE CASCADE,
subscriber_id UUID REFERENCES public.email_subscribers(id) ON DELETE SET NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('delivered','open','click','bounce','unsubscribe','complaint','failed')),
event_timestamp TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
link_url TEXT,
ip_hash TEXT,
user_agent TEXT,
bounce_type TEXT
);
CREATE INDEX IF NOT EXISTS idx_email_events_campaign_id ON public.email_events(campaign_id);
CREATE INDEX IF NOT EXISTS idx_email_events_subscriber_id ON public.email_events(subscriber_id);
CREATE INDEX IF NOT EXISTS idx_email_events_type ON public.email_events(event_type);
CREATE INDEX IF NOT EXISTS idx_email_events_timestamp ON public.email_events(event_timestamp);
ALTER TABLE public.email_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_email_events" ON public.email_events;
CREATE POLICY "admin_manage_email_events"
ON public.email_events FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 8. email_suppressions ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.email_suppressions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('unsubscribe','hard_bounce','complaint','manual')),
suppressed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
publication_id TEXT,
UNIQUE(email, publication_id)
);
CREATE INDEX IF NOT EXISTS idx_email_suppressions_email ON public.email_suppressions(email);
CREATE INDEX IF NOT EXISTS idx_email_suppressions_publication ON public.email_suppressions(publication_id);
ALTER TABLE public.email_suppressions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_email_suppressions" ON public.email_suppressions;
CREATE POLICY "admin_manage_email_suppressions"
ON public.email_suppressions FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 9. adops_inbox_log ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.adops_inbox_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id TEXT UNIQUE,
from_email TEXT NOT NULL,
subject TEXT,
received_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
request_type TEXT CHECK (request_type IN ('banner','distribution','newsletter','inquiry','unknown','ignored')),
parsed_data JSONB,
action_taken TEXT,
campaign_id UUID REFERENCES public.ad_campaigns(id) ON DELETE SET NULL,
email_campaign_id UUID REFERENCES public.adops_email_campaigns(id) ON DELETE SET NULL,
thread_id TEXT,
is_whitelisted BOOLEAN NOT NULL DEFAULT false,
processing_status TEXT NOT NULL DEFAULT 'received' CHECK (processing_status IN (
'received','processing','replied','completed','error','ignored'
)),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_adops_inbox_log_from_email ON public.adops_inbox_log(from_email);
CREATE INDEX IF NOT EXISTS idx_adops_inbox_log_received_at ON public.adops_inbox_log(received_at);
CREATE INDEX IF NOT EXISTS idx_adops_inbox_log_status ON public.adops_inbox_log(processing_status);
ALTER TABLE public.adops_inbox_log ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_adops_inbox_log" ON public.adops_inbox_log;
CREATE POLICY "admin_manage_adops_inbox_log"
ON public.adops_inbox_log FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 10. adops_pending_invoices ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.adops_pending_invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES public.ad_campaigns(id) ON DELETE CASCADE,
client_name TEXT NOT NULL,
client_email TEXT,
mooninvoice_contact_id TEXT,
invoice_data JSONB NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
last_retry_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','retrying','created','failed')),
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_adops_pending_invoices_status ON public.adops_pending_invoices(status);
CREATE INDEX IF NOT EXISTS idx_adops_pending_invoices_next_retry ON public.adops_pending_invoices(next_retry_at);
ALTER TABLE public.adops_pending_invoices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "admin_manage_adops_pending_invoices" ON public.adops_pending_invoices;
CREATE POLICY "admin_manage_adops_pending_invoices"
ON public.adops_pending_invoices FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
);
-- ─── 11. Seed default email lists ────────────────────────────────────────────
DO $$
BEGIN
INSERT INTO public.email_lists (list_name, publication_id, is_active)
VALUES
('Broadcast Beat Full List', 'broadcast-beat', true),
('AV Beat Full List', 'av-beat', true),
('Backlot Beat Full List', 'backlot-beat', true)
ON CONFLICT DO NOTHING;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE 'Email list seed failed: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,360 @@
-- ============================================================
-- BROADCAST BEAT MARKETPLACE — PHASE 1 MVP MIGRATION
-- Additive only: all tables prefixed marketplace_
-- ============================================================
-- ── TYPES ────────────────────────────────────────────────────
DROP TYPE IF EXISTS public.marketplace_profile_type CASCADE;
CREATE TYPE public.marketplace_profile_type AS ENUM ('crew', 'vendor');
DROP TYPE IF EXISTS public.marketplace_profile_status CASCADE;
CREATE TYPE public.marketplace_profile_status AS ENUM ('pending', 'active', 'rejected');
DROP TYPE IF EXISTS public.marketplace_subscription_tier CASCADE;
CREATE TYPE public.marketplace_subscription_tier AS ENUM ('free', 'pro', 'featured');
DROP TYPE IF EXISTS public.marketplace_job_type CASCADE;
CREATE TYPE public.marketplace_job_type AS ENUM ('full_time', 'part_time', 'contract', 'temp');
DROP TYPE IF EXISTS public.marketplace_listing_status CASCADE;
CREATE TYPE public.marketplace_listing_status AS ENUM ('active', 'expired', 'draft');
-- ── CORE TABLES ───────────────────────────────────────────────
-- Marketplace profiles (one per user)
CREATE TABLE IF NOT EXISTS public.marketplace_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
profile_type public.marketplace_profile_type NOT NULL,
slug TEXT NOT NULL UNIQUE,
status public.marketplace_profile_status NOT NULL DEFAULT 'pending',
subscription_tier public.marketplace_subscription_tier NOT NULL DEFAULT 'free',
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
rejection_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Crew member details
CREATE TABLE IF NOT EXISTS public.marketplace_crew_details (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.marketplace_profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL,
headshot_url TEXT,
location TEXT NOT NULL,
union_status TEXT DEFAULT 'non_union',
day_rate INTEGER,
bio TEXT,
skills TEXT[] DEFAULT ARRAY[]::TEXT[],
equipment TEXT[] DEFAULT ARRAY[]::TEXT[],
reel_url TEXT,
resume_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Vendor / company details
CREATE TABLE IF NOT EXISTS public.marketplace_vendor_details (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.marketplace_profiles(id) ON DELETE CASCADE,
company_name TEXT NOT NULL,
logo_url TEXT,
location TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
website TEXT,
contact_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Job board
CREATE TABLE IF NOT EXISTS public.marketplace_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.marketplace_profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
company TEXT NOT NULL,
location TEXT NOT NULL,
job_type public.marketplace_job_type NOT NULL DEFAULT 'full_time',
salary_min INTEGER,
salary_max INTEGER,
description TEXT NOT NULL,
apply_url TEXT,
apply_email TEXT,
expires_at TIMESTAMPTZ,
status public.marketplace_listing_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Gig board
CREATE TABLE IF NOT EXISTS public.marketplace_gigs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES public.marketplace_profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL,
company TEXT NOT NULL,
location TEXT NOT NULL,
gig_type TEXT NOT NULL,
rate TEXT,
start_date DATE,
end_date DATE,
description TEXT NOT NULL,
contact_method TEXT,
expires_at TIMESTAMPTZ,
status public.marketplace_listing_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── INDEXES ───────────────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_marketplace_profiles_user_id ON public.marketplace_profiles(user_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_profiles_slug ON public.marketplace_profiles(slug);
CREATE INDEX IF NOT EXISTS idx_marketplace_profiles_status ON public.marketplace_profiles(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_crew_profile_id ON public.marketplace_crew_details(profile_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_vendor_profile_id ON public.marketplace_vendor_details(profile_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_jobs_profile_id ON public.marketplace_jobs(profile_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_jobs_status ON public.marketplace_jobs(status);
CREATE INDEX IF NOT EXISTS idx_marketplace_gigs_profile_id ON public.marketplace_gigs(profile_id);
CREATE INDEX IF NOT EXISTS idx_marketplace_gigs_status ON public.marketplace_gigs(status);
-- ── FUNCTIONS ─────────────────────────────────────────────────
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION public.marketplace_set_updated_at()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
-- Check if user is admin (uses auth metadata to avoid recursion)
CREATE OR REPLACE FUNCTION public.marketplace_is_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM auth.users
WHERE id = auth.uid()
AND (
raw_user_meta_data->>'role' = 'admin'
OR raw_app_meta_data->>'role' = 'admin'
)
)
$$;
-- Check if user has active pro/featured subscription
CREATE OR REPLACE FUNCTION public.marketplace_has_active_subscription()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.marketplace_profiles
WHERE user_id = auth.uid()
AND subscription_tier IN ('pro', 'featured')
AND status = 'active'
)
$$;
-- ── ENABLE RLS ────────────────────────────────────────────────
ALTER TABLE public.marketplace_profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.marketplace_crew_details ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.marketplace_vendor_details ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.marketplace_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.marketplace_gigs ENABLE ROW LEVEL SECURITY;
-- ── RLS POLICIES ─────────────────────────────────────────────
-- marketplace_profiles
DROP POLICY IF EXISTS "mp_public_read_active" ON public.marketplace_profiles;
CREATE POLICY "mp_public_read_active" ON public.marketplace_profiles
FOR SELECT TO public
USING (status = 'active');
DROP POLICY IF EXISTS "mp_owner_read_own" ON public.marketplace_profiles;
CREATE POLICY "mp_owner_read_own" ON public.marketplace_profiles
FOR SELECT TO authenticated
USING (user_id = auth.uid());
DROP POLICY IF EXISTS "mp_owner_insert" ON public.marketplace_profiles;
CREATE POLICY "mp_owner_insert" ON public.marketplace_profiles
FOR INSERT TO authenticated
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS "mp_owner_update" ON public.marketplace_profiles;
CREATE POLICY "mp_owner_update" ON public.marketplace_profiles
FOR UPDATE TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
DROP POLICY IF EXISTS "mp_admin_all" ON public.marketplace_profiles;
CREATE POLICY "mp_admin_all" ON public.marketplace_profiles
FOR ALL TO authenticated
USING (public.marketplace_is_admin())
WITH CHECK (public.marketplace_is_admin());
-- marketplace_crew_details
DROP POLICY IF EXISTS "mcd_public_read" ON public.marketplace_crew_details;
CREATE POLICY "mcd_public_read" ON public.marketplace_crew_details
FOR SELECT TO public
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.status = 'active'
));
DROP POLICY IF EXISTS "mcd_owner_all" ON public.marketplace_crew_details;
CREATE POLICY "mcd_owner_all" ON public.marketplace_crew_details
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
));
DROP POLICY IF EXISTS "mcd_admin_all" ON public.marketplace_crew_details;
CREATE POLICY "mcd_admin_all" ON public.marketplace_crew_details
FOR ALL TO authenticated
USING (public.marketplace_is_admin())
WITH CHECK (public.marketplace_is_admin());
-- marketplace_vendor_details
DROP POLICY IF EXISTS "mvd_public_read" ON public.marketplace_vendor_details;
CREATE POLICY "mvd_public_read" ON public.marketplace_vendor_details
FOR SELECT TO public
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.status = 'active'
));
DROP POLICY IF EXISTS "mvd_owner_all" ON public.marketplace_vendor_details;
CREATE POLICY "mvd_owner_all" ON public.marketplace_vendor_details
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
));
DROP POLICY IF EXISTS "mvd_admin_all" ON public.marketplace_vendor_details;
CREATE POLICY "mvd_admin_all" ON public.marketplace_vendor_details
FOR ALL TO authenticated
USING (public.marketplace_is_admin())
WITH CHECK (public.marketplace_is_admin());
-- marketplace_jobs
DROP POLICY IF EXISTS "mj_public_read_active" ON public.marketplace_jobs;
CREATE POLICY "mj_public_read_active" ON public.marketplace_jobs
FOR SELECT TO public
USING (status = 'active');
DROP POLICY IF EXISTS "mj_owner_all" ON public.marketplace_jobs;
CREATE POLICY "mj_owner_all" ON public.marketplace_jobs
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
));
DROP POLICY IF EXISTS "mj_admin_all" ON public.marketplace_jobs;
CREATE POLICY "mj_admin_all" ON public.marketplace_jobs
FOR ALL TO authenticated
USING (public.marketplace_is_admin())
WITH CHECK (public.marketplace_is_admin());
-- marketplace_gigs
DROP POLICY IF EXISTS "mg_public_read_active" ON public.marketplace_gigs;
CREATE POLICY "mg_public_read_active" ON public.marketplace_gigs
FOR SELECT TO public
USING (status = 'active');
DROP POLICY IF EXISTS "mg_owner_all" ON public.marketplace_gigs;
CREATE POLICY "mg_owner_all" ON public.marketplace_gigs
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM public.marketplace_profiles mp
WHERE mp.id = profile_id AND mp.user_id = auth.uid()
));
DROP POLICY IF EXISTS "mg_admin_all" ON public.marketplace_gigs;
CREATE POLICY "mg_admin_all" ON public.marketplace_gigs
FOR ALL TO authenticated
USING (public.marketplace_is_admin())
WITH CHECK (public.marketplace_is_admin());
-- ── TRIGGERS ─────────────────────────────────────────────────
DROP TRIGGER IF EXISTS marketplace_profiles_updated_at ON public.marketplace_profiles;
CREATE TRIGGER marketplace_profiles_updated_at
BEFORE UPDATE ON public.marketplace_profiles
FOR EACH ROW EXECUTE FUNCTION public.marketplace_set_updated_at();
DROP TRIGGER IF EXISTS marketplace_crew_updated_at ON public.marketplace_crew_details;
CREATE TRIGGER marketplace_crew_updated_at
BEFORE UPDATE ON public.marketplace_crew_details
FOR EACH ROW EXECUTE FUNCTION public.marketplace_set_updated_at();
DROP TRIGGER IF EXISTS marketplace_vendor_updated_at ON public.marketplace_vendor_details;
CREATE TRIGGER marketplace_vendor_updated_at
BEFORE UPDATE ON public.marketplace_vendor_details
FOR EACH ROW EXECUTE FUNCTION public.marketplace_set_updated_at();
-- ── STORAGE BUCKETS (via SQL) ─────────────────────────────────
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES
('marketplace-headshots', 'marketplace-headshots', true, 5242880,
ARRAY['image/jpeg','image/jpg','image/png','image/webp']::TEXT[]),
('marketplace-logos', 'marketplace-logos', true, 5242880,
ARRAY['image/jpeg','image/jpg','image/png','image/webp','image/svg+xml']::TEXT[]),
('marketplace-resumes', 'marketplace-resumes', false, 10485760,
ARRAY['application/pdf','application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document']::TEXT[])
ON CONFLICT (id) DO NOTHING;
-- Storage RLS
DROP POLICY IF EXISTS "marketplace_headshots_public_read" ON storage.objects;
CREATE POLICY "marketplace_headshots_public_read" ON storage.objects
FOR SELECT TO public
USING (bucket_id = 'marketplace-headshots');
DROP POLICY IF EXISTS "marketplace_headshots_auth_write" ON storage.objects;
CREATE POLICY "marketplace_headshots_auth_write" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'marketplace-headshots');
DROP POLICY IF EXISTS "marketplace_logos_public_read" ON storage.objects;
CREATE POLICY "marketplace_logos_public_read" ON storage.objects
FOR SELECT TO public
USING (bucket_id = 'marketplace-logos');
DROP POLICY IF EXISTS "marketplace_logos_auth_write" ON storage.objects;
CREATE POLICY "marketplace_logos_auth_write" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'marketplace-logos');
DROP POLICY IF EXISTS "marketplace_resumes_auth_read" ON storage.objects;
CREATE POLICY "marketplace_resumes_auth_read" ON storage.objects
FOR SELECT TO authenticated
USING (bucket_id = 'marketplace-resumes' AND auth.uid()::TEXT = (storage.foldername(name))[1]);
DROP POLICY IF EXISTS "marketplace_resumes_auth_write" ON storage.objects;
CREATE POLICY "marketplace_resumes_auth_write" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'marketplace-resumes');

View File

@@ -0,0 +1,570 @@
-- ============================================================
-- Migration: RMP Master System — Relevant Media Properties LLC
-- Timestamp: 20260401120000
-- Covers: Sales, CRM, AdOps, Accounting, Email Distribution
-- ADDITIVE ONLY — no existing tables modified
-- ============================================================
-- ─── SALES STAFF ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_sales_staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
full_name text NOT NULL,
email text,
phone text,
status text DEFAULT 'active',
terminated_date date,
notes text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_sales_staff_status ON public.rmp_sales_staff(status);
-- ─── COMMISSION TIERS ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_commission_tiers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
staff_id uuid REFERENCES public.rmp_sales_staff(id) ON DELETE CASCADE,
effective_year integer,
tier_order integer,
threshold_cents integer,
rate numeric,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_commission_tiers_staff ON public.rmp_commission_tiers(staff_id);
CREATE INDEX IF NOT EXISTS idx_rmp_commission_tiers_year ON public.rmp_commission_tiers(effective_year);
-- ─── CLIENTS ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_clients (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_name text NOT NULL,
contact_name text,
contact_email text,
contact_phone text,
billing_address text,
notes text,
status text DEFAULT 'active',
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_clients_status ON public.rmp_clients(status);
CREATE INDEX IF NOT EXISTS idx_rmp_clients_company ON public.rmp_clients(company_name);
-- ─── PRODUCT / SERVICE CATALOG ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site text NOT NULL,
product_type text NOT NULL,
name text NOT NULL,
description text,
dimensions text,
standard_price_cents integer,
duration_days integer,
active boolean DEFAULT true,
sort_order integer DEFAULT 0,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_products_site ON public.rmp_products(site);
CREATE INDEX IF NOT EXISTS idx_rmp_products_active ON public.rmp_products(active);
-- ─── ORDERS ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
client_id uuid REFERENCES public.rmp_clients(id),
staff_id uuid REFERENCES public.rmp_sales_staff(id),
po_number text,
internal_order_number text,
site text NOT NULL,
product_id uuid REFERENCES public.rmp_products(id),
ad_unit text,
description text,
start_date date,
end_date date,
total_amount_cents integer,
currency text DEFAULT 'USD',
status text DEFAULT 'active',
invoicing_schedule text DEFAULT 'single',
next_invoice_date date,
notes text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_orders_client ON public.rmp_orders(client_id);
CREATE INDEX IF NOT EXISTS idx_rmp_orders_staff ON public.rmp_orders(staff_id);
CREATE INDEX IF NOT EXISTS idx_rmp_orders_site ON public.rmp_orders(site);
CREATE INDEX IF NOT EXISTS idx_rmp_orders_status ON public.rmp_orders(status);
CREATE INDEX IF NOT EXISTS idx_rmp_orders_next_invoice ON public.rmp_orders(next_invoice_date);
-- ─── INVOICES ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_invoices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES public.rmp_orders(id),
client_id uuid REFERENCES public.rmp_clients(id),
staff_id uuid REFERENCES public.rmp_sales_staff(id),
invoice_number text,
site text,
amount_cents integer,
currency text DEFAULT 'USD',
status text DEFAULT 'pending',
payment_method text,
stripe_payment_id text,
wire_reference text,
notes text,
issue_date date,
due_date date,
paid_date date,
is_staff_sale boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_invoices_order ON public.rmp_invoices(order_id);
CREATE INDEX IF NOT EXISTS idx_rmp_invoices_client ON public.rmp_invoices(client_id);
CREATE INDEX IF NOT EXISTS idx_rmp_invoices_status ON public.rmp_invoices(status);
CREATE INDEX IF NOT EXISTS idx_rmp_invoices_due_date ON public.rmp_invoices(due_date);
CREATE INDEX IF NOT EXISTS idx_rmp_invoices_site ON public.rmp_invoices(site);
-- ─── EXPENSES ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_expenses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vendor text,
amount_cents integer,
currency text DEFAULT 'USD',
category text,
description text,
receipt_url text,
receipt_source text,
ai_parsed boolean DEFAULT false,
reconciled boolean DEFAULT false,
bank_transaction_id text,
expense_date date,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_expenses_date ON public.rmp_expenses(expense_date);
CREATE INDEX IF NOT EXISTS idx_rmp_expenses_category ON public.rmp_expenses(category);
CREATE INDEX IF NOT EXISTS idx_rmp_expenses_reconciled ON public.rmp_expenses(reconciled);
-- ─── BANK TRANSACTIONS ────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_bank_transactions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_date date,
description text,
amount_cents integer,
type text,
matched_invoice_id uuid,
matched_expense_id uuid,
reconciled boolean DEFAULT false,
raw_csv_row jsonb,
imported_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_bank_tx_date ON public.rmp_bank_transactions(transaction_date);
CREATE INDEX IF NOT EXISTS idx_rmp_bank_tx_reconciled ON public.rmp_bank_transactions(reconciled);
-- ─── COMMISSIONS ──────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_commissions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id uuid REFERENCES public.rmp_invoices(id),
staff_id uuid REFERENCES public.rmp_sales_staff(id),
year integer,
sale_amount_cents integer,
commission_rate numeric,
commission_amount_cents integer,
ytd_sales_at_time_cents integer,
paid boolean DEFAULT false,
paid_date date,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_commissions_staff ON public.rmp_commissions(staff_id);
CREATE INDEX IF NOT EXISTS idx_rmp_commissions_year ON public.rmp_commissions(year);
CREATE INDEX IF NOT EXISTS idx_rmp_commissions_paid ON public.rmp_commissions(paid);
-- ─── DOCUMENT VAULT ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_type text,
title text,
year integer,
person text,
file_url text,
notes text,
uploaded_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_documents_type ON public.rmp_documents(document_type);
CREATE INDEX IF NOT EXISTS idx_rmp_documents_year ON public.rmp_documents(year);
-- ─── IO EXTENSIONS ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_io_extensions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
existing_io_id text NOT NULL,
order_id uuid REFERENCES public.rmp_orders(id),
product_id uuid REFERENCES public.rmp_products(id),
staff_id uuid REFERENCES public.rmp_sales_staff(id),
client_id uuid REFERENCES public.rmp_clients(id),
is_comp boolean DEFAULT false,
comp_reason text,
is_paid boolean DEFAULT false,
flight_status text DEFAULT 'scheduled',
start_date date NOT NULL,
end_date date,
auto_deactivate boolean DEFAULT true,
renewal_notice_sent boolean DEFAULT false,
renewal_notice_sent_at timestamptz,
reactivated_at timestamptz,
reactivation_reason text,
banner_image_url text,
click_through_url text,
alt_text text,
email_html_url text,
email_scheduled_date date,
email_sent_at timestamptz,
email_list_segment text,
page_url text,
newsletter_issue_date date,
notes text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_io_ext_flight_status ON public.rmp_io_extensions(flight_status);
CREATE INDEX IF NOT EXISTS idx_rmp_io_ext_end_date ON public.rmp_io_extensions(end_date);
CREATE INDEX IF NOT EXISTS idx_rmp_io_ext_client ON public.rmp_io_extensions(client_id);
CREATE INDEX IF NOT EXISTS idx_rmp_io_ext_staff ON public.rmp_io_extensions(staff_id);
-- ─── EMAIL SENDING DOMAINS ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_email_sending_domains (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site text NOT NULL,
domain text NOT NULL,
domain_type text DEFAULT 'subdomain',
is_primary boolean DEFAULT false,
is_active boolean DEFAULT false,
sort_order integer DEFAULT 0,
daily_send_limit integer DEFAULT 5000,
sends_today integer DEFAULT 0,
sends_reset_at date,
spf_valid boolean DEFAULT false,
spf_checked_at timestamptz,
dkim_valid boolean DEFAULT false,
dkim_checked_at timestamptz,
dmarc_valid boolean DEFAULT false,
dmarc_checked_at timestamptz,
ptr_valid boolean DEFAULT false,
ptr_checked_at timestamptz,
mx_valid boolean DEFAULT false,
mx_checked_at timestamptz,
dns_fully_verified boolean DEFAULT false,
blacklisted boolean DEFAULT false,
blacklist_details text,
blacklist_checked_at timestamptz,
health_score integer DEFAULT 0,
bypass_verification boolean DEFAULT false,
bypass_reason text,
bypass_set_by text,
bypass_set_at timestamptz,
last_used_at timestamptz,
notes text,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_email_domains_site ON public.rmp_email_sending_domains(site);
CREATE INDEX IF NOT EXISTS idx_rmp_email_domains_active ON public.rmp_email_sending_domains(is_active);
-- ─── EMAIL SUPPRESSIONS ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_email_suppressions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site text NOT NULL,
email text NOT NULL,
suppression_type text NOT NULL,
source text,
reply_text text,
sending_domain text,
suppressed_at timestamptz DEFAULT now(),
suppressed_by text,
notes text,
UNIQUE(site, email)
);
CREATE INDEX IF NOT EXISTS idx_rmp_suppressions_site ON public.rmp_email_suppressions(site);
CREATE INDEX IF NOT EXISTS idx_rmp_suppressions_email ON public.rmp_email_suppressions(email);
CREATE INDEX IF NOT EXISTS idx_rmp_suppressions_type ON public.rmp_email_suppressions(suppression_type);
-- ─── SUPPRESSION KEYWORDS ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_suppression_keywords (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
keyword text NOT NULL,
keyword_type text DEFAULT 'unsubscribe',
active boolean DEFAULT true,
created_at timestamptz DEFAULT now()
);
-- ─── EMAIL DNS CHECK LOG ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_email_dns_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
domain_id uuid REFERENCES public.rmp_email_sending_domains(id),
check_type text,
passed boolean,
result_detail text,
checked_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_dns_log_domain ON public.rmp_email_dns_log(domain_id);
-- ─── EMAIL SEND LOG ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_email_send_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
site text NOT NULL,
sending_domain_id uuid REFERENCES public.rmp_email_sending_domains(id),
io_extension_id uuid REFERENCES public.rmp_io_extensions(id),
recipient_email text,
subject text,
send_type text,
status text DEFAULT 'queued',
suppression_reason text,
sent_at timestamptz,
opens integer DEFAULT 0,
clicks integer DEFAULT 0,
bounced boolean DEFAULT false,
bounce_type text,
spam_complaint boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rmp_send_log_site ON public.rmp_email_send_log(site);
CREATE INDEX IF NOT EXISTS idx_rmp_send_log_status ON public.rmp_email_send_log(status);
CREATE INDEX IF NOT EXISTS idx_rmp_send_log_domain ON public.rmp_email_send_log(sending_domain_id);
-- ─── ADOPS NOTIFICATIONS ──────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_adops_notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
io_extension_id uuid REFERENCES public.rmp_io_extensions(id),
notification_type text,
recipient_type text,
recipient_email text,
recipient_phone text,
message text,
sent_via text,
sent_at timestamptz DEFAULT now(),
success boolean DEFAULT true,
error_message text
);
CREATE INDEX IF NOT EXISTS idx_rmp_adops_notif_io ON public.rmp_adops_notifications(io_extension_id);
-- ─── PAYMENT NOTIFICATIONS ────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.rmp_notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type text,
message text,
sent_at timestamptz DEFAULT now(),
invoice_id uuid
);
-- ─── RLS: ADMIN-ONLY ACCESS FOR ALL RMP TABLES ────────────────────────────────
CREATE OR REPLACE FUNCTION public.rmp_is_admin()
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin')
)
$$;
CREATE OR REPLACE FUNCTION public.rmp_is_adops_uploader()
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
SELECT EXISTS (
SELECT 1 FROM public.user_profiles up
WHERE up.id = auth.uid() AND up.role IN ('administrator', 'admin', 'adops_uploader')
)
$$;
ALTER TABLE public.rmp_sales_staff ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_commission_tiers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_clients ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_expenses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_bank_transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_commissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_io_extensions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_email_sending_domains ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_email_suppressions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_suppression_keywords ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_email_dns_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_email_send_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_adops_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.rmp_notifications ENABLE ROW LEVEL SECURITY;
-- Admin full access policies
DROP POLICY IF EXISTS "rmp_admin_sales_staff" ON public.rmp_sales_staff;
CREATE POLICY "rmp_admin_sales_staff" ON public.rmp_sales_staff FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_commission_tiers" ON public.rmp_commission_tiers;
CREATE POLICY "rmp_admin_commission_tiers" ON public.rmp_commission_tiers FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_clients" ON public.rmp_clients;
CREATE POLICY "rmp_admin_clients" ON public.rmp_clients FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_products" ON public.rmp_products;
CREATE POLICY "rmp_admin_products" ON public.rmp_products FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_orders" ON public.rmp_orders;
CREATE POLICY "rmp_admin_orders" ON public.rmp_orders FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_invoices" ON public.rmp_invoices;
CREATE POLICY "rmp_admin_invoices" ON public.rmp_invoices FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_expenses" ON public.rmp_expenses;
CREATE POLICY "rmp_admin_expenses" ON public.rmp_expenses FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_bank_tx" ON public.rmp_bank_transactions;
CREATE POLICY "rmp_admin_bank_tx" ON public.rmp_bank_transactions FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_commissions" ON public.rmp_commissions;
CREATE POLICY "rmp_admin_commissions" ON public.rmp_commissions FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_documents" ON public.rmp_documents;
CREATE POLICY "rmp_admin_documents" ON public.rmp_documents FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_adops_io_extensions" ON public.rmp_io_extensions;
CREATE POLICY "rmp_adops_io_extensions" ON public.rmp_io_extensions FOR ALL TO authenticated
USING (public.rmp_is_adops_uploader()) WITH CHECK (public.rmp_is_adops_uploader());
DROP POLICY IF EXISTS "rmp_admin_email_domains" ON public.rmp_email_sending_domains;
CREATE POLICY "rmp_admin_email_domains" ON public.rmp_email_sending_domains FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_suppressions" ON public.rmp_email_suppressions;
CREATE POLICY "rmp_admin_suppressions" ON public.rmp_email_suppressions FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_keywords" ON public.rmp_suppression_keywords;
CREATE POLICY "rmp_admin_keywords" ON public.rmp_suppression_keywords FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_dns_log" ON public.rmp_email_dns_log;
CREATE POLICY "rmp_admin_dns_log" ON public.rmp_email_dns_log FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_send_log" ON public.rmp_email_send_log;
CREATE POLICY "rmp_admin_send_log" ON public.rmp_email_send_log FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_adops_notif" ON public.rmp_adops_notifications;
CREATE POLICY "rmp_admin_adops_notif" ON public.rmp_adops_notifications FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
DROP POLICY IF EXISTS "rmp_admin_notifications" ON public.rmp_notifications;
CREATE POLICY "rmp_admin_notifications" ON public.rmp_notifications FOR ALL TO authenticated
USING (public.rmp_is_admin()) WITH CHECK (public.rmp_is_admin());
-- ─── SEED DATA ────────────────────────────────────────────────────────────────
-- Jeff Victor sales staff + commission tiers
DO $$
DECLARE
jeff_id uuid;
BEGIN
INSERT INTO public.rmp_sales_staff (full_name, email, status)
VALUES ('Jeff Victor', 'jeff.victor@relevantmediaproperties.com', 'active')
ON CONFLICT DO NOTHING
RETURNING id INTO jeff_id;
IF jeff_id IS NOT NULL THEN
INSERT INTO public.rmp_commission_tiers (staff_id, effective_year, tier_order, threshold_cents, rate)
VALUES
(jeff_id, 2026, 1, 10000000, 0.25),
(jeff_id, 2026, 2, NULL, 0.30)
ON CONFLICT DO NOTHING;
END IF;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Seed staff error: %', SQLERRM;
END $$;
-- Suppression keywords
INSERT INTO public.rmp_suppression_keywords (keyword, keyword_type) VALUES
('unsubscribe','unsubscribe'),('remove','unsubscribe'),('remove me','unsubscribe'),
('opt out','unsubscribe'),('opt-out','unsubscribe'),('stop','unsubscribe'),
('cancel','unsubscribe'),('do not email','unsubscribe'),('dont email','unsubscribe'),
('don''t email','unsubscribe'),('never email','unsubscribe'),
('take me off','unsubscribe'),('take me off your list','unsubscribe'),
('leave me alone','unsubscribe')
ON CONFLICT DO NOTHING;
-- Email sending domains
INSERT INTO public.rmp_email_sending_domains (site, domain, domain_type, is_primary, sort_order) VALUES
('broadcastbeat', 'news.broadcastbeat.com', 'subdomain', true, 1),
('broadcastbeat', 'mail.broadcastbeat.com', 'subdomain', false, 2),
('broadcastbeat', 'broadcastbeatnews.com', 'sister_domain',false, 3),
('avbeat', 'news.avbeat.com', 'subdomain', true, 1),
('avbeat', 'mail.avbeat.com', 'subdomain', false, 2),
('backlotbeat', 'news.backlotbeat.com', 'subdomain', true, 1),
('backlotbeat', 'mail.backlotbeat.com', 'subdomain', false, 2),
('broadcastengineering', 'news.broadcastengineering.com', 'subdomain', true, 1),
('broadcastengineering', 'mail.broadcastengineering.com', 'subdomain', false, 2)
ON CONFLICT DO NOTHING;
-- Product catalog seed for all 4 RMP sites
DO $$
DECLARE
sites text[] := ARRAY['broadcastbeat','avbeat','backlotbeat','broadcastengineering'];
s text;
all_types text[] := ARRAY[
'display_banner_300x250','display_banner_728x90','display_banner_300x600',
'display_banner_970x250','newsletter_banner_600x100','dedicated_email_blast',
'newsletter_sponsorship','featured_advertorial','featured_newsletter_story'
];
limited_types text[] := ARRAY[
'display_banner_300x250','display_banner_728x90','display_banner_300x600',
'display_banner_970x250','newsletter_banner_600x100','dedicated_email_blast',
'featured_advertorial'
];
pt text;
dims text;
use_types text[];
BEGIN
FOREACH s IN ARRAY sites LOOP
IF s IN ('backlotbeat','broadcastengineering') THEN
use_types := limited_types;
ELSE
use_types := all_types;
END IF;
FOREACH pt IN ARRAY use_types LOOP
dims := CASE pt
WHEN 'display_banner_300x250' THEN '300x250'
WHEN 'display_banner_728x90' THEN '728x90'
WHEN 'display_banner_300x600' THEN '300x600'
WHEN 'display_banner_970x250' THEN '970x250'
WHEN 'newsletter_banner_600x100' THEN '600x100'
ELSE NULL
END;
INSERT INTO public.rmp_products (site, product_type, name, dimensions, active)
VALUES (s, pt, initcap(replace(pt,'_',' ')), dims, true)
ON CONFLICT DO NOTHING;
END LOOP;
END LOOP;
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Product seed error: %', SQLERRM;
END $$;

View File

@@ -0,0 +1,72 @@
-- Migration: user_topic_preferences and reading_history
-- Timestamp: 20260402050000
-- ============================================================
-- 1. TABLES
-- ============================================================
-- User Topic Preferences: tracks topics a user is interested in, updated per article view
CREATE TABLE IF NOT EXISTS public.user_topic_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
view_count INTEGER DEFAULT 1,
last_viewed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- Reading History: tracks articles a user has viewed
CREATE TABLE IF NOT EXISTS public.reading_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
article_slug TEXT NOT NULL,
article_title TEXT NOT NULL,
article_excerpt TEXT,
article_image TEXT,
article_image_alt TEXT,
article_category TEXT,
article_author TEXT,
article_read_time TEXT,
article_date TEXT,
viewed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 2. INDEXES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_user_topic_preferences_user_id ON public.user_topic_preferences(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_topic_preferences_user_topic ON public.user_topic_preferences(user_id, topic);
CREATE INDEX IF NOT EXISTS idx_reading_history_user_id ON public.reading_history(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_history_article_slug ON public.reading_history(article_slug);
CREATE UNIQUE INDEX IF NOT EXISTS idx_reading_history_user_article ON public.reading_history(user_id, article_slug);
-- ============================================================
-- 3. ENABLE RLS
-- ============================================================
ALTER TABLE public.user_topic_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.reading_history ENABLE ROW LEVEL SECURITY;
-- ============================================================
-- 4. RLS POLICIES
-- ============================================================
-- User Topic Preferences: users manage their own preferences
DROP POLICY IF EXISTS "users_manage_own_topic_preferences" ON public.user_topic_preferences;
CREATE POLICY "users_manage_own_topic_preferences"
ON public.user_topic_preferences
FOR ALL
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
-- Reading History: users manage their own reading history
DROP POLICY IF EXISTS "users_manage_own_reading_history" ON public.reading_history;
CREATE POLICY "users_manage_own_reading_history"
ON public.reading_history
FOR ALL
TO authenticated
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());