This commit is contained in:
jjbrunton committed 2025-12-30 15:54:34 +00:00
1 parent 456459bb00
commit 5b97bc6f56
60 files changed
+1440 -1974

No files matched your search

@@ -1,108 +0,0 @@
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create custom types
CREATE TYPE public.answer_type AS ENUM ('yes', 'no', 'maybe');
CREATE TYPE public.match_type AS ENUM ('yes_yes', 'yes_maybe', 'maybe_maybe');
-- Couples table (must be created first due to FK references)
CREATE TABLE public.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()
);
-- User profiles (extends auth.users)
CREATE TABLE public.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 public.couples(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Question packs
CREATE TABLE public.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 INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Questions within packs
CREATE TABLE public.questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
text TEXT NOT NULL,
intensity INT DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User responses to questions
CREATE TABLE public.responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
answer public.answer_type NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, question_id)
);
-- Matches between partners
CREATE TABLE public.matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
match_type public.match_type NOT NULL,
is_new BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(couple_id, question_id)
);
-- Create indexes for common queries
CREATE INDEX idx_profiles_couple_id ON public.profiles(couple_id);
CREATE INDEX idx_questions_pack_id ON public.questions(pack_id);
CREATE INDEX idx_responses_couple_question ON public.responses(couple_id, question_id);
CREATE INDEX idx_matches_couple_id ON public.matches(couple_id);
CREATE INDEX idx_matches_is_new ON public.matches(is_new) WHERE is_new = TRUE;
-- Function to auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'full_name'),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to create profile on signup
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update updated_at on profiles
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
@@ -1,87 +0,0 @@
-- Enable Row Level Security on all tables
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.couples ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.question_packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY;
-- Profiles policies
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Users can view partner profile"
ON public.profiles FOR SELECT
USING (
couple_id IS NOT NULL AND
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
-- Couples policies
CREATE POLICY "Users can view own couple"
ON public.couples FOR SELECT
USING (id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Authenticated users can create couples"
ON public.couples FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Users can view couple by invite code"
ON public.couples FOR SELECT
USING (TRUE); -- Allow reading by invite_code for joining
-- Question packs policies (public packs visible to all)
CREATE POLICY "Anyone can view public packs"
ON public.question_packs FOR SELECT
USING (is_public = TRUE);
CREATE POLICY "Premium users can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
);
-- Questions policies
CREATE POLICY "Anyone can view questions in visible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT id FROM public.question_packs
WHERE is_public = TRUE OR (
is_premium = TRUE AND
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
)
)
);
-- Responses policies
CREATE POLICY "Users can view own responses"
ON public.responses FOR SELECT
USING (user_id = auth.uid());
CREATE POLICY "Users can insert own responses"
ON public.responses FOR INSERT
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update own responses"
ON public.responses FOR UPDATE
USING (user_id = auth.uid());
-- Matches policies
CREATE POLICY "Users can view couple matches"
ON public.matches FOR SELECT
USING (
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
CREATE POLICY "Users can update match seen status"
ON public.matches FOR UPDATE
USING (
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
@@ -1,58 +0,0 @@
-- Seed data: Initial question packs for testing
-- Starter Pack (free)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'Starter Pack',
'Perfect for couples just getting started. Light and playful questions to break the ice.',
'🌟',
FALSE,
TRUE,
1
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to try a couples massage together?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a candlelit dinner at home?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to slow dance in the living room?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a bath or shower together?', 2),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to give your partner a back rub?', 1);
-- Date Night Pack (free)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'b2c3d4e5-f678-9012-bcde-f23456789012',
'Date Night',
'Spice up your date nights with these romantic suggestions.',
'🌹',
FALSE,
TRUE,
2
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy stargazing together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to cook a meal together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a picnic in the park?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to watch a romantic movie together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a weekend getaway?', 2);
-- Adventure Pack (premium)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'c3d4e5f6-7890-1234-cdef-345678901234',
'Adventure',
'For couples ready to explore more adventurous activities together.',
'🔥',
TRUE,
TRUE,
3
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to try role-playing scenarios?', 3),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy using massage oils?', 2),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to write love letters to each other?', 2),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy a blindfolded experience?', 3),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to plan a surprise for your partner?', 2);
@@ -1,32 +0,0 @@
-- Add Adult / After Dark pack
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'd4e5f607-8901-2345-defa-456789012345',
'After Dark',
'Our most intimate and explicit questions for couples ready to explore their deepest fantasies.',
'😈',
TRUE,
TRUE,
4
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try using toys during intimacy?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring BDSM (e.g., light bondage)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to watch adult content together?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying intimacy in a new or daring location?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with temperature play (e.g., ice or wax)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy role-playing a "stranger" or "first date" scenario?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "dirty talk" or more explicit communication?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a full-body sensory massage with oils and feathers?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to explore "edging" or delayed gratification?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy sharing your deepest, most explicit fantasies?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try shower sex or water play?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a roleplay involving a "student/teacher" or similar dynamic?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with leather, latex, or other textures?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying "impact play" (e.g., spanking)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a weekend dedicated entirely to intimacy and exploration?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "blindfolded feeding" or sensory food play?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring your partner''s "no-go" zones with permission?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "synchronized breathing" or Tantric techniques?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy making a "video for us" to watch later?', 3);
@@ -1,49 +0,0 @@
-- Fix infinite recursion in profiles RLS policy
-- Create a secure function to look up couple_id without triggering recursion
CREATE OR REPLACE FUNCTION public.get_auth_user_couple_id()
RETURNS UUID
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
$$;
-- Drop the recursive policy
DROP POLICY IF EXISTS "Users can view partner profile" ON public.profiles;
-- Recreate the policy using the secure function
CREATE POLICY "Users can view partner profile"
ON public.profiles FOR SELECT
USING (
couple_id IS NOT NULL AND
couple_id = public.get_auth_user_couple_id()
);
-- Also fix the Matches policy which has the same issue
DROP POLICY IF EXISTS "Users can view couple matches" ON public.matches;
CREATE POLICY "Users can view couple matches"
ON public.matches FOR SELECT
USING (
couple_id = public.get_auth_user_couple_id()
);
-- And the update matches policy
DROP POLICY IF EXISTS "Users can update match seen status" ON public.matches;
CREATE POLICY "Users can update match seen status"
ON public.matches FOR UPDATE
USING (
couple_id = public.get_auth_user_couple_id()
);
-- And the Couples policy
DROP POLICY IF EXISTS "Users can view own couple" ON public.couples;
CREATE POLICY "Users can view own couple"
ON public.couples FOR SELECT
USING (
id = public.get_auth_user_couple_id()
);
@@ -1,41 +0,0 @@
-- Create couple_packs table to track enabled packs per couple
CREATE TABLE public.couple_packs (
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (couple_id, pack_id)
);
-- RLS Policies
ALTER TABLE public.couple_packs ENABLE ROW LEVEL SECURITY;
-- Couples can view their own pack settings
CREATE POLICY "Couples can view their own pack settings"
ON public.couple_packs
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.couple_id = couple_packs.couple_id
)
);
-- Couples can insert/update their own pack settings
CREATE POLICY "Couples can modify their own pack settings"
ON public.couple_packs
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.couple_id = couple_packs.couple_id
)
);
-- Function to handle default packs for new couples (Optional, but good UX)
-- For now, we'll just assume if no record exists, it's not enabled?
-- Or maybe better: if no record exists, it IS enabled if it's not premium?
-- Actually, the user wants to "select which packs are active".
-- Let's stick to explicit records.
@@ -1,64 +0,0 @@
-- Create messages table
CREATE TABLE IF NOT EXISTS public.messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
match_id UUID NOT NULL REFERENCES public.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(),
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
);
-- RLS for messages
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view messages in their matches"
ON public.messages
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
);
CREATE POLICY "Users can insert messages in their matches"
ON public.messages
FOR INSERT
WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
);
-- Storage bucket for chat media
INSERT INTO storage.buckets (id, name, public)
VALUES ('chat-media', 'chat-media', true)
ON CONFLICT (id) DO NOTHING;
-- Storage policies
CREATE POLICY "Users can upload chat media"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'chat-media' AND
auth.role() = 'authenticated'
);
CREATE POLICY "Users can view chat media"
ON storage.objects
FOR SELECT
USING (bucket_id = 'chat-media');
-- Realtime
alter publication supabase_realtime add table messages;
@@ -1,21 +0,0 @@
-- Add read_at column
ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
-- Allow users to update read_at for messages where they are NOT the sender
CREATE POLICY "Users can mark messages as read"
ON public.messages
FOR UPDATE
USING (
auth.uid() != user_id AND
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
)
WITH CHECK (
auth.uid() != user_id
);
@@ -1,33 +0,0 @@
-- Add Explicit / Wild pack
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'e5f6a7b8-9012-3456-abcd-567890123456',
'Explicit & Wild',
'Unleash your wildest desires with our most explicit questions. Uses direct language for couples who want to get straight to the point.',
'🔥',
TRUE,
TRUE,
5
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me cum on your face?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to swallow my cum?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to be fucked rough, with hair pulling and choking?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to use a butt plug during sex?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me treat you like a slut in the bedroom?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to make a sex tape where we show everything?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be into a gangbang fantasy?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want a creampie (cum inside)?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to use a strap-on to fuck me?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to rim my asshole?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to try fisting?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you lick my cum off your own body?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to deep throat until you gag?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to watch me have sex with someone else?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to have anal sex until I cum?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be down for a threesome?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want me to edge you until you are begging to cum?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to wake me up with oral sex?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to be spanked hard enough to leave marks?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you enjoy being tied up and helpless while I use you?', 5);
@@ -1,70 +0,0 @@
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
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
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
);
END;
$$;
@@ -1,79 +0,0 @@
-- Add partner_text column to questions table
ALTER TABLE public.questions ADD COLUMN IF NOT EXISTS partner_text TEXT;
-- Update get_recommended_questions to include partner_text
-- Must drop first because return type changes
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
partner_text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
q.partner_text,
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
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
);
END;
$$;
@@ -1,18 +0,0 @@
-- Add a new pack for two-part questions
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'd4e5f6f7-8901-2345-defa-456789012345',
'Giving & Receiving',
'Explore different roles and dynamics with these two-part questions.',
'🌓',
FALSE,
TRUE,
4
) ON CONFLICT (id) DO NOTHING;
INSERT INTO public.questions (pack_id, text, partner_text, intensity) VALUES
('d4e5f6f7-8901-2345-defa-456789012345', 'Finish on your partner''s face', 'Have your partner finish on your face', 4),
('d4e5f6f7-8901-2345-defa-456789012345', 'Give your partner a full body massage', 'Receive a full body massage from your partner', 2),
('d4e5f6f7-8901-2345-defa-456789012345', 'Tie your partner up', 'Be tied up by your partner', 4),
('d4e5f6f7-8901-2345-defa-456789012345', 'Blindfold your partner', 'Be blindfolded by your partner', 3),
('d4e5f6f7-8901-2345-defa-456789012345', 'Feed your partner while they are blindfolded', 'Be fed by your partner while blindfolded', 2);
@@ -1,75 +0,0 @@
-- Update get_recommended_questions to handle two-part questions role assignment
-- And return is_two_part for prioritization in the app
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
partner_text TEXT, -- Keep this for reference if needed, but 'text' will be the display one
is_two_part BOOLEAN,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
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
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
WHERE target_pack_id IS NULL AND cp.couple_id = v_couple_id AND cp.enabled = 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 NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
-- Logic: Show partner_text IF partner has already answered, otherwise show text
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
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
);
END;
$$;
@@ -1,9 +0,0 @@
-- Allow users to view their partner's responses in addition to their own
DROP POLICY IF EXISTS "Users can view own responses" ON public.responses;
CREATE POLICY "Users can view couple responses"
ON public.responses FOR SELECT
USING (
user_id = auth.uid() OR
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
@@ -1,74 +0,0 @@
-- Create feedback type enum
CREATE TYPE public.feedback_type AS ENUM ('bug', 'feature_request', 'general');
-- Create feedback status enum (for future admin use)
CREATE TYPE public.feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed');
-- Create feedback table
CREATE TABLE public.feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
-- Feedback content
type public.feedback_type NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
screenshot_url TEXT,
-- Device info (JSONB for flexibility)
device_info JSONB NOT NULL DEFAULT '{}',
-- Admin/tracking fields
status public.feedback_status NOT NULL DEFAULT 'new',
admin_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_feedback_user_id ON public.feedback(user_id);
CREATE INDEX idx_feedback_type ON public.feedback(type);
CREATE INDEX idx_feedback_status ON public.feedback(status);
CREATE INDEX idx_feedback_created_at ON public.feedback(created_at DESC);
-- Enable RLS
ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users can insert their own feedback
CREATE POLICY "Users can create feedback"
ON public.feedback FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can view their own feedback
CREATE POLICY "Users can view own feedback"
ON public.feedback FOR SELECT
USING (auth.uid() = user_id);
-- Users can update their own feedback (only if still 'new' status)
CREATE POLICY "Users can update own pending feedback"
ON public.feedback FOR UPDATE
USING (auth.uid() = user_id AND status = 'new');
-- Create storage bucket for feedback screenshots
INSERT INTO storage.buckets (id, name, public)
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
ON CONFLICT (id) DO NOTHING;
-- Storage RLS policies for feedback screenshots
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
);
@@ -1,84 +0,0 @@
-- Create subscription status enum
CREATE TYPE public.subscription_status AS ENUM (
'active',
'cancelled',
'expired',
'billing_issue',
'paused'
);
-- Create subscriptions table
CREATE TABLE public.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
revenuecat_app_user_id TEXT NOT NULL,
product_id TEXT NOT NULL,
status public.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)
);
-- Create webhook events table for idempotency
CREATE TABLE public.revenuecat_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
app_user_id TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload JSONB
);
-- Indexes
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON public.subscriptions(status);
CREATE INDEX idx_subscriptions_expires_at ON public.subscriptions(expires_at);
CREATE INDEX idx_webhook_events_event_id ON public.revenuecat_webhook_events(event_id);
-- Update updated_at trigger for subscriptions
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
-- RLS Policies for subscriptions
CREATE POLICY "Users can view own subscriptions"
ON public.subscriptions FOR SELECT
USING (auth.uid() = user_id);
-- Service role only for webhook events (no user access needed)
-- Webhook events are written by service role only
-- Function to sync is_premium flag to profiles
CREATE OR REPLACE FUNCTION public.sync_premium_status()
RETURNS TRIGGER AS $$
BEGIN
-- Update the profile's is_premium based on active subscription
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;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to auto-sync premium status on subscription changes
CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status();
@@ -1,91 +0,0 @@
-- Update RLS policies to allow partner premium access
-- When either user OR their partner has is_premium = TRUE, both can access premium packs
-- Drop existing premium pack policy
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
-- Create new policy that checks couple premium status
-- Access granted if: pack is not premium OR user is premium OR partner is premium
CREATE POLICY "Users or partners with premium can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
);
-- Update questions policy similarly
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Users can view questions in accessible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT qp.id FROM public.question_packs qp
WHERE qp.is_public = TRUE
AND (
qp.is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
)
)
);
-- Create a helper function to check premium access for a user
-- This can be used in application code and other policies
CREATE OR REPLACE FUNCTION public.has_premium_access(check_user_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
user_premium BOOLEAN;
partner_premium BOOLEAN;
user_couple_id UUID;
BEGIN
-- Get user's premium status and couple_id
SELECT is_premium, couple_id INTO user_premium, user_couple_id
FROM public.profiles
WHERE id = check_user_id;
-- If user is premium, return true
IF user_premium = TRUE THEN
RETURN TRUE;
END IF;
-- If user has no couple, return false
IF user_couple_id IS NULL THEN
RETURN FALSE;
END IF;
-- Check if partner is premium
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;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
@@ -1,94 +0,0 @@
-- Fix: Filter out premium packs from get_recommended_questions when user lacks premium access
-- This ensures users whose subscriptions have expired don't continue seeing premium questions
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.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 INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_has_premium BOOLEAN;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
-- Check if user or partner has premium access
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 (
-- When targeting a specific pack, only include if accessible (not premium OR has premium access)
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
-- When browsing all enabled packs, filter out premium packs if no premium access
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
-- Default packs when no config exists (filter premium here too)
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
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
);
END;
$$;
@@ -1,22 +0,0 @@
-- Enforce maximum 2 members per couple at the database level
-- This prevents race conditions and direct API manipulation from allowing
-- more than 2 people to join a single couple relationship
CREATE OR REPLACE FUNCTION check_couple_size()
RETURNS TRIGGER AS $$
BEGIN
-- Only check when couple_id is being set (not when clearing it)
IF NEW.couple_id IS NOT NULL THEN
-- Count existing members excluding the current user
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;
$$ LANGUAGE plpgsql;
-- Trigger fires before insert or update of couple_id column
CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
@@ -1,69 +0,0 @@
-- Admin Role-Based Access Control
-- Creates admin_users table and helper functions for role management
-- Create admin role enum
CREATE TYPE public.admin_role AS ENUM ('pack_creator', 'super_admin');
-- Admin users table (tracks which auth.users are admins and their role)
CREATE TABLE public.admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
role public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for quick lookups
CREATE INDEX idx_admin_users_user_id ON public.admin_users(user_id);
CREATE INDEX idx_admin_users_role ON public.admin_users(role);
-- Updated_at trigger
CREATE TRIGGER update_admin_users_updated_at
BEFORE UPDATE ON public.admin_users
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
-- Policy: Allow admins to read their own record
CREATE POLICY "Admin users can read own record"
ON public.admin_users FOR SELECT
USING (auth.uid() = user_id);
-- Helper function to check if user is admin
CREATE OR REPLACE FUNCTION public.is_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users WHERE user_id = check_user_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to get admin role
CREATE OR REPLACE FUNCTION public.get_admin_role(check_user_id UUID DEFAULT auth.uid())
RETURNS public.admin_role 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;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to check if user is super admin
CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users
WHERE user_id = check_user_id AND role = 'super_admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Grant execute permissions on helper functions
GRANT EXECUTE ON FUNCTION public.is_admin TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_admin_role TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_super_admin TO authenticated;
@@ -1,81 +0,0 @@
-- Audit Logging System
-- Creates audit_logs table and RPC function for logging admin actions
-- Create action type enum for audit logging
CREATE TYPE public.audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
-- Audit log table
CREATE TABLE public.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action public.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 public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for querying audit logs
CREATE INDEX idx_audit_logs_table_name ON public.audit_logs(table_name);
CREATE INDEX idx_audit_logs_record_id ON public.audit_logs(record_id);
CREATE INDEX idx_audit_logs_admin_user_id ON public.audit_logs(admin_user_id);
CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_action ON public.audit_logs(action);
-- Enable RLS (super_admins only can view logs)
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Only super admins can view audit logs
CREATE POLICY "Super admins can view audit logs"
ON public.audit_logs FOR SELECT
USING (public.is_super_admin());
-- Function to log admin actions (called from frontend via RPC)
CREATE OR REPLACE FUNCTION public.log_admin_action(
p_table_name TEXT,
p_record_id UUID,
p_action public.audit_action,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
log_id UUID;
admin_role_val public.admin_role;
changed_fields_arr TEXT[];
BEGIN
-- Get admin role
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;
-- Calculate changed fields for updates
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 audit log
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;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission on logging function
GRANT EXECUTE ON FUNCTION public.log_admin_action TO authenticated;
@@ -1,79 +0,0 @@
-- Admin RLS Policies
-- Adds policies for admin access to tables
-- Question Packs: All admins can manage (CRUD)
CREATE POLICY "Admins can view all question packs"
ON public.question_packs FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert question packs"
ON public.question_packs FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update question packs"
ON public.question_packs FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete question packs"
ON public.question_packs FOR DELETE
USING (public.is_admin());
-- Questions: All admins can manage (CRUD)
CREATE POLICY "Admins can view all questions"
ON public.questions FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert questions"
ON public.questions FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update questions"
ON public.questions FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete questions"
ON public.questions FOR DELETE
USING (public.is_admin());
-- Profiles: Super admins can view and update all
CREATE POLICY "Super admins can view all profiles"
ON public.profiles FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all profiles"
ON public.profiles FOR UPDATE
USING (public.is_super_admin());
-- Couples: Super admins can view all
CREATE POLICY "Super admins can view all couples"
ON public.couples FOR SELECT
USING (public.is_super_admin());
-- Responses: Super admins can view all
CREATE POLICY "Super admins can view all responses"
ON public.responses FOR SELECT
USING (public.is_super_admin());
-- Matches: Super admins can view all
CREATE POLICY "Super admins can view all matches"
ON public.matches FOR SELECT
USING (public.is_super_admin());
-- Messages: Super admins can view all
CREATE POLICY "Super admins can view all messages"
ON public.messages FOR SELECT
USING (public.is_super_admin());
-- Subscriptions: Super admins can view all
CREATE POLICY "Super admins can view all subscriptions"
ON public.subscriptions FOR SELECT
USING (public.is_super_admin());
-- Feedback: Super admins can view and manage all
CREATE POLICY "Super admins can view all feedback"
ON public.feedback FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all feedback"
ON public.feedback FOR UPDATE
USING (public.is_super_admin());
@@ -1,175 +0,0 @@
-- Security Hardening Migration
-- Fixes: Weak invite codes, overly permissive RLS, password settings
-- ============================================
-- 1. Strengthen invite code generation
-- Use crypto-secure random with alphanumeric chars (8 chars retained for UX)
-- ============================================
-- Create a function to generate secure invite codes
CREATE OR REPLACE FUNCTION generate_secure_invite_code()
RETURNS TEXT AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Removed confusing chars: I, O, 0, 1
result TEXT := '';
i INTEGER;
rand_bytes BYTEA;
BEGIN
-- Get 8 cryptographically secure random bytes
rand_bytes := gen_random_bytes(8);
FOR i IN 1..8 LOOP
result := result || substr(chars, (get_byte(rand_bytes, i-1) % 32) + 1, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update the default for new couples to use secure generation
ALTER TABLE public.couples
ALTER COLUMN invite_code SET DEFAULT generate_secure_invite_code();
-- Add rate limiting tracking table for invite code attempts
CREATE TABLE IF NOT EXISTS public.invite_code_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ip_hash TEXT NOT NULL, -- Hashed IP for privacy
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
attempted_at TIMESTAMPTZ DEFAULT NOW(),
was_successful BOOLEAN DEFAULT FALSE
);
-- Index for efficient rate limiting queries
CREATE INDEX IF NOT EXISTS idx_invite_attempts_user_time
ON public.invite_code_attempts(user_id, attempted_at);
-- Enable RLS on attempts table
ALTER TABLE public.invite_code_attempts ENABLE ROW LEVEL SECURITY;
-- Only allow inserts from authenticated users
CREATE POLICY "Authenticated users can log attempts"
ON public.invite_code_attempts
FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- ============================================
-- 2. Fix overly permissive couples RLS policy
-- ============================================
-- Drop the overly permissive policy
DROP POLICY IF EXISTS "Users can view couple by invite code" ON public.couples;
-- Replace with a more restrictive policy
-- Users can only view their own couple OR look up a couple via RPC (not direct table access)
CREATE POLICY "Users can only view their own couple"
ON public.couples FOR SELECT
USING (
id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
-- Create a secure RPC function for invite code lookup
-- This only returns success/failure, not couple data
CREATE OR REPLACE FUNCTION public.verify_invite_code(code TEXT)
RETURNS JSONB AS $$
DECLARE
couple_record RECORD;
member_count INTEGER;
BEGIN
-- Normalize the code
code := UPPER(TRIM(code));
-- Check if code exists
SELECT id INTO couple_record
FROM public.couples
WHERE UPPER(invite_code) = code;
IF couple_record.id IS NULL THEN
RETURN jsonb_build_object('valid', false, 'error', 'Invalid invite code');
END IF;
-- Check member count
SELECT COUNT(*) INTO member_count
FROM public.profiles
WHERE couple_id = couple_record.id;
IF member_count >= 2 THEN
RETURN jsonb_build_object('valid', false, 'error', 'This couple already has two partners');
END IF;
RETURN jsonb_build_object('valid', true, 'couple_id', couple_record.id);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION public.verify_invite_code(TEXT) TO authenticated;
-- ============================================
-- 3. Optional: Add password policy comment
-- (Password length is enforced client-side, but should also be in Supabase Auth config)
-- ============================================
COMMENT ON SCHEMA public IS 'Password policy: Minimum 8 characters required. Configure in Supabase Dashboard > Auth > Policies';
-- ============================================
-- 4. Protect push tokens from being accessed by other users
-- Push tokens should only be accessible via service role for notifications
-- ============================================
-- Create a secure view for partner profile access that excludes sensitive fields
CREATE OR REPLACE VIEW public.partner_profiles AS
SELECT
id,
name,
avatar_url,
is_premium,
couple_id,
created_at,
updated_at,
show_explicit_content
-- Explicitly excluding: push_token
FROM public.profiles;
-- Allow authenticated users to query the view
GRANT SELECT ON public.partner_profiles TO authenticated;
-- Add comment explaining the security rationale
COMMENT ON VIEW public.partner_profiles IS
'Secure view of profiles excluding push_token. Use this view when displaying partner information. Push tokens are only accessible via service role for sending notifications.';
-- Create a function to safely get partner info without exposing push_token
CREATE OR REPLACE FUNCTION public.get_partner_profile()
RETURNS JSONB AS $$
DECLARE
current_couple_id UUID;
partner_data JSONB;
BEGIN
-- Get current user's couple_id
SELECT couple_id INTO current_couple_id
FROM public.profiles
WHERE id = auth.uid();
IF current_couple_id IS NULL THEN
RETURN NULL;
END IF;
-- Get partner profile without sensitive fields
SELECT jsonb_build_object(
'id', p.id,
'name', p.name,
'avatar_url', p.avatar_url,
'is_premium', p.is_premium,
'couple_id', p.couple_id,
'created_at', p.created_at
) INTO partner_data
FROM public.profiles p
WHERE p.couple_id = current_couple_id
AND p.id != auth.uid();
RETURN partner_data;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_partner_profile() TO authenticated;
@@ -1,17 +0,0 @@
-- Allow super admins to manage subscriptions manually
-- This enables the "Grant Premium" feature in the admin dashboard
-- Allow super admins to insert new manual subscriptions
CREATE POLICY "Super admins can insert subscriptions"
ON public.subscriptions FOR INSERT
WITH CHECK (public.is_super_admin());
-- Allow super admins to update subscriptions (e.g. extending expiry)
CREATE POLICY "Super admins can update subscriptions"
ON public.subscriptions FOR UPDATE
USING (public.is_super_admin());
-- Allow super admins to delete subscriptions (e.g. revoking access)
CREATE POLICY "Super admins can delete subscriptions"
ON public.subscriptions FOR DELETE
USING (public.is_super_admin());
@@ -1,115 +0,0 @@
-- Ensure gender column exists on profiles (if not already present)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'gender') THEN
ALTER TABLE public.profiles ADD COLUMN gender TEXT CHECK (gender IN ('male', 'female', 'non-binary', 'prefer-not-to-say'));
END IF;
END $$;
-- Add allowed_couple_genders to questions
ALTER TABLE public.questions
ADD COLUMN IF NOT EXISTS allowed_couple_genders TEXT[] DEFAULT NULL;
COMMENT ON COLUMN public.questions.allowed_couple_genders IS 'Array of allowed gender pairings (e.g., ["female+male", "female+female"]). Null means all allowed.';
-- Update get_recommended_questions to respect gender context
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[]
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
BEGIN
v_user_id := auth.uid();
-- Get user info including gender
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;
-- Get partner gender
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;
-- Determine couple type
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
-- Sort genders to create a consistent type string
-- We assume binary genders for the primary logic as requested: male/male, male/female, female/female
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;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
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
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
);
END;
$$;