Align prod schema and deploy tooling

This commit is contained in:
jjbrunton committed 2026-01-24 20:16:01 +00:00
1 parent d2db86ec7f
commit 7e2e723880
105 files changed
+19014 -131

No files matched your search

@@ -11,7 +11,6 @@ CREATE TABLE redemption_codes (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Code redemptions table
CREATE TABLE code_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -20,10 +19,8 @@ CREATE TABLE code_redemptions (
redeemed_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(code_id, user_id)
);
ALTER TABLE redemption_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE code_redemptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT
USING (has_permission('manage_codes'::text));
@@ -46,7 +43,6 @@ CREATE POLICY "Super admins can manage redemption codes"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE POLICY "Users can view own redemptions"
ON code_redemptions FOR SELECT
USING (auth.uid() = user_id);
@@ -60,7 +56,6 @@ CREATE POLICY "Super admins can view all redemptions"
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE OR REPLACE FUNCTION public.redeem_code_by_email(p_email text, p_code text)
RETURNS jsonb
LANGUAGE plpgsql
@@ -133,6 +128,5 @@ BEGIN
RETURN jsonb_build_object('success', true, 'message', 'Code redeemed successfully! You now have premium access.');
END;
$function$;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO PUBLIC;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO anon, authenticated, service_role;
@@ -8,36 +8,29 @@ CREATE TABLE feature_interests (
-- Ensure a user can only express interest in a feature once
UNIQUE(user_id, feature_name)
);
-- Index for querying by feature
CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name);
-- Index for querying by user
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
-- Enable RLS
ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
-- Users can insert their own interest
CREATE POLICY "Users can express interest in features"
ON feature_interests
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Users can view their own interests
CREATE POLICY "Users can view their own interests"
ON feature_interests
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Users can delete their own interests
CREATE POLICY "Users can remove their interest"
ON feature_interests
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Grant service role full access for admin queries
GRANT ALL ON feature_interests TO service_role;;
GRANT ALL ON feature_interests TO service_role;
@@ -38,4 +38,4 @@ AS $$
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
WHERE p.id = user_id;
$$;;
$$;
@@ -1,7 +1,5 @@
-- Add RLS policy for admins with gift_premium permission to insert subscriptions
CREATE POLICY "Admins with gift_premium can insert subscriptions"
ON public.subscriptions
FOR INSERT
WITH CHECK (has_permission('gift_premium'));
;
@@ -1,4 +1,3 @@
-- Create a function that admins with gift_premium permission can use to gift premium
-- This bypasses RLS issues by using SECURITY DEFINER and checking permissions internally
CREATE OR REPLACE FUNCTION admin_gift_premium(
@@ -52,7 +51,5 @@ BEGIN
RETURN v_subscription_id;
END;
$$;
-- Grant execute permission to authenticated users (the function checks permissions internally)
GRANT EXECUTE ON FUNCTION admin_gift_premium(UUID, TIMESTAMPTZ) TO authenticated;
;
@@ -14,35 +14,28 @@ CREATE TABLE notification_preferences (
-- Timestamps
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
-- RLS policies: users can only manage their own preferences
CREATE POLICY "Users can view their own notification preferences"
ON notification_preferences FOR SELECT
USING (user_id = auth.uid());
USING (user_id = auth.uid());
CREATE POLICY "Users can insert their own notification preferences"
ON notification_preferences FOR INSERT
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update their own notification preferences"
ON notification_preferences FOR UPDATE
USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid());
WITH CHECK (user_id = auth.uid());
-- Super admins can view all preferences
CREATE POLICY "Super admins can view all notification preferences"
ON notification_preferences FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_notification_preferences_updated_at
BEFORE UPDATE ON notification_preferences
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to get notification preferences with defaults
-- Returns preferences if they exist, or creates a default row
CREATE OR REPLACE FUNCTION get_or_create_notification_preferences(p_user_id UUID)
@@ -68,7 +61,6 @@ BEGIN
RETURN v_prefs;
END;
$$;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
@@ -2,8 +2,7 @@
-- Notifies partner when a user answers questions (batched with 1-hour debounce)
-- Add last_active_at to profiles for "is user in app" check
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
-- Create pending activity notifications table
CREATE TABLE pending_activity_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,29 +13,23 @@ CREATE TABLE pending_activity_notifications (
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
);
);
-- Index for efficient cron queries
CREATE INDEX idx_pending_activity_notifications_notify_at
ON pending_activity_notifications(notify_at);
ON pending_activity_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all pending activity notifications"
ON pending_activity_notifications FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_activity_notifications_updated_at
BEFORE UPDATE ON pending_activity_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue partner activity notifications
-- Called via trigger when a response is inserted or updated
CREATE OR REPLACE FUNCTION queue_partner_activity_notification()
@@ -88,14 +81,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_submitted
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION queue_partner_activity_notification();
EXECUTE FUNCTION queue_partner_activity_notification();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-partner-activity-notifications',
@@ -107,4 +98,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -4,13 +4,11 @@
-- Add scheduling columns to question_packs
ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS scheduled_release_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
-- Index for efficient cron queries
CREATE INDEX idx_question_packs_scheduled_release
ON question_packs(scheduled_release_at)
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
-- Schedule cron job for processing scheduled releases (runs every 5 minutes)
SELECT cron.schedule(
'process-scheduled-releases',
@@ -22,4 +20,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -13,25 +13,20 @@ CREATE TABLE couple_streaks (
streak_celebrated_at INTEGER DEFAULT 0, -- Last milestone that was celebrated
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
);
-- Enable RLS
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's streaks"
ON couple_streaks FOR SELECT
USING (couple_id = get_auth_user_couple_id());
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all streaks"
ON couple_streaks FOR SELECT
USING (is_super_admin());
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_couple_streaks_updated_at
BEFORE UPDATE ON couple_streaks
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to update streak when a response is submitted
CREATE OR REPLACE FUNCTION update_couple_streak()
RETURNS TRIGGER
@@ -150,14 +145,12 @@ BEGIN
RETURN NEW;
END;
$$;
$$;
-- Create trigger on responses table
CREATE TRIGGER on_response_update_streak
AFTER INSERT OR UPDATE OF answer ON responses
FOR EACH ROW
EXECUTE FUNCTION update_couple_streak();
EXECUTE FUNCTION update_couple_streak();
-- Function to reset daily flags (called by cron at UTC midnight)
CREATE OR REPLACE FUNCTION reset_daily_streak_flags()
RETURNS void
@@ -172,8 +165,7 @@ BEGIN
user2_answered_today = false
WHERE last_active_date < CURRENT_DATE;
END;
$$;
$$;
-- Schedule cron job for daily flag reset (runs at 00:00 UTC)
SELECT cron.schedule(
'reset-daily-streak-flags',
@@ -181,8 +173,7 @@ SELECT cron.schedule(
$$
SELECT reset_daily_streak_flags();
$$
);
);
-- Schedule cron job for checking streak milestones (runs at 00:05 UTC)
SELECT cron.schedule(
'check-streak-milestones',
@@ -194,4 +185,4 @@ SELECT cron.schedule(
body := '{}'::jsonb
)
$$
);
);
@@ -0,0 +1,221 @@
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id uuid DEFAULT NULL::uuid)
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 TO 'public'
AS $function$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_my_unanswered INTEGER;
v_partner_unanswered INTEGER;
v_net_gap INTEGER;
v_user_max_intensity INTEGER;
v_partner_max_intensity INTEGER;
v_effective_max_intensity INTEGER;
v_use_couple_intensity_gate BOOLEAN;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_partner_id, v_partner_gender, v_partner_max_intensity
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Toggle: gate questions by couple-effective intensity
SELECT COALESCE(ac.couple_intensity_gate_enabled, FALSE)
INTO v_use_couple_intensity_gate
FROM app_config ac
LIMIT 1;
v_use_couple_intensity_gate := COALESCE(v_use_couple_intensity_gate, FALSE);
v_effective_max_intensity := CASE
WHEN v_use_couple_intensity_gate AND v_partner_id IS NOT NULL THEN LEAST(v_user_max_intensity, v_partner_max_intensity)
ELSE v_user_max_intensity
END;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
IF v_daily_limit > 0 THEN
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
IF v_gap_threshold > 0 THEN
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep)
AND (
NOT v_use_couple_intensity_gate
OR q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
);
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
IF v_net_gap >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
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;
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
)
)
AND (
q.intensity IS NULL
OR q.intensity <= v_effective_max_intensity
)
ORDER BY
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
) DESC,
q.intensity ASC,
q.id ASC; -- ADDED: Deterministic tiebreaker to ensure both partners see questions in same order
END;
$function$;
@@ -0,0 +1,467 @@
-- Sync migration: Make idempotent for production
-- Extensions
create extension if not exists "hypopg" with schema "extensions";
create extension if not exists "index_advisor" with schema "extensions";
-- Drop policy if exists (will be recreated at the end)
drop policy if exists "Anyone can view questions in visible packs" on "public"."questions";
-- Drop and recreate constraint (idempotent)
alter table "public"."question_packs" drop constraint if exists "question_packs_category_id_fkey";
-- Note: pack_question_stats view is NOT recreated here.
-- The original migration (20260111100000_add_inverse_of_to_questions.sql) creates it correctly.
-- Recreating it causes persistent diff noise due to pg_dump formatting differences.
-- Create tables if not exists
create table if not exists "public"."pack_topics" (
"pack_id" uuid not null,
"topic_id" uuid not null
);
alter table "public"."pack_topics" enable row level security;
create table if not exists "public"."topics" (
"id" uuid not null default gen_random_uuid(),
"name" text not null,
"description" text,
"icon" text,
"sort_order" integer default 0,
"created_at" timestamp with time zone default now()
);
alter table "public"."topics" enable row level security;
-- Add columns if not exists
alter table "public"."admin_users" add column if not exists "permissions" jsonb default '[]'::jsonb;
alter table "public"."admin_users" disable row level security;
alter table "public"."ai_config" add column if not exists "cherry_pick_ensure_intensity_distribution" boolean default true;
alter table "public"."ai_config" add column if not exists "council_selection_mode" text default 'whole_set'::text;
alter table "public"."ai_config" add column if not exists "heuristic_keyword_triggers" text;
alter table "public"."ai_config" add column if not exists "heuristic_min_text_length" integer not null default 12;
alter table "public"."ai_config" add column if not exists "heuristic_record_reason" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_skip_if_no_alnum" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_skip_media_without_text" boolean not null default false;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_keywords" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_use_default_whitelist" boolean not null default true;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist" text;
alter table "public"."ai_config" add column if not exists "heuristic_whitelist_max_length" integer not null default 30;
alter table "public"."ai_config" add column if not exists "heuristics_enabled" boolean not null default false;
-- Create indexes if not exists
CREATE INDEX IF NOT EXISTS idx_pack_topics_pack_id ON public.pack_topics USING btree (pack_id);
CREATE UNIQUE INDEX IF NOT EXISTS pack_topics_pkey ON public.pack_topics USING btree (pack_id, topic_id);
CREATE UNIQUE INDEX IF NOT EXISTS topics_name_key ON public.topics USING btree (name);
CREATE UNIQUE INDEX IF NOT EXISTS topics_pkey ON public.topics USING btree (id);
-- Add constraints if not exists (check existence first)
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pkey" PRIMARY KEY USING INDEX "pack_topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_pkey') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_pkey" PRIMARY KEY USING INDEX "topics_pkey";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_config_council_selection_mode_check') THEN
ALTER TABLE "public"."ai_config" ADD CONSTRAINT "ai_config_council_selection_mode_check"
CHECK ((council_selection_mode = ANY (ARRAY['whole_set'::text, 'cherry_pick'::text])));
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_pack_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_pack_id_fkey"
FOREIGN KEY (pack_id) REFERENCES public.question_packs(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'pack_topics_topic_id_fkey') THEN
ALTER TABLE "public"."pack_topics" ADD CONSTRAINT "pack_topics_topic_id_fkey"
FOREIGN KEY (topic_id) REFERENCES public.topics(id) ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'topics_name_key') THEN
ALTER TABLE "public"."topics" ADD CONSTRAINT "topics_name_key" UNIQUE USING INDEX "topics_name_key";
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'question_packs_category_id_fkey') THEN
ALTER TABLE "public"."question_packs" ADD CONSTRAINT "question_packs_category_id_fkey"
FOREIGN KEY (category_id) REFERENCES public.categories(id) ON DELETE CASCADE;
END IF;
END $$;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.get_profiles_with_auth_info()
RETURNS TABLE(id uuid, name text, email text, avatar_url text, is_premium boolean, couple_id uuid, created_at timestamp with time zone, last_sign_in_at timestamp with time zone, email_confirmed_at timestamp with time zone)
LANGUAGE sql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
SELECT
p.id,
COALESCE(p.name, au.raw_user_meta_data->>'name') as name,
COALESCE(p.email, au.email) as email,
p.avatar_url,
p.is_premium,
p.couple_id,
p.created_at,
au.last_sign_in_at,
au.email_confirmed_at
FROM profiles p
LEFT JOIN auth.users au ON au.id = p.id
ORDER BY p.created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_media_files(user_id uuid)
RETURNS TABLE(id uuid, name text, bucket_id text, created_at timestamp with time zone, metadata jsonb)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
id,
name,
bucket_id,
created_at,
metadata
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner = user_id
ORDER BY created_at DESC;
$function$
;
CREATE OR REPLACE FUNCTION public.get_user_storage_usage()
RETURNS TABLE(owner uuid, total_bytes bigint)
LANGUAGE sql
SECURITY DEFINER
AS $function$
SELECT
owner,
SUM((metadata->>'size')::bigint) as total_bytes
FROM storage.objects
WHERE bucket_id = 'chat-media'
AND owner IS NOT NULL
GROUP BY owner;
$function$
;
CREATE OR REPLACE FUNCTION public.cleanup_expired_videos()
RETURNS TABLE(deleted_count integer, deleted_paths text[])
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
expired_paths text[];
count int;
BEGIN
SELECT array_agg(media_path)
INTO expired_paths
FROM messages
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL;
WITH updated AS (
UPDATE messages
SET media_expired = true, media_path = NULL
WHERE media_type = 'video'
AND media_expires_at IS NOT NULL
AND media_expires_at < NOW()
AND media_expired = false
AND media_path IS NOT NULL
RETURNING 1
)
SELECT count(*) INTO count FROM updated;
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.get_pack_unique_question_count(pack_uuid uuid)
RETURNS integer
LANGUAGE sql
STABLE
AS $function$
SELECT COUNT(*)::INTEGER
FROM questions
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
INSERT INTO public.profiles (id, created_at, updated_at, onboarding_completed)
VALUES (NEW.id, NOW(), NOW(), false);
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.notify_new_message()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
PERFORM net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.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;
$function$
;
-- pack_question_stats view creation removed - see comment at top of file
grant delete on table "public"."pack_topics" to "anon";
grant insert on table "public"."pack_topics" to "anon";
grant references on table "public"."pack_topics" to "anon";
grant select on table "public"."pack_topics" to "anon";
grant trigger on table "public"."pack_topics" to "anon";
grant truncate on table "public"."pack_topics" to "anon";
grant update on table "public"."pack_topics" to "anon";
grant delete on table "public"."pack_topics" to "authenticated";
grant insert on table "public"."pack_topics" to "authenticated";
grant references on table "public"."pack_topics" to "authenticated";
grant select on table "public"."pack_topics" to "authenticated";
grant trigger on table "public"."pack_topics" to "authenticated";
grant truncate on table "public"."pack_topics" to "authenticated";
grant update on table "public"."pack_topics" to "authenticated";
grant delete on table "public"."pack_topics" to "service_role";
grant insert on table "public"."pack_topics" to "service_role";
grant references on table "public"."pack_topics" to "service_role";
grant select on table "public"."pack_topics" to "service_role";
grant trigger on table "public"."pack_topics" to "service_role";
grant truncate on table "public"."pack_topics" to "service_role";
grant update on table "public"."pack_topics" to "service_role";
grant delete on table "public"."topics" to "anon";
grant insert on table "public"."topics" to "anon";
grant references on table "public"."topics" to "anon";
grant select on table "public"."topics" to "anon";
grant trigger on table "public"."topics" to "anon";
grant truncate on table "public"."topics" to "anon";
grant update on table "public"."topics" to "anon";
grant delete on table "public"."topics" to "authenticated";
grant insert on table "public"."topics" to "authenticated";
grant references on table "public"."topics" to "authenticated";
grant select on table "public"."topics" to "authenticated";
grant trigger on table "public"."topics" to "authenticated";
grant truncate on table "public"."topics" to "authenticated";
grant update on table "public"."topics" to "authenticated";
grant delete on table "public"."topics" to "service_role";
grant insert on table "public"."topics" to "service_role";
grant references on table "public"."topics" to "service_role";
grant select on table "public"."topics" to "service_role";
grant trigger on table "public"."topics" to "service_role";
grant truncate on table "public"."topics" to "service_role";
grant update on table "public"."topics" to "service_role";
-- Policies: drop if exists then create
drop policy if exists "Admins can view couple pack settings" on "public"."couple_packs";
create policy "Admins can view couple pack settings"
on "public"."couple_packs"
as permissive
for select
to public
using ((public.is_super_admin() OR public.has_permission('view_users'::text)));
drop policy if exists "Admins can delete pack_topics" on "public"."pack_topics";
create policy "Admins can delete pack_topics"
on "public"."pack_topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert pack_topics" on "public"."pack_topics";
create policy "Admins can insert pack_topics"
on "public"."pack_topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update pack_topics" on "public"."pack_topics";
create policy "Admins can update pack_topics"
on "public"."pack_topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Pack topics are viewable by authenticated users" on "public"."pack_topics";
create policy "Pack topics are viewable by authenticated users"
on "public"."pack_topics"
as permissive
for select
to authenticated
using (true);
drop policy if exists "Admins can delete topics" on "public"."topics";
create policy "Admins can delete topics"
on "public"."topics"
as permissive
for delete
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can insert topics" on "public"."topics";
create policy "Admins can insert topics"
on "public"."topics"
as permissive
for insert
to authenticated
with check ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Admins can update topics" on "public"."topics";
create policy "Admins can update topics"
on "public"."topics"
as permissive
for update
to authenticated
using ((EXISTS ( SELECT 1
FROM public.admin_users
WHERE (admin_users.user_id = auth.uid()))));
drop policy if exists "Topics are viewable by authenticated users" on "public"."topics";
create policy "Topics are viewable by authenticated users"
on "public"."topics"
as permissive
for select
to authenticated
using (true);
-- This policy was dropped at the start, recreate it
create policy "Anyone can view questions in visible packs"
on "public"."questions"
as permissive
for select
to public
using (((deleted_at IS NULL) AND (pack_id IN ( SELECT question_packs.id
FROM public.question_packs
WHERE (((question_packs.is_public = true) OR ((question_packs.is_premium = true) AND (EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.is_premium = true)))))) AND ((question_packs.category_id IS NULL) OR (EXISTS ( SELECT 1
FROM public.categories c
WHERE ((c.id = question_packs.category_id) AND (c.is_public = true))))))))));
-- Storage policies: drop if exists then create
drop policy if exists "Anyone can view avatars" on "storage"."objects";
create policy "Anyone can view avatars"
on "storage"."objects"
as permissive
for select
to public
using ((bucket_id = 'avatars'::text));
drop policy if exists "Users can delete own avatar" on "storage"."objects";
create policy "Users can delete own avatar"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can update own avatar" on "storage"."objects";
create policy "Users can update own avatar"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));
drop policy if exists "Users can upload own avatar" on "storage"."objects";
create policy "Users can upload own avatar"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])));