initial commit: rocket.new export of broadcastbeat
This commit is contained in:
@@ -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);
|
||||
@@ -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();
|
||||
95
supabase/migrations/20260320230000_wp_imported_posts.sql
Normal file
95
supabase/migrations/20260320230000_wp_imported_posts.sql
Normal 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();
|
||||
50
supabase/migrations/20260321065000_post_images_storage.sql
Normal file
50
supabase/migrations/20260321065000_post_images_storage.sql
Normal 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');
|
||||
53
supabase/migrations/20260321070000_native_articles.sql
Normal file
53
supabase/migrations/20260321070000_native_articles.sql
Normal 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();
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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();
|
||||
97
supabase/migrations/20260321100000_audit_logs.sql
Normal file
97
supabase/migrations/20260321100000_audit_logs.sql
Normal 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 $$;
|
||||
@@ -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);
|
||||
91
supabase/migrations/20260321130000_newsletter_templates.sql
Normal file
91
supabase/migrations/20260321130000_newsletter_templates.sql
Normal 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
|
||||
);
|
||||
454
supabase/migrations/20260321170000_community_forum.sql
Normal file
454
supabase/migrations/20260321170000_community_forum.sql
Normal 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 $$;
|
||||
185
supabase/migrations/20260321190000_forum_votes_reputation.sql
Normal file
185
supabase/migrations/20260321190000_forum_votes_reputation.sql
Normal 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();
|
||||
144
supabase/migrations/20260321200000_notifications.sql
Normal file
144
supabase/migrations/20260321200000_notifications.sql
Normal 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();
|
||||
@@ -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;
|
||||
51
supabase/migrations/20260321220000_forum_moderation.sql
Normal file
51
supabase/migrations/20260321220000_forum_moderation.sql
Normal 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);
|
||||
156
supabase/migrations/20260322120000_user_badges.sql
Normal file
156
supabase/migrations/20260322120000_user_badges.sql
Normal 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 $$;
|
||||
40
supabase/migrations/20260322160000_user_follows.sql
Normal file
40
supabase/migrations/20260322160000_user_follows.sql
Normal 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());
|
||||
82
supabase/migrations/20260322190000_role_permissions.sql
Normal file
82
supabase/migrations/20260322190000_role_permissions.sql
Normal 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;
|
||||
129
supabase/migrations/20260322200000_user_suspensions.sql
Normal file
129
supabase/migrations/20260322200000_user_suspensions.sql
Normal 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());
|
||||
269
supabase/migrations/20260322210000_wp_user_migration.sql
Normal file
269
supabase/migrations/20260322210000_wp_user_migration.sql
Normal 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 $$;
|
||||
81
supabase/migrations/20260322220000_company_coverage.sql
Normal file
81
supabase/migrations/20260322220000_company_coverage.sql
Normal 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();
|
||||
403
supabase/migrations/20260322230000_multisite_system.sql
Normal file
403
supabase/migrations/20260322230000_multisite_system.sql
Normal 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());
|
||||
599
supabase/migrations/20260322240000_sections_15_to_19.sql
Normal file
599
supabase/migrations/20260322240000_sections_15_to_19.sql
Normal 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;
|
||||
164
supabase/migrations/20260322250000_ai_console_hybrid_router.sql
Normal file
164
supabase/migrations/20260322250000_ai_console_hybrid_router.sql
Normal 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 $$;
|
||||
343
supabase/migrations/20260322260000_unified_billing_system.sql
Normal file
343
supabase/migrations/20260322260000_unified_billing_system.sql
Normal 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 $$;
|
||||
403
supabase/migrations/20260322270000_adops_system.sql
Normal file
403
supabase/migrations/20260322270000_adops_system.sql
Normal 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 $$;
|
||||
360
supabase/migrations/20260401000000_marketplace_phase1.sql
Normal file
360
supabase/migrations/20260401000000_marketplace_phase1.sql
Normal 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');
|
||||
570
supabase/migrations/20260401120000_rmp_master_system.sql
Normal file
570
supabase/migrations/20260401120000_rmp_master_system.sql
Normal 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 $$;
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user