From e671ae9d1c8e86f3e54665284881f2ccec4aac51 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sun, 11 Jan 2026 19:03:34 +0000 Subject: [PATCH] Add admin user details, pack notifications, remove crypto - Add enabled packs and feature interests to admin user page - Add clickable links from responses to packs in admin - Add pack change notification edge function - Remove react-native-quick-crypto (unused E2EE) - Bump mobile version to 1.0.1 (versionCode 22) - Add EAS config and preview build scripts - Gitignore keystore and APK files Co-Authored-By: Claude Opus 4.5 --- .../src/pages/activity/UserActivityPage.tsx | 56 ++++-- apps/admin/src/pages/users/UserDetailPage.tsx | 162 ++++++++++++++++- apps/mobile/.gitignore | 5 +- apps/mobile/android/app/build.gradle | 4 +- apps/mobile/android/app/google-services.json | 8 + apps/mobile/app.json | 2 +- apps/mobile/app/_layout.tsx | 1 - apps/mobile/google-services.json | 8 + apps/mobile/metro.config.js | 4 +- apps/mobile/package.json | 5 +- apps/mobile/src/polyfills/crypto.ts | 32 ---- .../send-pack-change-notification/index.ts | 142 +++++++++++++++ ...> 20260109000001_create_dares_feature.sql} | 0 ...260110000003_add_daily_response_limit.sql} | 0 ...0260111000002_fix_answer_gap_deadlock.sql} | 0 ...12100000_add_pack_change_notifications.sql | 101 +++++++++++ eas.json | 21 +++ package-lock.json | 164 +++++------------- 18 files changed, 527 insertions(+), 188 deletions(-) delete mode 100644 apps/mobile/src/polyfills/crypto.ts create mode 100644 apps/supabase/functions/send-pack-change-notification/index.ts rename apps/supabase/migrations/{20260109000000_create_dares_feature.sql => 20260109000001_create_dares_feature.sql} (100%) rename apps/supabase/migrations/{20260110000000_add_daily_response_limit.sql => 20260110000003_add_daily_response_limit.sql} (100%) rename apps/supabase/migrations/{20260111000000_fix_answer_gap_deadlock.sql => 20260111000002_fix_answer_gap_deadlock.sql} (100%) create mode 100644 apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql create mode 100644 eas.json diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx index eaa0869..eaabed3 100644 --- a/apps/admin/src/pages/activity/UserActivityPage.tsx +++ b/apps/admin/src/pages/activity/UserActivityPage.tsx @@ -49,6 +49,7 @@ interface ResponseActivity { id: string; text: string; pack: { + id: string; name: string; } | null; } | null; @@ -155,7 +156,7 @@ export function UserActivityPage() { profile:profiles!responses_user_id_fkey(id, name, email), question:questions( id, text, - pack:question_packs(name) + pack:question_packs(id, name) ) `, { count: 'exact' }) .order('created_at', { ascending: false }) @@ -317,18 +318,22 @@ export function UserActivityPage() { const fetchSignups = useCallback(async () => { setLoading(prev => ({ ...prev, signups: true })); try { - const from = (signupsPage - 1) * signupsPageSize; - const to = from + signupsPageSize - 1; - - const { data, error, count } = await supabase - .from('profiles') - .select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' }) - .order('created_at', { ascending: false }) - .range(from, to); + // Use RPC to get profiles with auth info (includes email from auth.users) + const { data: allData, error } = await supabase + .rpc('get_profiles_with_auth_info'); if (error) throw error; - setSignups(data || []); - setSignupsTotal(count || 0); + + // Sort by created_at descending and apply pagination + const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + const from = (signupsPage - 1) * signupsPageSize; + const to = from + signupsPageSize; + + setSignups(sorted.slice(from, to)); + setSignupsTotal(sorted.length); } catch (error) { console.error('Failed to load signups:', error); toast.error("Failed to load signups"); @@ -502,14 +507,31 @@ export function UserActivityPage() { {renderUserLink(item.profile)} - - {item.question?.text || 'Unknown question'} - + {item.question?.pack?.id ? ( + + {item.question?.text || 'Unknown question'} + + ) : ( + + {item.question?.text || 'Unknown question'} + + )} - - {item.question?.pack?.name || 'Unknown'} - + {item.question?.pack?.id ? ( + + + {item.question.pack.name} + + + ) : ( + + {item.question?.pack?.name || 'Unknown'} + + )} {renderAnswerBadge(item.answer)} diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index a9ff75d..9659d64 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -38,14 +38,17 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react'; import { format } from 'date-fns'; +import { IconPreview } from '@/components/ui/icon-picker'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; // ... (interfaces and constants are the same) +type IntensityLevel = 1 | 2 | 3 | 4 | 5; + interface Profile { id: string; name: string | null; @@ -58,6 +61,7 @@ interface Profile { usage_reason: UsageReason | null; show_explicit_content: boolean | null; onboarding_completed: boolean | null; + max_intensity: IntensityLevel | null; } const usageReasonLabels: Record = { @@ -75,6 +79,14 @@ const genderLabels: Record = { 'prefer-not-to-say': 'Prefer not to say', }; +const intensityLabels: Record = { + 1: { label: 'Gentle', emoji: '💭' }, + 2: { label: 'Warm', emoji: '💕' }, + 3: { label: 'Playful', emoji: '😏' }, + 4: { label: 'Steamy', emoji: '🔥' }, + 5: { label: 'Intense', emoji: '🌶️' }, +}; + interface Partner { id: string; name: string | null; @@ -88,6 +100,7 @@ interface Response { id: string; text: string; pack: { + id: string; name: string; }; }; @@ -105,6 +118,30 @@ interface Match { message_count?: number; } +interface EnabledPack { + pack_id: string; + enabled: boolean; + created_at: string | null; + pack: { + id: string; + name: string; + description: string | null; + icon: string | null; + is_premium: boolean; + is_explicit: boolean; + category: { + id: string; + name: string; + } | null; + }; +} + +interface FeatureInterest { + id: string; + feature_name: string; + created_at: string; +} + const answerIcons = { yes: , no: , @@ -238,6 +275,8 @@ export function UserDetailPage() { const [responses, setResponses] = useState([]); const [matches, setMatches] = useState([]); const [mediaMessages, setMediaMessages] = useState([]); + const [enabledPacks, setEnabledPacks] = useState([]); + const [featureInterests, setFeatureInterests] = useState([]); const [responsesPage, setResponsesPage] = useState(1); const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesTotal, setResponsesTotal] = useState(0); @@ -311,6 +350,14 @@ export function UserDetailPage() { const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single(); setProfile(profileData); + // Fetch feature interests for the user + const { data: featureInterestsData } = await supabase + .from('feature_interests') + .select('id, feature_name, created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + setFeatureInterests((featureInterestsData || []) as FeatureInterest[]); + if (profileData?.couple_id) { const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single(); setPartner(partnerData); @@ -323,7 +370,7 @@ export function UserDetailPage() { const { data: responseData, count: responsesCount, error: responsesError } = await supabase .from('responses') - .select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' }) + .select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' }) .eq('user_id', userId) .order('created_at', { ascending: false }) .range(responsesFrom, responsesTo); @@ -388,11 +435,39 @@ export function UserDetailPage() { setMediaMessages([]); setMediaTotal(0); } + // Fetch enabled packs for the couple + const { data: enabledPacksData, error: packsError } = await supabase + .from('couple_packs') + .select(` + pack_id, + enabled, + created_at, + pack:question_packs( + id, + name, + description, + icon, + is_premium, + is_explicit, + category:categories(id, name) + ) + `) + .eq('couple_id', profileData.couple_id) + .eq('enabled', true) + .order('created_at', { ascending: false }); + + if (packsError) { + console.error("Error fetching enabled packs:", packsError); + setEnabledPacks([]); + } else { + setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]); + } } else { setMatches([]); setMatchesTotal(0); setMediaMessages([]); setMediaTotal(0); + setEnabledPacks([]); } } catch (error) { console.error('Failed to load user data:', error); @@ -456,20 +531,46 @@ export function UserDetailPage() { {/* Onboarding Details */} - Onboarding DetailsInformation collected during user onboarding
+ Onboarding DetailsInformation collected during user onboarding

Gender

{profile.gender ? genderLabels[profile.gender] : 'Not set'}

Why using Sauci

{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}

+

Comfort Zone

{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}

{profile.show_explicit_content ? : }

Explicit Content

{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}

Onboarding

{profile.onboarding_completed ? 'Completed' : 'Not completed'}

+ {/* Feature Opt-Ins */} + {featureInterests.length > 0 && ( + + + + + Feature Opt-Ins + + Features the user has expressed interest in + + +
+ {featureInterests.map((interest) => ( + + {interest.feature_name} + + ({format(new Date(interest.created_at), 'MMM d')}) + + + ))} +
+
+
+ )} {/* Tabs */} {canViewResponses && Responses ({responsesTotal})} {canViewMatches && Matches ({matchesTotal})} {canViewMedia && Media ({mediaTotal})} + Enabled Packs ({enabledPacks.length}) - {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.text}{r.question.pack?.name || '—'}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} + {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} {canViewMatches && {matches.length === 0 ?

No matches yet

:
{matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats && }
))}
{ setMatchesPage(1); setMatchesPageSize(size); }} />
}
} {canViewMedia && {mediaMessages.length === 0 ?

No media uploaded

:
{mediaMessages.map((message) => (
{message.media_type === 'video' ? ( @@ -478,6 +579,59 @@ export function UserDetailPage() { )}
{message.media_type === 'video' ? 'Video' : 'Image'}
{canViewChats && message.match_id && View Chat}
{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}
))}
{ setMediaPage(1); setMediaPageSize(size); }} />
}
} + + {enabledPacks.length === 0 ? ( + + +

No packs enabled

+
+ ) : ( +
+ {enabledPacks.map((ep) => ( + + + +
+
+ + {ep.pack.name} +
+
+ {ep.pack.is_premium && ( + + + Premium + + )} + {ep.pack.is_explicit && ( + + 18+ + + )} +
+
+ {ep.pack.description && ( + + {ep.pack.description} + + )} +
+ +
+ + {ep.pack.category?.name || 'Uncategorized'} + + + Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'} + +
+
+
+ + ))} +
+ )} +
); diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index 05bdf7c..c43dbc6 100755 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -5,4 +5,7 @@ expo-env.d.ts # @end expo-cli *.aab -*.ipa \ No newline at end of file +*.ipa +*.apk +*.jks +*.keystore \ No newline at end of file diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 11e7dae..52f5763 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -93,8 +93,8 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 21 - versionName "1.0.0" + versionCode 22 + versionName "1.0.1" } signingConfigs { debug { diff --git a/apps/mobile/android/app/google-services.json b/apps/mobile/android/app/google-services.json index 9bd1e03..ac495da 100644 --- a/apps/mobile/android/app/google-services.json +++ b/apps/mobile/android/app/google-services.json @@ -13,6 +13,14 @@ } }, "oauth_client": [ + { + "client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.sauci.app", + "certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459" + } + }, { "client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com", "client_type": 1, diff --git a/apps/mobile/app.json b/apps/mobile/app.json index b3ce8be..a8f75ba 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -176,7 +176,7 @@ "backgroundColor": "#1a1a2e" }, "package": "com.sauci.app", - "versionCode": 21, + "versionCode": 22, "googleServicesFile": "./google-services.json", "permissions": [ "RECEIVE_BOOT_COMPLETED", diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 5dfc605..fcc86cc 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,6 +1,5 @@ // Polyfills must be imported FIRST before any other React Native imports import "../src/polyfills/web"; -import "../src/polyfills/crypto"; // Initialize Sentry early to capture all errors import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry"; diff --git a/apps/mobile/google-services.json b/apps/mobile/google-services.json index 9bd1e03..ac495da 100644 --- a/apps/mobile/google-services.json +++ b/apps/mobile/google-services.json @@ -13,6 +13,14 @@ } }, "oauth_client": [ + { + "client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.sauci.app", + "certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459" + } + }, { "client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com", "client_type": 1, diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 2fac1a5..91a8a35 100755 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -25,11 +25,9 @@ config.resolver.nodeModulesPaths = [ // of the workspace root, unless they are installed in the project root. config.resolver.disableHierarchicalLookup = true; -// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto -// This enables Web Crypto API for encryption operations +// 4. Keep extraNodeModules in case we need to add monorepo overrides later config.resolver.extraNodeModules = { ...config.resolver.extraNodeModules, - crypto: require.resolve('react-native-quick-crypto'), }; module.exports = config; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f12b99c..240d164 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -14,7 +14,9 @@ "test:watch": "jest --watch", "test:ci": "jest --runInBand --ci", "release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local", - "release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local" + "release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local", + "release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local", + "release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local" }, "dependencies": { "@expo/ngrok": "^4.1.0", @@ -58,7 +60,6 @@ "react-native-compressor": "^1.13.0", "react-native-gesture-handler": "~2.20.0", "react-native-purchases": "^9.6.12", - "react-native-quick-crypto": "^0.7.11", "react-native-reanimated": "~3.16.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", diff --git a/apps/mobile/src/polyfills/crypto.ts b/apps/mobile/src/polyfills/crypto.ts deleted file mode 100644 index 6015753..0000000 --- a/apps/mobile/src/polyfills/crypto.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Crypto polyfill for React Native - * - * This file must be imported BEFORE any code that uses crypto. - * It installs react-native-quick-crypto as the global crypto implementation. - * - * On web, we skip this since the native Web Crypto API is available. - */ - -import { Platform } from 'react-native'; - -if (Platform.OS !== 'web') { - try { - // Import react-native-quick-crypto to install its polyfill - const QuickCrypto = require('react-native-quick-crypto'); - - // Install the polyfill globally if not already present - if (typeof global.crypto === 'undefined') { - (global as any).crypto = QuickCrypto; - } - - // Also ensure subtle is available - if (global.crypto && !global.crypto.subtle) { - (global.crypto as any).subtle = QuickCrypto.subtle; - } - - console.log('[Crypto] react-native-quick-crypto polyfill installed'); - } catch (error) { - console.error('[Crypto] Failed to install crypto polyfill:', error); - console.error('[Crypto] Make sure you have run: npx expo prebuild --clean'); - } -} diff --git a/apps/supabase/functions/send-pack-change-notification/index.ts b/apps/supabase/functions/send-pack-change-notification/index.ts new file mode 100644 index 0000000..48fa44e --- /dev/null +++ b/apps/supabase/functions/send-pack-change-notification/index.ts @@ -0,0 +1,142 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +/** + * Send delayed pack change notifications. + * Called by pg_cron every 5 minutes to check for pending notifications + * where notify_at has passed. + * + * Notifications are sent when a user enables new question packs. The notification + * is delayed by 30 minutes, with the timer resetting on each new pack enable. + * This prevents spam when a user enables multiple packs in quick succession. + */ + +interface ExpoPushMessage { + to: string; + title: string; + body: string; + sound?: string; + data?: Record; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + throw new Error(`Failed to send push notification: ${error}`); + } + + return response.json(); +} + +Deno.serve(async (req) => { + // Only allow POST (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + // Get all pending notifications where notify_at has passed + const { data: pendingNotifications, error: fetchError } = await supabase + .from("pending_pack_notifications") + .select("id, couple_id, changed_by_user_id, notify_at") + .lte("notify_at", new Date().toISOString()); + + if (fetchError) { + console.error("Error fetching pending notifications:", fetchError); + return new Response( + JSON.stringify({ error: fetchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!pendingNotifications || pendingNotifications.length === 0) { + return new Response( + JSON.stringify({ sent: 0, message: "No pending notifications" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + const messages: ExpoPushMessage[] = []; + const processedIds: string[] = []; + + for (const notification of pendingNotifications) { + // Get the partner's push token (not the user who made changes) + const { data: partner, error: partnerError } = await supabase + .from("profiles") + .select("push_token") + .eq("couple_id", notification.couple_id) + .neq("id", notification.changed_by_user_id) + .maybeSingle(); + + if (partnerError) { + console.error( + `Error fetching partner for couple ${notification.couple_id}:`, + partnerError + ); + continue; + } + + if (partner?.push_token) { + messages.push({ + to: partner.push_token, + title: "Question packs updated", + body: "Your partner updated the question packs", + sound: "default", + data: { type: "pack_change" }, + }); + } + + processedIds.push(notification.id); + } + + // Send all notifications + if (messages.length > 0) { + await sendExpoPushNotifications(messages); + } + + // Delete processed notifications + if (processedIds.length > 0) { + const { error: deleteError } = await supabase + .from("pending_pack_notifications") + .delete() + .in("id", processedIds); + + if (deleteError) { + console.error("Error deleting processed notifications:", deleteError); + } + } + + console.log(`Sent ${messages.length} pack change notifications`); + + return new Response( + JSON.stringify({ + sent: messages.length, + processed: processedIds.length, + }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/migrations/20260109000000_create_dares_feature.sql b/apps/supabase/migrations/20260109000001_create_dares_feature.sql similarity index 100% rename from apps/supabase/migrations/20260109000000_create_dares_feature.sql rename to apps/supabase/migrations/20260109000001_create_dares_feature.sql diff --git a/apps/supabase/migrations/20260110000000_add_daily_response_limit.sql b/apps/supabase/migrations/20260110000003_add_daily_response_limit.sql similarity index 100% rename from apps/supabase/migrations/20260110000000_add_daily_response_limit.sql rename to apps/supabase/migrations/20260110000003_add_daily_response_limit.sql diff --git a/apps/supabase/migrations/20260111000000_fix_answer_gap_deadlock.sql b/apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql similarity index 100% rename from apps/supabase/migrations/20260111000000_fix_answer_gap_deadlock.sql rename to apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql diff --git a/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql b/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql new file mode 100644 index 0000000..c62b038 --- /dev/null +++ b/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql @@ -0,0 +1,101 @@ +-- Add delayed pack change notification system +-- Notifies partner when a user enables new question packs (with 30-min debounce) + +-- Create pending notifications table +CREATE TABLE pending_pack_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, + changed_by_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + notify_at TIMESTAMPTZ NOT NULL, + 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_pack_notifications_notify_at + ON pending_pack_notifications(notify_at); + +-- Enable RLS +ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY; + +-- RLS policies +CREATE POLICY "Users can view their couple's pending notifications" + ON pending_pack_notifications FOR SELECT + USING (couple_id = get_auth_user_couple_id()); + +CREATE POLICY "Super admins can view all pending notifications" + ON pending_pack_notifications FOR SELECT + USING (is_super_admin()); + +-- Updated_at trigger +CREATE TRIGGER update_pending_pack_notifications_updated_at + BEFORE UPDATE ON pending_pack_notifications + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Function to queue pack change notifications +CREATE OR REPLACE FUNCTION queue_pack_change_notification() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; +BEGIN + -- Only trigger when enabling a pack (not disabling) + IF NEW.enabled = false THEN + RETURN NEW; + END IF; + + -- For UPDATE, only proceed if changing from disabled to enabled + IF TG_OP = 'UPDATE' AND OLD.enabled = true THEN + RETURN NEW; + END IF; + + -- Get the user making the change + v_user_id := auth.uid(); + + -- Skip if no auth context (service role operations) + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- UPSERT: Insert new notification or reset timer on existing one + INSERT INTO pending_pack_notifications ( + couple_id, + changed_by_user_id, + notify_at + ) + VALUES ( + NEW.couple_id, + v_user_id, + now() + interval '30 minutes' + ) + ON CONFLICT (couple_id) DO UPDATE SET + changed_by_user_id = EXCLUDED.changed_by_user_id, + notify_at = now() + interval '30 minutes', + updated_at = now(); + + RETURN NEW; +END; +$$; + +-- Create trigger on couple_packs +CREATE TRIGGER on_couple_pack_enabled + AFTER INSERT OR UPDATE OF enabled ON couple_packs + FOR EACH ROW + EXECUTE FUNCTION queue_pack_change_notification(); + +-- Schedule cron job (runs every 5 minutes) +SELECT cron.schedule( + 'send-pack-change-notifications', + '*/5 * * * *', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-pack-change-notification', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +); diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..f4001c5 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 16.28.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/package-lock.json b/package-lock.json index f39fef4..b93b7e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,17 +11,17 @@ "apps/supabase", "packages/*" ], - "devDependencies": { - "turbo": "^2.3.3", - "typescript": "^5.3.3" - }, - "optionalDependencies": { - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0" - }, - "engines": { - "node": ">=20" - } + "devDependencies": { + "turbo": "^2.3.3", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0" + } }, "apps/admin": { "name": "@sauci/admin", @@ -42,7 +42,7 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", - "@sauci/shared": "*", + "@sauci/shared": "file:../../packages/shared", "@supabase/supabase-js": "^2.39.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", @@ -114,7 +114,6 @@ "react-native-compressor": "^1.13.0", "react-native-gesture-handler": "~2.20.0", "react-native-purchases": "^9.6.12", - "react-native-quick-crypto": "^0.7.11", "react-native-reanimated": "~3.16.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", @@ -2288,30 +2287,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@craftzdog/react-native-buffer": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@craftzdog/react-native-buffer/-/react-native-buffer-6.1.1.tgz", - "integrity": "sha512-YXJ0Jr4V+Hk2CZXpQw0A0NJeuiW2Rv6rAAutJCZ2k/JG13vLsppUibkJ8exSMxODtH9yJUrLiR96rilG3pFZ4Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "ieee754": "^1.2.1", - "react-native-quick-base64": "^2.2.2" - } - }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -6384,6 +6359,32 @@ "darwin" ] }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@sauci/admin": { "resolved": "apps/admin", "link": true @@ -10364,6 +10365,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -16145,15 +16147,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -16613,36 +16606,6 @@ } } }, - "node_modules/react-native-quick-base64": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz", - "integrity": "sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg==", - "license": "MIT", - "workspaces": [ - "example" - ], - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/react-native-quick-crypto": { - "version": "0.7.17", - "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-0.7.17.tgz", - "integrity": "sha512-cJzp6oA/dM1lujt+Rwtn46Mgcs3w9F/0oQvNz1jcADc/AXktveAOUTzzKrDMxyg6YPziCYnoqMDzHBo6OLSU1g==", - "license": "MIT", - "workspaces": [ - ".", - "example" - ], - "dependencies": { - "@craftzdog/react-native-buffer": "^6.0.5", - "events": "^3.3.0", - "readable-stream": "^4.5.2", - "string_decoder": "^1.3.0", - "util": "^0.12.5" - } - }, "node_modules/react-native-reanimated": { "version": "3.16.7", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz", @@ -17020,46 +16983,6 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/readable-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -18053,15 +17976,6 @@ "node": ">=4" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",