diff --git a/apps/admin/package.json b/apps/admin/package.json index 843a55a..46aaf15 100755 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,7 +25,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "@sauci/shared": "workspace:*", + "@sauci/shared": "file:../../packages/shared", "@supabase/supabase-js": "^2.39.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/apps/mobile/app.json b/apps/mobile/app.json index db2e1d4..b3ce8be 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Sauci", "slug": "sauci", - "version": "1.0.0", + "version": "1.0.1", "orientation": "portrait", "icon": "./assets/icon.png", "scheme": "app.sauci", @@ -16,7 +16,7 @@ "ios": { "supportsTablet": false, "bundleIdentifier": "com.sauci.app", - "buildNumber": "25", + "buildNumber": "30", "usesAppleSignIn": true, "infoPlist": { "UIBackgroundModes": [ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 68bf88e..050c5e1 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -5,13 +5,16 @@ }, "build": { "development": { + "node": "20.10.0", "developmentClient": true, "distribution": "internal" }, "preview": { + "node": "20.10.0", "distribution": "internal" }, "production": { + "node": "20.10.0", "autoIncrement": true, "env": { "EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co", diff --git a/apps/mobile/ios/Sauci/Info.plist b/apps/mobile/ios/Sauci/Info.plist index da1450b..bdcbb99 100644 --- a/apps/mobile/ios/Sauci/Info.plist +++ b/apps/mobile/ios/Sauci/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.0 + 1.0.1 CFBundleSignature ???? CFBundleURLTypes @@ -47,7 +47,7 @@ CFBundleVersion - 25 + 30 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/apps/mobile/release-notes.md b/apps/mobile/release-notes.md new file mode 100644 index 0000000..7b8e489 --- /dev/null +++ b/apps/mobile/release-notes.md @@ -0,0 +1,36 @@ +# Sauci Mobile Release Notes + +## v1.0.1 (Build 30) - January 2025 + +### New Features +- **Match Archiving** - Archive old matches to keep your active list focused +- **Daily Swipe Limits** - Pace your discovery with daily question limits +- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale) +- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll +- **Chat Date Separators** - Easily see when conversations happened +- **Lifecycle Notifications** - Get reminded to check in with your partner +- **Message Reporting** - Report inappropriate messages for review +- **Account Deletion** - Full account deletion option in settings +- **Reset Progress** - Start fresh by resetting your question progress + +### Improvements +- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements +- **Improved Chat Layout** - Match card moved into message list for cleaner design +- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space +- **Profile Settings** - New appearance and notification preferences +- **Premium Design** - Updated subscription card with gold styling +- **Version Display** - App version now visible in settings + +### Bug Fixes +- Fixed subscription status incorrectly showing "Via partner's" after direct purchase +- Fixed Android navigation issues +- Various performance improvements and stability fixes + +### Privacy +- Removed partner answered indicator for enhanced privacy + +--- + +## v1.0.0 (Build 25) - January 8, 2025 + +Initial release. diff --git a/apps/mobile/src/components/MatchNotificationModal.tsx b/apps/mobile/src/components/MatchNotificationModal.tsx index 15e7182..fabd031 100644 --- a/apps/mobile/src/components/MatchNotificationModal.tsx +++ b/apps/mobile/src/components/MatchNotificationModal.tsx @@ -14,6 +14,7 @@ import { StyleSheet, Pressable, Animated, + Keyboard, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { BlurView } from 'expo-blur'; @@ -106,6 +107,9 @@ export const MatchNotificationModal: React.FC = ({ useEffect(() => { if (visible) { + // Dismiss keyboard so it doesn't block the modal buttons + Keyboard.dismiss(); + // Reset animations modalScale.setValue(0.8); modalOpacity.setValue(0); diff --git a/apps/mobile/src/features/chat/components/ChatMessages.tsx b/apps/mobile/src/features/chat/components/ChatMessages.tsx index dab027a..4570223 100644 --- a/apps/mobile/src/features/chat/components/ChatMessages.tsx +++ b/apps/mobile/src/features/chat/components/ChatMessages.tsx @@ -139,6 +139,7 @@ const ChatMessagesComponent: React.FC = ({ style={styles.messageList} contentContainerStyle={styles.listContent} keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" showsVerticalScrollIndicator={false} // Performance optimizations removeClippedSubviews={true} diff --git a/apps/supabase/migrations/20260111000000_fix_answer_gap_deadlock.sql b/apps/supabase/migrations/20260111000000_fix_answer_gap_deadlock.sql new file mode 100644 index 0000000..2d4e29c --- /dev/null +++ b/apps/supabase/migrations/20260111000000_fix_answer_gap_deadlock.sql @@ -0,0 +1,341 @@ +-- Fix answer gap deadlock by calculating NET gap instead of independent gaps +-- Also add ORDER BY to get_recommended_questions to prioritize partner-answered questions + +-- ============================================================================ +-- Part 1: Fix get_answer_gap_status() to calculate NET gap +-- ============================================================================ +-- +-- Problem: Both partners can be "ahead" simultaneously when they answer different questions +-- Example: User A answers Q1-Q10, User B answers Q11-Q20 +-- - Old: Both have gap=10, both blocked (deadlock!) +-- - New: Both have net_gap=0, neither blocked +-- +-- NET gap = (questions I answered that partner hasn't) - (questions partner answered that I haven't) +-- Only positive values mean user is truly ahead + +CREATE OR REPLACE FUNCTION get_answer_gap_status() +RETURNS TABLE( + unanswered_by_partner integer, + threshold integer, + is_blocked boolean +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; + v_partner_id UUID; + v_my_unanswered integer; + v_partner_unanswered integer; + v_net_gap integer; + v_threshold integer; +BEGIN + v_user_id := auth.uid(); + + -- Get user's couple_id + SELECT p.couple_id INTO v_couple_id + FROM profiles p + WHERE p.id = v_user_id; + + -- No couple = no blocking needed + IF v_couple_id IS NULL THEN + RETURN QUERY SELECT 0, 0, false; + RETURN; + END IF; + + -- Get partner's ID + SELECT p.id INTO v_partner_id + FROM profiles p + WHERE p.couple_id = v_couple_id AND p.id != v_user_id + LIMIT 1; + + -- No partner = no blocking needed + IF v_partner_id IS NULL THEN + RETURN QUERY SELECT 0, 0, false; + RETURN; + END IF; + + -- Get threshold from app_config + SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_threshold + FROM app_config ac + LIMIT 1; + + -- If threshold is 0, feature is disabled + IF v_threshold = 0 THEN + RETURN QUERY SELECT 0, 0, false; + RETURN; + END IF; + + -- Calculate NET gap to prevent deadlocks + WITH enabled_packs AS ( + -- Get packs that the couple has explicitly enabled + SELECT cp.pack_id + FROM couple_packs cp + WHERE cp.couple_id = v_couple_id AND cp.enabled = true + ) + SELECT + -- Questions I answered that partner hasn't + 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, + -- Questions partner answered that I haven't + 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 pack_id FROM enabled_packs); + + -- Net gap: only positive values mean user is truly ahead + -- If partner has answered more, user's net gap is 0 (they're behind or even) + v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered); + + -- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility) + RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold); +END; +$$; + +COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.'; + +-- ============================================================================ +-- Part 2: Update get_recommended_questions() to prioritize partner-answered questions +-- ============================================================================ +-- +-- Add ORDER BY to ensure: +-- 1. Questions partner has already answered come first (helps resolve gaps) +-- 2. Lower intensity questions shown first within each group + +CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) +RETURNS TABLE( + id UUID, + text TEXT, + partner_text TEXT, + is_two_part BOOLEAN, + pack_id UUID, + intensity INTEGER, + partner_answered BOOLEAN, + allowed_couple_genders TEXT[], + target_user_genders TEXT[] +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; + v_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_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 INTO v_partner_id, v_partner_gender + FROM public.profiles p + WHERE p.couple_id = v_couple_id AND p.id != v_user_id + LIMIT 1; + + -- 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 + -- Get daily limit from app_config + SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit + FROM app_config ac + LIMIT 1; + + -- If daily limit > 0, check if user has reached it + IF v_daily_limit > 0 THEN + -- Calculate UTC day boundaries + v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_tomorrow_start := v_today_start + INTERVAL '1 day'; + + -- Count responses created today (UTC) + 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 over daily limit, return empty (old apps see "All caught up!") + 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 + -- Get threshold from app_config + SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold + FROM app_config ac + LIMIT 1; + + -- If threshold > 0, check the NET gap + IF v_gap_threshold > 0 THEN + -- Calculate NET gap to prevent deadlocks + WITH enabled_packs AS ( + SELECT cp.pack_id + FROM couple_packs cp + WHERE cp.couple_id = v_couple_id AND cp.enabled = true + ) + SELECT + -- Questions I answered that partner hasn't + 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, + -- Questions partner answered that I haven't + 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); + + -- Net gap: only positive values mean user is truly ahead + v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered); + + -- If over threshold, return empty (old apps see "All caught up!") + 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 + ) + ) + -- Filter by user's max_intensity preference + AND ( + q.intensity IS NULL + OR q.intensity <= v_user_max_intensity + ) + -- ORDER BY: Partner-answered questions first to help resolve gaps, then by 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; +END; +$$; + +COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.'; diff --git a/apps/supabase/migrations/20260111000001_randomize_question_order.sql b/apps/supabase/migrations/20260111000001_randomize_question_order.sql new file mode 100644 index 0000000..cc50ba0 --- /dev/null +++ b/apps/supabase/migrations/20260111000001_randomize_question_order.sql @@ -0,0 +1,228 @@ +-- Randomize question order for variety +-- Changes ORDER BY from intensity ASC to RANDOM() +-- Keeps partner-answered priority for gap resolution + +CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) +RETURNS TABLE( + id UUID, + text TEXT, + partner_text TEXT, + is_two_part BOOLEAN, + pack_id UUID, + intensity INTEGER, + partner_answered BOOLEAN, + allowed_couple_genders TEXT[], + target_user_genders TEXT[] +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; + v_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_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 INTO v_partner_id, v_partner_gender + FROM public.profiles p + WHERE p.couple_id = v_couple_id AND p.id != v_user_id + LIMIT 1; + + -- 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 + -- Get daily limit from app_config + SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit + FROM app_config ac + LIMIT 1; + + -- If daily limit > 0, check if user has reached it + IF v_daily_limit > 0 THEN + -- Calculate UTC day boundaries + v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'; + v_tomorrow_start := v_today_start + INTERVAL '1 day'; + + -- Count responses created today (UTC) + 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 over daily limit, return empty (old apps see "All caught up!") + 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 + -- Get threshold from app_config + SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold + FROM app_config ac + LIMIT 1; + + -- If threshold > 0, check the NET gap + IF v_gap_threshold > 0 THEN + -- Calculate NET gap to prevent deadlocks + WITH enabled_packs AS ( + SELECT cp.pack_id + FROM couple_packs cp + WHERE cp.couple_id = v_couple_id AND cp.enabled = true + ) + SELECT + -- Questions I answered that partner hasn't + 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, + -- Questions partner answered that I haven't + 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); + + -- Net gap: only positive values mean user is truly ahead + v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered); + + -- If over threshold, return empty (old apps see "All caught up!") + 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 + ) + ) + -- Filter by user's max_intensity preference + AND ( + q.intensity IS NULL + OR q.intensity <= v_user_max_intensity + ) + -- ORDER BY: Partner-answered questions first, then RANDOM for variety + 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, + RANDOM(); +END; +$$; + +COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.';