mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
- Couple streaks with milestone notifications - Scheduled pack releases with admin UI - Notification preferences screen - Refactored profile settings screens - Partner activity notifications
972 lines
33 KiB
PL/PgSQL
972 lines
33 KiB
PL/PgSQL
-- Sauci v1.0 Initial Schema
|
|
-- Consolidated migration for production release
|
|
|
|
-- ============================================================================
|
|
-- EXTENSIONS
|
|
-- ============================================================================
|
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
|
-- ============================================================================
|
|
-- CUSTOM TYPES
|
|
-- ============================================================================
|
|
CREATE TYPE answer_type AS ENUM ('yes', 'no', 'maybe');
|
|
CREATE TYPE match_type AS ENUM ('yes_yes', 'yes_maybe', 'maybe_maybe');
|
|
CREATE TYPE feedback_type AS ENUM ('bug', 'feature_request', 'general', 'question');
|
|
CREATE TYPE feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed');
|
|
CREATE TYPE subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused');
|
|
CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin');
|
|
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
|
|
-- ============================================================================
|
|
-- TABLES
|
|
-- ============================================================================
|
|
|
|
-- Couples table
|
|
CREATE TABLE couples (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
-- Profiles table (extends auth.users)
|
|
CREATE TABLE profiles (
|
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
name TEXT,
|
|
avatar_url TEXT,
|
|
push_token TEXT,
|
|
is_premium BOOLEAN DEFAULT false,
|
|
couple_id UUID REFERENCES couples(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
|
email TEXT,
|
|
gender TEXT,
|
|
show_explicit_content BOOLEAN DEFAULT true,
|
|
onboarding_completed BOOLEAN DEFAULT false,
|
|
usage_reason TEXT,
|
|
CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say']))
|
|
);
|
|
-- Categories table
|
|
CREATE TABLE categories (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
icon TEXT,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
-- Question packs table
|
|
CREATE TABLE question_packs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
icon TEXT,
|
|
is_premium BOOLEAN DEFAULT false,
|
|
is_public BOOLEAN DEFAULT true,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
category_id UUID REFERENCES categories(id),
|
|
is_explicit BOOLEAN NOT NULL DEFAULT false
|
|
);
|
|
-- Questions table
|
|
CREATE TABLE questions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pack_id UUID NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
|
|
text TEXT NOT NULL,
|
|
intensity INTEGER DEFAULT 1 CHECK (intensity >= 1 AND intensity <= 5),
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
partner_text TEXT,
|
|
allowed_couple_genders TEXT[],
|
|
target_user_genders TEXT[]
|
|
);
|
|
COMMENT ON COLUMN questions.allowed_couple_genders IS 'Array of allowed couple gender pairings (e.g., ["female+male", "female+female"]). Null means all couple types allowed.';
|
|
COMMENT ON COLUMN questions.target_user_genders IS 'Array of genders that can see this question first (as initiator). NULL = all genders. Partners still see it via partner_text after their partner answers.';
|
|
-- Responses table
|
|
CREATE TABLE responses (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
|
answer answer_type NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(user_id, question_id)
|
|
);
|
|
-- Matches table
|
|
CREATE TABLE matches (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
|
question_id UUID NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
|
match_type match_type NOT NULL,
|
|
is_new BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(couple_id, question_id)
|
|
);
|
|
-- Messages table
|
|
CREATE TABLE messages (
|
|
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
|
|
match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
content TEXT,
|
|
media_path TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
read_at TIMESTAMPTZ,
|
|
media_viewed_at TIMESTAMPTZ,
|
|
delivered_at TIMESTAMPTZ,
|
|
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
|
|
);
|
|
-- Couple packs table (which packs a couple has enabled)
|
|
CREATE TABLE couple_packs (
|
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
|
pack_id UUID NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
|
|
enabled BOOLEAN DEFAULT true,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
PRIMARY KEY (couple_id, pack_id)
|
|
);
|
|
-- Feedback table
|
|
CREATE TABLE feedback (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
type feedback_type NOT NULL,
|
|
title TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
screenshot_url TEXT,
|
|
device_info JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
status feedback_status NOT NULL DEFAULT 'new',
|
|
admin_notes TEXT,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
|
question_id UUID REFERENCES questions(id) ON DELETE SET NULL
|
|
);
|
|
-- Subscriptions table
|
|
CREATE TABLE subscriptions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
|
revenuecat_app_user_id TEXT NOT NULL,
|
|
product_id TEXT NOT NULL,
|
|
status subscription_status NOT NULL DEFAULT 'active',
|
|
entitlement_ids TEXT[] DEFAULT '{}',
|
|
purchased_at TIMESTAMPTZ NOT NULL,
|
|
expires_at TIMESTAMPTZ,
|
|
original_transaction_id TEXT,
|
|
store TEXT DEFAULT 'APP_STORE',
|
|
is_sandbox BOOLEAN DEFAULT false,
|
|
cancel_reason TEXT,
|
|
grace_period_expires_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
|
UNIQUE(user_id, original_transaction_id)
|
|
);
|
|
-- RevenueCat webhook events table (for idempotency)
|
|
CREATE TABLE revenuecat_webhook_events (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
event_id TEXT NOT NULL UNIQUE,
|
|
event_type TEXT NOT NULL,
|
|
app_user_id TEXT NOT NULL,
|
|
processed_at TIMESTAMPTZ DEFAULT now(),
|
|
payload JSONB
|
|
);
|
|
-- Admin users table
|
|
CREATE TABLE admin_users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
role admin_role NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
-- Audit logs table
|
|
CREATE TABLE audit_logs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
table_name TEXT NOT NULL,
|
|
record_id UUID NOT NULL,
|
|
action audit_action NOT NULL,
|
|
old_values JSONB,
|
|
new_values JSONB,
|
|
changed_fields TEXT[],
|
|
admin_user_id UUID NOT NULL REFERENCES auth.users(id),
|
|
admin_role admin_role NOT NULL,
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
);
|
|
-- ============================================================================
|
|
-- INDEXES
|
|
-- ============================================================================
|
|
CREATE INDEX idx_profiles_couple_id ON profiles(couple_id);
|
|
CREATE INDEX idx_questions_pack_id ON questions(pack_id);
|
|
CREATE INDEX idx_question_packs_category_id ON question_packs(category_id);
|
|
CREATE INDEX idx_responses_couple_question ON responses(couple_id, question_id);
|
|
CREATE INDEX idx_matches_couple_id ON matches(couple_id);
|
|
CREATE INDEX idx_matches_is_new ON matches(is_new) WHERE is_new = true;
|
|
CREATE INDEX idx_messages_delivered_at ON messages(delivered_at) WHERE delivered_at IS NULL;
|
|
CREATE INDEX idx_feedback_user_id ON feedback(user_id);
|
|
CREATE INDEX idx_feedback_type ON feedback(type);
|
|
CREATE INDEX idx_feedback_status ON feedback(status);
|
|
CREATE INDEX idx_feedback_created_at ON feedback(created_at DESC);
|
|
CREATE INDEX idx_feedback_question_id ON feedback(question_id) WHERE question_id IS NOT NULL;
|
|
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
|
|
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
|
CREATE INDEX idx_subscriptions_expires_at ON subscriptions(expires_at);
|
|
CREATE INDEX idx_webhook_events_event_id ON revenuecat_webhook_events(event_id);
|
|
CREATE INDEX idx_admin_users_user_id ON admin_users(user_id);
|
|
CREATE INDEX idx_admin_users_role ON admin_users(role);
|
|
CREATE INDEX idx_audit_logs_table_name ON audit_logs(table_name);
|
|
CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
|
|
CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id);
|
|
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
|
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
|
-- ============================================================================
|
|
-- FUNCTIONS
|
|
-- ============================================================================
|
|
|
|
-- Get authenticated user's couple_id (cached for RLS performance)
|
|
CREATE OR REPLACE FUNCTION get_auth_user_couple_id()
|
|
RETURNS UUID
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
|
|
$$;
|
|
-- Check if user is any admin
|
|
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
|
|
RETURNS BOOLEAN
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
RETURN EXISTS (
|
|
SELECT 1 FROM public.admin_users WHERE user_id = check_user_id
|
|
);
|
|
END;
|
|
$$;
|
|
-- Check if user is super admin
|
|
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
|
|
RETURNS BOOLEAN
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
RETURN EXISTS (
|
|
SELECT 1 FROM public.admin_users
|
|
WHERE user_id = check_user_id AND role = 'super_admin'
|
|
);
|
|
END;
|
|
$$;
|
|
-- Get admin role for user
|
|
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
|
|
RETURNS admin_role
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
DECLARE
|
|
user_role public.admin_role;
|
|
BEGIN
|
|
SELECT role INTO user_role FROM public.admin_users WHERE user_id = check_user_id;
|
|
RETURN user_role;
|
|
END;
|
|
$$;
|
|
-- Check if user or partner has premium access
|
|
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
|
|
RETURNS BOOLEAN
|
|
LANGUAGE plpgsql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
DECLARE
|
|
user_premium BOOLEAN;
|
|
partner_premium BOOLEAN;
|
|
user_couple_id UUID;
|
|
BEGIN
|
|
SELECT is_premium, couple_id INTO user_premium, user_couple_id
|
|
FROM public.profiles
|
|
WHERE id = check_user_id;
|
|
|
|
IF user_premium = TRUE THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
IF user_couple_id IS NULL THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
SELECT is_premium INTO partner_premium
|
|
FROM public.profiles
|
|
WHERE couple_id = user_couple_id
|
|
AND id != check_user_id
|
|
LIMIT 1;
|
|
|
|
RETURN COALESCE(partner_premium, FALSE);
|
|
END;
|
|
$$;
|
|
-- Get recommended questions for a user
|
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
|
RETURNS TABLE(
|
|
id UUID,
|
|
text TEXT,
|
|
partner_text TEXT,
|
|
is_two_part BOOLEAN,
|
|
pack_id UUID,
|
|
intensity INTEGER,
|
|
partner_answered BOOLEAN,
|
|
allowed_couple_genders TEXT[],
|
|
target_user_genders TEXT[]
|
|
)
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_user_id UUID;
|
|
v_couple_id UUID;
|
|
v_user_gender TEXT;
|
|
v_partner_gender TEXT;
|
|
v_couple_type TEXT;
|
|
v_has_premium BOOLEAN;
|
|
BEGIN
|
|
v_user_id := auth.uid();
|
|
|
|
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
|
|
FROM public.profiles p
|
|
WHERE p.id = v_user_id;
|
|
|
|
IF v_couple_id IS NULL THEN
|
|
RETURN;
|
|
END IF;
|
|
|
|
SELECT p.gender INTO v_partner_gender
|
|
FROM public.profiles p
|
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
|
LIMIT 1;
|
|
|
|
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
|
IF v_user_gender < v_partner_gender THEN
|
|
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
|
ELSE
|
|
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
|
END IF;
|
|
END IF;
|
|
|
|
v_has_premium := public.has_premium_access(v_user_id);
|
|
|
|
RETURN QUERY
|
|
WITH user_config_exists AS (
|
|
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
|
),
|
|
active_packs AS (
|
|
SELECT target_pack_id AS pack_id
|
|
WHERE target_pack_id IS NOT NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.question_packs qp
|
|
WHERE qp.id = target_pack_id
|
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
|
)
|
|
UNION ALL
|
|
SELECT cp.pack_id FROM public.couple_packs cp
|
|
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
|
WHERE target_pack_id IS NULL
|
|
AND cp.couple_id = v_couple_id
|
|
AND cp.enabled = TRUE
|
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
|
UNION ALL
|
|
SELECT p.id AS pack_id FROM public.question_packs p
|
|
WHERE target_pack_id IS NULL
|
|
AND p.is_public = TRUE
|
|
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
|
)
|
|
SELECT
|
|
q.id,
|
|
CASE
|
|
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
|
SELECT 1 FROM public.responses r
|
|
WHERE r.question_id = q.id
|
|
AND r.couple_id = v_couple_id
|
|
AND r.user_id != v_user_id
|
|
) THEN q.partner_text
|
|
ELSE q.text
|
|
END as text,
|
|
q.partner_text,
|
|
q.partner_text IS NOT NULL as is_two_part,
|
|
q.pack_id,
|
|
q.intensity,
|
|
EXISTS (
|
|
SELECT 1 FROM public.responses r
|
|
WHERE r.question_id = q.id
|
|
AND r.couple_id = v_couple_id
|
|
AND r.user_id != v_user_id
|
|
) AS partner_answered,
|
|
q.allowed_couple_genders,
|
|
q.target_user_genders
|
|
FROM public.questions q
|
|
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM public.responses r
|
|
WHERE r.question_id = q.id
|
|
AND r.user_id = v_user_id
|
|
)
|
|
AND (
|
|
q.allowed_couple_genders IS NULL
|
|
OR v_couple_type IS NULL
|
|
OR v_couple_type = ANY(q.allowed_couple_genders)
|
|
)
|
|
AND (
|
|
q.target_user_genders IS NULL
|
|
OR v_user_gender = ANY(q.target_user_genders)
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.responses r
|
|
WHERE r.question_id = q.id
|
|
AND r.couple_id = v_couple_id
|
|
AND r.user_id != v_user_id
|
|
)
|
|
);
|
|
END;
|
|
$$;
|
|
-- Get pack teaser questions (bypasses premium for preview)
|
|
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
|
|
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
q.id,
|
|
q.text,
|
|
q.intensity
|
|
FROM public.questions q
|
|
INNER JOIN public.question_packs qp ON qp.id = q.pack_id
|
|
WHERE q.pack_id = target_pack_id
|
|
AND qp.is_public = TRUE
|
|
ORDER BY q.intensity ASC, random()
|
|
LIMIT 3;
|
|
END;
|
|
$$;
|
|
-- Handle new user signup (create profile)
|
|
CREATE OR REPLACE FUNCTION handle_new_user()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
INSERT INTO public.profiles (id, name, avatar_url, email)
|
|
VALUES (
|
|
NEW.id,
|
|
COALESCE(NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'full_name'),
|
|
NEW.raw_user_meta_data->>'avatar_url',
|
|
NEW.email
|
|
);
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- Update updated_at timestamp
|
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- Check couple size (max 2 members)
|
|
CREATE OR REPLACE FUNCTION check_couple_size()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.couple_id IS NOT NULL THEN
|
|
IF (SELECT COUNT(*) FROM profiles WHERE couple_id = NEW.couple_id AND id != NEW.id) >= 2 THEN
|
|
RAISE EXCEPTION 'A couple can only have 2 members';
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- Sync premium status from subscriptions
|
|
CREATE OR REPLACE FUNCTION sync_premium_status()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
UPDATE public.profiles
|
|
SET is_premium = EXISTS (
|
|
SELECT 1 FROM public.subscriptions
|
|
WHERE user_id = NEW.user_id
|
|
AND status = 'active'
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
)
|
|
WHERE id = NEW.user_id;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- Disable premium packs when subscription is lost
|
|
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
BEGIN
|
|
IF OLD.is_premium = TRUE AND NEW.is_premium = FALSE AND NEW.couple_id IS NOT NULL THEN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM public.profiles
|
|
WHERE couple_id = NEW.couple_id
|
|
AND id != NEW.id
|
|
AND is_premium = TRUE
|
|
) THEN
|
|
UPDATE public.couple_packs cp
|
|
SET enabled = FALSE
|
|
FROM public.question_packs qp
|
|
WHERE cp.pack_id = qp.id
|
|
AND qp.is_premium = TRUE
|
|
AND cp.couple_id = NEW.couple_id
|
|
AND cp.enabled = TRUE;
|
|
END IF;
|
|
END IF;
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- Log admin actions
|
|
CREATE OR REPLACE FUNCTION log_admin_action(
|
|
p_table_name TEXT,
|
|
p_record_id UUID,
|
|
p_action audit_action,
|
|
p_old_values JSONB DEFAULT NULL,
|
|
p_new_values JSONB DEFAULT NULL
|
|
)
|
|
RETURNS UUID
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = 'public'
|
|
AS $$
|
|
DECLARE
|
|
log_id UUID;
|
|
admin_role_val public.admin_role;
|
|
changed_fields_arr TEXT[];
|
|
BEGIN
|
|
SELECT role INTO admin_role_val FROM public.admin_users WHERE user_id = auth.uid();
|
|
|
|
IF admin_role_val IS NULL THEN
|
|
RAISE EXCEPTION 'User is not an admin';
|
|
END IF;
|
|
|
|
IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
|
|
SELECT ARRAY(
|
|
SELECT key FROM jsonb_each(p_new_values)
|
|
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
|
|
) INTO changed_fields_arr;
|
|
END IF;
|
|
|
|
INSERT INTO public.audit_logs (
|
|
table_name, record_id, action, old_values, new_values,
|
|
changed_fields, admin_user_id, admin_role
|
|
)
|
|
VALUES (
|
|
p_table_name, p_record_id, p_action, p_old_values, p_new_values,
|
|
changed_fields_arr, auth.uid(), admin_role_val
|
|
)
|
|
RETURNING id INTO log_id;
|
|
|
|
RETURN log_id;
|
|
END;
|
|
$$;
|
|
-- Notify on new message (calls edge function)
|
|
CREATE OR REPLACE FUNCTION notify_new_message()
|
|
RETURNS TRIGGER
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
PERFORM net.http_post(
|
|
url := 'https://npmmakhjmbzlkcmfyegw.supabase.co/functions/v1/send-message-notification',
|
|
headers := jsonb_build_object(
|
|
'Content-Type', 'application/json'
|
|
),
|
|
body := jsonb_build_object(
|
|
'match_id', NEW.match_id,
|
|
'sender_id', NEW.user_id
|
|
)
|
|
);
|
|
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
-- ============================================================================
|
|
-- TRIGGERS
|
|
-- ============================================================================
|
|
|
|
-- Profile triggers
|
|
CREATE TRIGGER update_profiles_updated_at
|
|
BEFORE UPDATE ON profiles
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
CREATE TRIGGER enforce_couple_size
|
|
BEFORE INSERT OR UPDATE OF couple_id ON profiles
|
|
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
|
|
CREATE TRIGGER disable_premium_packs_on_sub_loss
|
|
AFTER UPDATE OF is_premium ON profiles
|
|
FOR EACH ROW
|
|
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
|
|
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
|
|
-- Subscription triggers
|
|
CREATE TRIGGER update_subscriptions_updated_at
|
|
BEFORE UPDATE ON subscriptions
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
CREATE TRIGGER sync_premium_on_subscription_change
|
|
AFTER INSERT OR UPDATE ON subscriptions
|
|
FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
|
|
-- Admin users trigger
|
|
CREATE TRIGGER update_admin_users_updated_at
|
|
BEFORE UPDATE ON admin_users
|
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
|
-- Message notification trigger
|
|
CREATE TRIGGER on_new_message
|
|
AFTER INSERT ON messages
|
|
FOR EACH ROW EXECUTE FUNCTION notify_new_message();
|
|
-- Auth trigger to create profile on signup
|
|
CREATE TRIGGER on_auth_user_created
|
|
AFTER INSERT ON auth.users
|
|
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
|
-- ============================================================================
|
|
-- ENABLE ROW LEVEL SECURITY
|
|
-- ============================================================================
|
|
ALTER TABLE couples ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE question_packs ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE questions ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE responses ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE matches ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE couple_packs ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE feedback ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
|
-- ============================================================================
|
|
-- RLS POLICIES: COUPLES
|
|
-- ============================================================================
|
|
CREATE POLICY "Authenticated users can create couples"
|
|
ON couples FOR INSERT
|
|
WITH CHECK (auth.role() = 'authenticated');
|
|
CREATE POLICY "Users can view own couple"
|
|
ON couples FOR SELECT
|
|
USING (id = get_auth_user_couple_id());
|
|
CREATE POLICY "Users can lookup couple by exact invite code"
|
|
ON couples FOR SELECT
|
|
USING (id = get_auth_user_couple_id());
|
|
CREATE POLICY "Super admins can view all couples"
|
|
ON couples FOR SELECT
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: PROFILES
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can view own profile"
|
|
ON profiles FOR SELECT
|
|
USING (auth.uid() = id);
|
|
CREATE POLICY "Users can view partner profile"
|
|
ON profiles FOR SELECT
|
|
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
|
|
CREATE POLICY "Users can update own profile"
|
|
ON profiles FOR UPDATE
|
|
USING (auth.uid() = id);
|
|
CREATE POLICY "Super admins can view all profiles"
|
|
ON profiles FOR SELECT
|
|
USING (is_super_admin());
|
|
CREATE POLICY "Super admins can update all profiles"
|
|
ON profiles FOR UPDATE
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: CATEGORIES
|
|
-- ============================================================================
|
|
CREATE POLICY "Categories are viewable by everyone"
|
|
ON categories FOR SELECT
|
|
USING (true);
|
|
CREATE POLICY "Admins can view all categories"
|
|
ON categories FOR SELECT
|
|
USING (is_admin());
|
|
CREATE POLICY "Admins can insert categories"
|
|
ON categories FOR INSERT
|
|
WITH CHECK (is_admin());
|
|
CREATE POLICY "Admins can update categories"
|
|
ON categories FOR UPDATE
|
|
USING (is_admin())
|
|
WITH CHECK (is_admin());
|
|
CREATE POLICY "Admins can delete categories"
|
|
ON categories FOR DELETE
|
|
USING (is_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: QUESTION PACKS
|
|
-- ============================================================================
|
|
CREATE POLICY "Anyone can view public packs"
|
|
ON question_packs FOR SELECT
|
|
USING (is_public = true);
|
|
CREATE POLICY "Premium users can view premium packs"
|
|
ON question_packs FOR SELECT
|
|
USING (is_premium = false OR EXISTS (
|
|
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
|
));
|
|
CREATE POLICY "Admins can view all question packs"
|
|
ON question_packs FOR SELECT
|
|
USING (is_admin());
|
|
CREATE POLICY "Admins can insert question packs"
|
|
ON question_packs FOR INSERT
|
|
WITH CHECK (is_admin());
|
|
CREATE POLICY "Admins can update question packs"
|
|
ON question_packs FOR UPDATE
|
|
USING (is_admin());
|
|
CREATE POLICY "Admins can delete question packs"
|
|
ON question_packs FOR DELETE
|
|
USING (is_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: QUESTIONS
|
|
-- ============================================================================
|
|
CREATE POLICY "Anyone can view questions in visible packs"
|
|
ON questions FOR SELECT
|
|
USING (pack_id IN (
|
|
SELECT id FROM question_packs
|
|
WHERE is_public = true OR (is_premium = true AND EXISTS (
|
|
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
|
))
|
|
));
|
|
CREATE POLICY "Admins can view all questions"
|
|
ON questions FOR SELECT
|
|
USING (is_admin());
|
|
CREATE POLICY "Admins can insert questions"
|
|
ON questions FOR INSERT
|
|
WITH CHECK (is_admin());
|
|
CREATE POLICY "Admins can update questions"
|
|
ON questions FOR UPDATE
|
|
USING (is_admin());
|
|
CREATE POLICY "Admins can delete questions"
|
|
ON questions FOR DELETE
|
|
USING (is_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: RESPONSES
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can view couple responses"
|
|
ON responses FOR SELECT
|
|
USING (user_id = auth.uid() OR couple_id IN (
|
|
SELECT couple_id FROM profiles WHERE id = auth.uid()
|
|
));
|
|
CREATE POLICY "Users can insert own responses"
|
|
ON responses FOR INSERT
|
|
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
|
|
CREATE POLICY "Users can update own responses"
|
|
ON responses FOR UPDATE
|
|
USING (user_id = auth.uid());
|
|
CREATE POLICY "Users can delete own responses"
|
|
ON responses FOR DELETE
|
|
USING (user_id = auth.uid());
|
|
CREATE POLICY "Super admins can view all responses"
|
|
ON responses FOR SELECT
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: MATCHES
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can view couple matches"
|
|
ON matches FOR SELECT
|
|
USING (couple_id = get_auth_user_couple_id());
|
|
CREATE POLICY "Users can update match seen status"
|
|
ON matches FOR UPDATE
|
|
USING (couple_id = get_auth_user_couple_id());
|
|
CREATE POLICY "Users can delete own couple matches"
|
|
ON matches FOR DELETE
|
|
USING (couple_id = get_auth_user_couple_id());
|
|
CREATE POLICY "Super admins can view all matches"
|
|
ON matches FOR SELECT
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: MESSAGES
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can view messages in their matches"
|
|
ON messages FOR SELECT
|
|
USING (EXISTS (
|
|
SELECT 1 FROM matches m
|
|
WHERE m.id = messages.match_id
|
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
|
));
|
|
CREATE POLICY "Users can insert messages in their matches"
|
|
ON messages FOR INSERT
|
|
WITH CHECK (auth.uid() = user_id AND EXISTS (
|
|
SELECT 1 FROM matches m
|
|
WHERE m.id = messages.match_id
|
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
|
));
|
|
CREATE POLICY "Users can mark messages as read"
|
|
ON messages FOR UPDATE
|
|
USING (auth.uid() <> user_id AND EXISTS (
|
|
SELECT 1 FROM matches m
|
|
WHERE m.id = messages.match_id
|
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
|
))
|
|
WITH CHECK (auth.uid() <> user_id);
|
|
CREATE POLICY "Users can delete own messages"
|
|
ON messages FOR DELETE
|
|
USING (user_id = auth.uid());
|
|
CREATE POLICY "Super admins can view all messages"
|
|
ON messages FOR SELECT
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: COUPLE PACKS
|
|
-- ============================================================================
|
|
CREATE POLICY "Couples can view their own pack settings"
|
|
ON couple_packs FOR SELECT
|
|
USING (EXISTS (
|
|
SELECT 1 FROM profiles
|
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
|
));
|
|
CREATE POLICY "Couples can modify their own pack settings"
|
|
ON couple_packs FOR ALL
|
|
USING (EXISTS (
|
|
SELECT 1 FROM profiles
|
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
|
));
|
|
CREATE POLICY "Users can delete own couple pack settings"
|
|
ON couple_packs FOR DELETE
|
|
USING (EXISTS (
|
|
SELECT 1 FROM profiles
|
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
|
));
|
|
-- ============================================================================
|
|
-- RLS POLICIES: FEEDBACK
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can create feedback"
|
|
ON feedback FOR INSERT
|
|
WITH CHECK (auth.uid() = user_id);
|
|
CREATE POLICY "Users can view own feedback"
|
|
ON feedback FOR SELECT
|
|
USING (auth.uid() = user_id);
|
|
CREATE POLICY "Users can update own pending feedback"
|
|
ON feedback FOR UPDATE
|
|
USING (auth.uid() = user_id AND status = 'new');
|
|
CREATE POLICY "Super admins can view all feedback"
|
|
ON feedback FOR SELECT
|
|
USING (is_super_admin());
|
|
CREATE POLICY "Super admins can update all feedback"
|
|
ON feedback FOR UPDATE
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: SUBSCRIPTIONS
|
|
-- ============================================================================
|
|
CREATE POLICY "Users can view own subscriptions"
|
|
ON subscriptions FOR SELECT
|
|
USING (auth.uid() = user_id);
|
|
CREATE POLICY "Super admins can view all subscriptions"
|
|
ON subscriptions FOR SELECT
|
|
USING (is_super_admin());
|
|
CREATE POLICY "Super admins can insert subscriptions"
|
|
ON subscriptions FOR INSERT
|
|
WITH CHECK (is_super_admin());
|
|
CREATE POLICY "Super admins can update subscriptions"
|
|
ON subscriptions FOR UPDATE
|
|
USING (is_super_admin());
|
|
CREATE POLICY "Super admins can delete subscriptions"
|
|
ON subscriptions FOR DELETE
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
|
|
-- ============================================================================
|
|
-- Service role only - no public policies needed
|
|
-- Edge functions use service role key
|
|
|
|
-- ============================================================================
|
|
-- RLS POLICIES: ADMIN USERS
|
|
-- ============================================================================
|
|
CREATE POLICY "Admin users can read own record"
|
|
ON admin_users FOR SELECT
|
|
USING (auth.uid() = user_id);
|
|
-- ============================================================================
|
|
-- RLS POLICIES: AUDIT LOGS
|
|
-- ============================================================================
|
|
CREATE POLICY "Super admins can view audit logs"
|
|
ON audit_logs FOR SELECT
|
|
USING (is_super_admin());
|
|
-- ============================================================================
|
|
-- REALTIME SUBSCRIPTIONS
|
|
-- ============================================================================
|
|
ALTER PUBLICATION supabase_realtime ADD TABLE profiles;
|
|
ALTER PUBLICATION supabase_realtime ADD TABLE matches;
|
|
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
|
|
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
|
|
-- ============================================================================
|
|
-- STORAGE BUCKETS
|
|
-- ============================================================================
|
|
INSERT INTO storage.buckets (id, name, public)
|
|
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
INSERT INTO storage.buckets (id, name, public)
|
|
VALUES ('chat-media', 'chat-media', false)
|
|
ON CONFLICT (id) DO NOTHING;
|
|
-- ============================================================================
|
|
-- STORAGE POLICIES
|
|
-- ============================================================================
|
|
|
|
-- Feedback screenshots policies
|
|
CREATE POLICY "Users can upload feedback screenshots"
|
|
ON storage.objects FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
bucket_id = 'feedback-screenshots'
|
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
|
);
|
|
CREATE POLICY "Users can view own feedback screenshots"
|
|
ON storage.objects FOR SELECT TO authenticated
|
|
USING (
|
|
bucket_id = 'feedback-screenshots'
|
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
|
);
|
|
-- Chat media policies
|
|
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
|
|
CREATE POLICY "Users can view chat media in their matches"
|
|
ON storage.objects FOR SELECT TO authenticated
|
|
USING (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM matches m
|
|
JOIN profiles p ON p.couple_id = m.couple_id
|
|
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
|
AND p.id = auth.uid()
|
|
)
|
|
);
|
|
CREATE POLICY "Users can upload chat media to their matches"
|
|
ON storage.objects FOR INSERT TO authenticated
|
|
WITH CHECK (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM matches m
|
|
JOIN profiles p ON p.couple_id = m.couple_id
|
|
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
|
AND p.id = auth.uid()
|
|
)
|
|
);
|
|
CREATE POLICY "Users can delete their own chat media"
|
|
ON storage.objects FOR DELETE TO authenticated
|
|
USING (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (
|
|
SELECT 1 FROM matches m
|
|
JOIN profiles p ON p.couple_id = m.couple_id
|
|
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
|
|
AND p.id = auth.uid()
|
|
)
|
|
);
|
|
CREATE POLICY "Admins can view all chat media"
|
|
ON storage.objects FOR SELECT TO authenticated
|
|
USING (
|
|
bucket_id = 'chat-media'
|
|
AND EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid())
|
|
);
|