-- ============================================================================ -- SOJORN DATABASE SETUP -- Complete, idempotent schema for Sojorn social platform -- ============================================================================ -- Extensions DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "pg_trgm"; EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE EXTENSION IF NOT EXISTS "postgis"; EXCEPTION WHEN duplicate_object THEN null; END $$; -- Types DO $$ BEGIN CREATE TYPE beacon_type AS ENUM ('police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE trust_tier AS ENUM ('new', 'trusted', 'established'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE notification_type AS ENUM ('appreciate', 'chain', 'follow', 'comment', 'mention', 'follow_request', 'new_follower', 'request_accepted'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE tone_label AS ENUM ('positive', 'neutral', 'mixed', 'negative', 'hostile'); EXCEPTION WHEN duplicate_object THEN null; END $$; DO $$ BEGIN CREATE TYPE post_status AS ENUM ('active', 'flagged', 'removed'); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tables CREATE TABLE IF NOT EXISTS profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, handle TEXT UNIQUE NOT NULL CHECK (handle ~ '^[a-z0-9_]{3,20}$'), display_name TEXT NOT NULL CHECK (length(trim(display_name)) >= 1 AND length(display_name) <= 50), bio TEXT CHECK (length(bio) <= 300), avatar_url TEXT, cover_url TEXT, is_official BOOLEAN NOT NULL DEFAULT FALSE, beacon_enabled BOOLEAN NOT NULL DEFAULT FALSE, location TEXT, website TEXT, interests TEXT[], created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS trust_state ( user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, harmony_score INTEGER NOT NULL DEFAULT 50 CHECK (harmony_score >= 0 AND harmony_score <= 100), tier trust_tier NOT NULL DEFAULT 'new', posts_today INTEGER NOT NULL DEFAULT 0 CHECK (posts_today >= 0), last_post_at TIMESTAMPTZ, last_harmony_calc_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS categories ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), slug TEXT UNIQUE NOT NULL CHECK (slug ~ '^[a-z0-9_]{2,30}$'), name TEXT NOT NULL CHECK (length(trim(name)) >= 1 AND length(name) <= 60), description TEXT CHECK (length(description) <= 200), is_sensitive BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, body TEXT NOT NULL CHECK (length(trim(body)) >= 1 AND length(body) <= 500), status post_status NOT NULL DEFAULT 'active', tone_label tone_label, cis_score NUMERIC(3,2) CHECK (cis_score >= 0 AND cis_score <= 1), image_url TEXT, body_format TEXT DEFAULT 'plain' CHECK (body_format IN ('plain', 'markdown')), background_id TEXT CHECK (background_id IN ('white', 'grey', 'blue', 'green', 'yellow', 'orange', 'red', 'purple', 'pink')), tags TEXT[], is_beacon BOOLEAN NOT NULL DEFAULT FALSE, beacon_type beacon_type, location geography(POINT), confidence_score NUMERIC(3,2) CHECK (confidence_score >= 0 AND confidence_score <= 1), is_active_beacon BOOLEAN DEFAULT TRUE, allow_chain BOOLEAN NOT NULL DEFAULT TRUE, chain_parent_id UUID REFERENCES posts(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), edited_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS post_metrics ( post_id UUID PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE, like_count INTEGER NOT NULL DEFAULT 0 CHECK (like_count >= 0), save_count INTEGER NOT NULL DEFAULT 0 CHECK (save_count >= 0), view_count INTEGER NOT NULL DEFAULT 0 CHECK (view_count >= 0), comment_count INTEGER NOT NULL DEFAULT 0 CHECK (comment_count >= 0), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS post_likes ( user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, post_id) ); CREATE TABLE IF NOT EXISTS post_saves ( user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, post_id) ); CREATE TABLE IF NOT EXISTS comments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, author_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, body TEXT NOT NULL CHECK (length(trim(body)) >= 1 AND length(body) <= 300), status post_status NOT NULL DEFAULT 'active', tone_label tone_label, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS beacon_votes ( beacon_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, vote_type TEXT NOT NULL CHECK (vote_type IN ('vouch', 'report')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (beacon_id, user_id) ); CREATE TABLE IF NOT EXISTS notifications ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, type notification_type NOT NULL, actor_id UUID REFERENCES profiles(id) ON DELETE SET NULL, post_id UUID REFERENCES posts(id) ON DELETE SET NULL, comment_id UUID REFERENCES comments(id) ON DELETE SET NULL, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, is_read BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS follows ( follower_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, following_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (follower_id, following_id), CHECK (follower_id != following_id) ); CREATE TABLE IF NOT EXISTS blocks ( blocker_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, blocked_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (blocker_id, blocked_id), CHECK (blocker_id != blocked_id) ); CREATE TABLE IF NOT EXISTS reports ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), reporter_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, target_type TEXT NOT NULL CHECK (target_type IN ('post', 'comment', 'profile')), target_id UUID NOT NULL, reason TEXT NOT NULL CHECK (length(trim(reason)) >= 10 AND length(reason) <= 500), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewing', 'resolved', 'dismissed')), reviewed_by UUID REFERENCES profiles(id), reviewed_at TIMESTAMPTZ, resolution_note TEXT CHECK (length(resolution_note) <= 1000), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (reporter_id, target_type, target_id) ); -- Indexes CREATE INDEX IF NOT EXISTS idx_profiles_handle ON profiles(handle); CREATE INDEX IF NOT EXISTS idx_profiles_handle_trgm ON profiles USING GIN (handle gin_trgm_ops); CREATE INDEX IF NOT EXISTS idx_profiles_beacon_enabled ON profiles(beacon_enabled) WHERE beacon_enabled = TRUE; CREATE INDEX IF NOT EXISTS idx_posts_tags ON posts USING GIN (tags); CREATE INDEX IF NOT EXISTS idx_posts_beacon_active ON posts(is_beacon, is_active_beacon) WHERE is_beacon = TRUE AND is_active_beacon = TRUE; CREATE INDEX IF NOT EXISTS idx_posts_location ON posts USING GIST (location); -- Functions CREATE OR REPLACE FUNCTION has_block_between(user_a UUID, user_b UUID) RETURNS BOOLEAN LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ BEGIN IF user_a IS NULL OR user_b IS NULL THEN RETURN FALSE; END IF; RETURN EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = user_a AND blocked_id = user_b) OR (blocker_id = user_b AND blocked_id = user_a)); END; $$; CREATE OR REPLACE FUNCTION is_mutual_follow(user_a UUID, user_b UUID) RETURNS BOOLEAN LANGUAGE plpgsql STABLE SECURITY DEFINER AS $$ BEGIN IF user_a IS NULL OR user_b IS NULL THEN RETURN FALSE; END IF; RETURN EXISTS (SELECT 1 FROM follows WHERE follower_id = user_a AND following_id = user_b) AND EXISTS (SELECT 1 FROM follows WHERE follower_id = user_b AND following_id = user_a); END; $$; CREATE OR REPLACE FUNCTION get_beacon_status_color(score NUMERIC) RETURNS TEXT LANGUAGE plpgsql STABLE AS $$ BEGIN IF score > 0.7 THEN RETURN 'green'; ELSIF score >= 0.3 THEN RETURN 'yellow'; ELSE RETURN 'red'; END IF; END; $$; CREATE OR REPLACE FUNCTION search_sojorn(p_query TEXT, limit_count INTEGER DEFAULT 10) RETURNS JSON LANGUAGE plpgsql STABLE AS $$ DECLARE result JSON; BEGIN SELECT json_build_object( 'users', (SELECT json_agg(json_build_object('id', p.id, 'username', p.handle, 'display_name', p.display_name, 'avatar_url', p.avatar_url, 'harmony_tier', COALESCE(ts.tier, 'new'))) FROM profiles p LEFT JOIN trust_state ts ON p.id = ts.user_id WHERE p.handle ILIKE '%' || p_query || '%' OR p.display_name ILIKE '%' || p_query || '%' LIMIT limit_count), 'tags', (SELECT json_agg(json_build_object('tag', tag, 'count', cnt)) FROM ( SELECT LOWER(UNNEST(tags)) AS tag, COUNT(*) AS cnt FROM posts WHERE tags IS NOT NULL AND deleted_at IS NULL GROUP BY tag HAVING LOWER(tag) LIKE '%' || LOWER(p_query) || '%' ORDER BY cnt DESC LIMIT limit_count) t), 'posts', (SELECT json_agg(json_build_object('id', post.id, 'body', post.body, 'author_id', post.author_id, 'author_handle', post.handle, 'author_display_name', post.display_name, 'created_at', post.created_at)) FROM ( SELECT po.id, po.body, po.author_id, pr.handle, pr.display_name, po.created_at FROM posts po LEFT JOIN profiles pr ON po.author_id = pr.id WHERE po.deleted_at IS NULL AND po.status = 'active' AND ( po.body ILIKE '%' || p_query || '%' OR EXISTS (SELECT 1 FROM UNNEST(po.tags) AS tag WHERE LOWER(tag) = LOWER(p_query)) ) ORDER BY po.created_at DESC LIMIT limit_count ) post) ) INTO result; RETURN result; END; $$; CREATE OR REPLACE FUNCTION fetch_beacons(lat DOUBLE PRECISION, long DOUBLE PRECISION, radius_meters DOUBLE PRECISION DEFAULT 5000, beacon_type_filter TEXT DEFAULT NULL, limit_count INTEGER DEFAULT 50) RETURNS TABLE ( id UUID, body TEXT, author_id UUID, beacon_type TEXT, confidence_score NUMERIC, is_active_beacon BOOLEAN, created_at TIMESTAMPTZ, distance_meters DOUBLE PRECISION, author_handle TEXT, author_display_name TEXT, author_avatar_url TEXT, vouch_count INTEGER, report_count INTEGER, status_color TEXT ) LANGUAGE plpgsql STABLE AS $$ BEGIN RETURN QUERY SELECT p.id, p.body, p.author_id, p.beacon_type::TEXT, p.confidence_score, p.is_active_beacon, p.created_at, ST_Distance(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography) AS distance_meters, p.handle AS author_handle, p.display_name AS author_display_name, p.avatar_url AS author_avatar_url, COALESCE(vouch.cnt, 0)::INT, COALESCE(report.cnt, 0)::INT, get_beacon_status_color(p.confidence_score) FROM posts p LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'vouch' GROUP BY beacon_id) vouch ON p.id = vouch.beacon_id LEFT JOIN (SELECT beacon_id, COUNT(*)::INT AS cnt FROM beacon_votes WHERE vote_type = 'report' GROUP BY beacon_id) report ON p.id = report.beacon_id WHERE p.is_beacon = TRUE AND p.is_active_beacon = TRUE AND p.deleted_at IS NULL AND (beacon_type_filter IS NULL OR p.beacon_type::TEXT = beacon_type_filter) AND ST_DWithin(p.location, ST_SetSRID(ST_MakePoint(long, lat), 4326)::geography, radius_meters) ORDER BY p.confidence_score DESC, p.created_at DESC LIMIT limit_count; END; $$; CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN INSERT INTO public.profiles (id, handle, display_name) VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'handle', NEW.email), COALESCE(NEW.raw_user_meta_data->>'display_name', NEW.email)); INSERT INTO public.trust_state (user_id, harmony_score, tier, posts_today) VALUES (NEW.id, 50, 'new', 0); RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION init_post_metrics() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN INSERT INTO post_metrics (post_id) VALUES (NEW.id); RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION update_post_like_count() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET like_count = like_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET like_count = like_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; RETURN NULL; END; $$; CREATE OR REPLACE FUNCTION update_post_save_count() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET save_count = save_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET save_count = save_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; RETURN NULL; END; $$; CREATE OR REPLACE FUNCTION update_post_comment_count() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'INSERT' THEN UPDATE post_metrics SET comment_count = comment_count + 1, updated_at = NOW() WHERE post_id = NEW.post_id; ELSIF TG_OP = 'DELETE' THEN UPDATE post_metrics SET comment_count = comment_count - 1, updated_at = NOW() WHERE post_id = OLD.post_id; END IF; RETURN NULL; END; $$; CREATE OR REPLACE FUNCTION update_beacon_score_on_vouch() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE voucher_trust INTEGER; BEGIN IF NEW.vote_type = 'vouch' THEN SELECT COALESCE(ts.harmony_score, 50) INTO voucher_trust FROM trust_state ts WHERE ts.user_id = NEW.user_id; UPDATE posts SET confidence_score = LEAST(1.0, confidence_score + (voucher_trust::NUMERIC / 1000)) WHERE id = NEW.beacon_id; END IF; RETURN NEW; END; $$; CREATE OR REPLACE FUNCTION prune_inactive_beacons() RETURNS INTEGER LANGUAGE plpgsql AS $$ DECLARE disabled_count INTEGER; BEGIN UPDATE posts SET is_active_beacon = FALSE WHERE is_beacon = TRUE AND is_active_beacon = TRUE AND confidence_score < 0.3 AND created_at < NOW() - INTERVAL '10 minutes' AND deleted_at IS NULL; GET DIAGNOSTICS disabled_count = ROW_COUNT; RETURN disabled_count; END; $$; -- Triggers CREATE TRIGGER handle_new_user AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); CREATE TRIGGER init_metrics_on_post AFTER INSERT ON posts FOR EACH ROW EXECUTE FUNCTION init_post_metrics(); CREATE TRIGGER update_like_count AFTER INSERT OR DELETE ON post_likes FOR EACH ROW EXECUTE FUNCTION update_post_like_count(); CREATE TRIGGER update_save_count AFTER INSERT OR DELETE ON post_saves FOR EACH ROW EXECUTE FUNCTION update_post_save_count(); CREATE TRIGGER update_comment_count AFTER INSERT OR DELETE ON comments FOR EACH ROW EXECUTE FUNCTION update_post_comment_count(); CREATE TRIGGER update_beacon_score AFTER INSERT ON beacon_votes FOR EACH ROW EXECUTE FUNCTION update_beacon_score_on_vouch(); -- RLS ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE trust_state ENABLE ROW LEVEL SECURITY; ALTER TABLE categories ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; ALTER TABLE post_metrics ENABLE ROW LEVEL SECURITY; ALTER TABLE post_likes ENABLE ROW LEVEL SECURITY; ALTER TABLE post_saves ENABLE ROW LEVEL SECURITY; ALTER TABLE comments ENABLE ROW LEVEL SECURITY; ALTER TABLE beacon_votes ENABLE ROW LEVEL SECURITY; ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; ALTER TABLE follows ENABLE ROW LEVEL SECURITY; ALTER TABLE blocks ENABLE ROW LEVEL SECURITY; ALTER TABLE reports ENABLE ROW LEVEL SECURITY; CREATE POLICY "Public profiles" ON profiles FOR SELECT USING (true); CREATE POLICY "Own profile" ON profiles FOR UPDATE USING (auth.uid() = id); CREATE POLICY "Own trust state" ON trust_state FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Categories are public" ON categories FOR SELECT USING (true); CREATE POLICY "Anyone can insert categories" ON categories FOR INSERT WITH CHECK (true); CREATE POLICY "Public posts" ON posts FOR SELECT USING (deleted_at IS NULL AND status = 'active'); CREATE POLICY "Create posts" ON posts FOR INSERT WITH CHECK (auth.uid() = author_id); CREATE POLICY "Own posts" ON posts FOR UPDATE USING (auth.uid() = author_id AND deleted_at IS NULL); CREATE POLICY "Metrics" ON post_metrics FOR SELECT USING (true); CREATE POLICY "Likes" ON post_likes FOR ALL USING (auth.uid() = user_id); CREATE POLICY "Saves" ON post_saves FOR ALL USING (auth.uid() = user_id); CREATE POLICY "Comments" ON comments FOR SELECT USING (deleted_at IS NULL AND status = 'active'); CREATE POLICY "Create comments" ON comments FOR INSERT WITH CHECK (auth.uid() = author_id); CREATE POLICY "Own comments" ON comments FOR UPDATE USING (auth.uid() = author_id AND deleted_at IS NULL); CREATE POLICY "Beacon votes" ON beacon_votes FOR ALL USING (auth.uid() = user_id); CREATE POLICY "Notifications" ON notifications FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Follows" ON follows FOR SELECT USING (true); CREATE POLICY "Manage follows" ON follows FOR ALL USING (auth.uid() = follower_id); CREATE POLICY "Blocks" ON blocks FOR SELECT USING (auth.uid() = blocker_id); CREATE POLICY "Manage blocks" ON blocks FOR ALL USING (auth.uid() = blocker_id); CREATE POLICY "Reports" ON reports FOR SELECT USING (auth.uid() = reporter_id); -- Seed Data INSERT INTO categories (slug, name, description, is_sensitive) VALUES ('general', 'General', 'General discussion', false), ('news', 'News', 'News and current events', false), ('help', 'Help', 'Ask for help', false), ('events', 'Events', 'Community events', false), ('beacon-alerts', 'Beacon Alerts', 'Community safety alerts', false) ON CONFLICT (slug) DO NOTHING;