Files
avbeat-com/supabase/migrations/20260322230000_multisite_system.sql
2026-05-07 16:39:17 +00:00

404 lines
19 KiB
PL/PgSQL

-- ============================================================
-- 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());