mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
Align prod schema and deploy tooling
This commit is contained in:
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
|
||||
)
|
||||
$$
|
||||
);
|
||||
);
|
||||
File renamed without changes.
File renamed without changes.
+221
@@ -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])));
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user