From 09b7646787a5293ad536f9bb7a3476c2b9ddb4c8 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Tue, 30 Dec 2025 17:05:08 +0000 Subject: [PATCH] Update --- CLAUDE.md | 43 ++ apps/mobile/app/(app)/_layout.tsx | 47 +- apps/mobile/app/(app)/onboarding.tsx | 11 + apps/mobile/app/(app)/pairing.tsx | 534 +++++++++++------- apps/mobile/app/(auth)/login.tsx | 39 +- apps/mobile/app/_layout.tsx | 8 +- apps/mobile/app/index.tsx | 5 + .../email-templates/confirmation.html | 257 ++++++--- .../email-templates/email-change.html | 269 +++++---- apps/supabase/email-templates/invite.html | 309 ++++++---- apps/supabase/email-templates/magic-link.html | 263 ++++++--- .../email-templates/password-reset.html | 269 +++++---- .../supabase/functions/manage-couple/index.ts | 91 ++- .../20251230000000_v1_initial_schema.sql | 8 +- 14 files changed, 1421 insertions(+), 732 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a8ccd1..92f794c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,9 +96,52 @@ Edge functions in `apps/supabase/functions/` handle complex operations: - `revenuecat-webhook` - Subscription webhook handler - `sync-subscription` - Syncs RevenueCat subscription status +#### Edge Function Deployment Guidelines +When deploying edge functions via MCP tools (`mcp__sauci-*__deploy_edge_function`): + +**IMPORTANT: Always use `verify_jwt: false`** for all edge functions. Supabase's built-in JWT verification causes 401 errors even with valid tokens. Instead, handle authentication manually in the function code using: +```typescript +const { data: { user }, error: authError } = await supabase.auth.getUser( + authHeader.replace("Bearer ", "") +); +``` + +This approach: +- Gives better error messages +- Allows custom auth handling +- Avoids mysterious 401 failures + +**Current function settings (all should be verify_jwt: false):** +| Function | verify_jwt | Auth Method | +|----------|------------|-------------| +| `manage-couple` | false | Manual via getUser() | +| `submit-response` | false | Manual via getUser() | +| `sync-subscription` | false | Manual via getUser() | +| `delete-relationship` | false | Manual via getUser() | +| `revenuecat-webhook` | false | Webhook signature validation | +| `send-notification` | false | Internal trigger (no auth) | +| `send-message-notification` | false | Internal trigger (no auth) | + +### Database Best Practices +- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned. +- **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking. +- **Check for null profiles** in edge functions before performing operations. + ### Key Patterns - Responses use UPSERT with `onConflict: "user_id,question_id"` - Match detection happens in `submit-response` edge function after each response - Real-time subscriptions used for chat messages - RevenueCat handles in-app purchases and subscription management - RLS policies enforce couple-level data isolation + +## Mobile UI - DO NOT CHANGE + +### Tab Bar Background +**NEVER set `backgroundColor: 'transparent'` on the tab bar.** Always use a semi-transparent solid color: +```typescript +backgroundColor: 'rgba(18, 18, 18, 0.85)', +``` + +The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top. + +**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle` diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 92f8b36..5aa67c1 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -19,13 +19,21 @@ function TabBarBackground() { if (Platform.OS === 'ios') { return ( ); } - return ; + // Android fallback - use experimental blur method + return ( + + ); } interface PlayTabButtonProps { @@ -191,6 +199,37 @@ export default function AppLayout() { } }, [user?.id, user?.onboarding_completed]); + // Subscribe to realtime changes on the current user's profile + // This detects when partner disconnects (sets our couple_id to NULL) + useEffect(() => { + if (!user?.id) return; + + const channel = supabase + .channel(`profile:${user.id}`) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "profiles", + filter: `id=eq.${user.id}`, + }, + async (payload) => { + const newProfile = payload.new as { couple_id: string | null }; + // If couple_id changed (especially to NULL when partner disconnects) + if (newProfile.couple_id !== user.couple_id) { + console.log("Profile couple_id changed, refreshing user data..."); + await useAuthStore.getState().fetchUser(); + } + } + ) + .subscribe(); + + return () => { + supabase.removeChannel(channel); + }; + }, [user?.id, user?.couple_id]); + // Subscribe to realtime match notifications useEffect(() => { if (!user?.couple_id) return; @@ -373,7 +412,7 @@ export default function AppLayout() { tabBarBackground: () => , tabBarStyle: shouldHideTabBar ? { display: 'none' } : { position: 'absolute', - backgroundColor: 'transparent', + backgroundColor: 'rgba(18, 18, 18, 0.85)', borderTopColor: colors.glass.border, borderTopWidth: 1, paddingTop: 8, diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 968aa61..5e6b539 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -94,7 +94,9 @@ export default function OnboardingScreen() { }; const handleComplete = async () => { + console.log('[Onboarding] handleComplete called, user:', user?.id, 'name:', name); if (!user?.id) { + console.error('[Onboarding] No user ID available'); setError('Not logged in. Please restart the app.'); return; } @@ -115,10 +117,13 @@ export default function OnboardingScreen() { .eq('id', user.id); if (updateError) { + console.error('[Onboarding] Profile update error:', updateError); throw updateError; } + console.log('[Onboarding] Profile updated successfully, fetching user...'); await fetchUser(); + console.log('[Onboarding] User fetched, navigating to home...'); router.replace('/'); } catch (err: any) { console.error('Onboarding error:', err); @@ -401,6 +406,12 @@ export default function OnboardingScreen() { + {error && ( + + + {error} + + )} diff --git a/apps/mobile/app/(app)/pairing.tsx b/apps/mobile/app/(app)/pairing.tsx index 1933549..2a47441 100755 --- a/apps/mobile/app/(app)/pairing.tsx +++ b/apps/mobile/app/(app)/pairing.tsx @@ -3,22 +3,26 @@ import { View, Text, TextInput, - TouchableOpacity, StyleSheet, Alert, ActivityIndicator, Share, KeyboardAvoidingView, Platform, + TouchableOpacity, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import Animated, { FadeInDown } from "react-native-reanimated"; import * as Clipboard from "expo-clipboard"; import { useAuthStore } from "../../src/store"; import { supabase } from "../../src/lib/supabase"; import { router } from "expo-router"; +import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui"; +import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme"; export default function PairingScreen() { - const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading, signOut } = useAuthStore(); + const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore(); const [inviteCode, setInviteCode] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -105,13 +109,17 @@ export default function PairingScreen() { body: { invite_code: sanitizedCode }, }); - if (error) throw error; - if (data.error) throw new Error(data.error); + if (error) { + // Extract user-friendly error message + const errorMessage = data?.error || "Unable to join couple. Please try again."; + throw new Error(errorMessage); + } + if (data?.error) throw new Error(data.error); await fetchUser(); // Refresh user to get couple_id await fetchCouple(); // Fetch couple data - Alert.alert("Success", "You are now paired! 💕", [ + Alert.alert("Success", "You are now paired!", [ { text: "Let's Go", onPress: () => router.replace("/(app)/") } ]); } catch (error: any) { @@ -140,16 +148,33 @@ export default function PairingScreen() { } }; - const handleSignOut = () => { + const handleCancelPairing = async () => { Alert.alert( - "Sign Out", - "Are you sure you want to sign out?", + "Cancel Pairing", + "Are you sure you want to cancel? Your invite code will be deleted.", [ - { text: "Cancel", style: "cancel" }, + { text: "Keep Waiting", style: "cancel" }, { - text: "Sign Out", + text: "Cancel Pairing", style: "destructive", - onPress: signOut, + onPress: async () => { + setIsSubmitting(true); + try { + const { data, error } = await supabase.functions.invoke("manage-couple", { + method: "DELETE", + }); + + if (error) throw error; + if (data?.error) throw new Error(data.error); + + await fetchUser(); + await fetchCouple(); + } catch (error: any) { + Alert.alert("Error", error.message); + } finally { + setIsSubmitting(false); + } + }, }, ] ); @@ -157,273 +182,360 @@ export default function PairingScreen() { if (isAuthLoading) { return ( - - - + + + + + ); } // If user has a couple but no partner, they are waiting if (couple && !partner) { return ( - - router.back()} - > - - - - - + + + {/* Header */} + + router.back()} + activeOpacity={0.7} + > + + + Partner Code + + + + {/* Icon */} + + + + + + Share this code with your partner to link your accounts + + + + {/* Code Card */} + + + + {couple.invite_code.toUpperCase()} + + + + + Tap to copy + + + + {/* Share Button */} + + } + > + Share Code + + + + {/* Waiting indicator */} + + + + Waiting for your partner to join... + + + + {/* Cancel Button */} + + + Cancel Pairing + + - Partner Code - - Share this code with your partner to link your accounts - - - - {couple.invite_code.toUpperCase()} - - - - - - Share Code - - - - Waiting for your partner to join... - - - + ); } return ( - - router.back()} + + - - - - 🔗 - Pair Up - - Link with your partner to start matching - - - - Have a code? - - - - {isSubmitting ? "Joining..." : "Join Partner"} - - - - - - - or - - - - - - {isSubmitting ? "Creating..." : "Create New Code"} - - + router.back()} + activeOpacity={0.7} + > + + + Pair Up + - - - Sign Out - - - + + {/* Icon */} + + + + + + Link with your partner to start matching + + + + {/* Join Card */} + + Have a code? + + + + Join Partner + + + + + {/* Divider */} + + + or + + + + {/* Create Code Button */} + + + Create New Code + + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#1a1a2e", + }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + header: { + paddingTop: Platform.OS === "ios" ? 60 : 40, + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + flexDirection: "row", + alignItems: "center", }, backButton: { - position: "absolute", - top: Platform.OS === "ios" ? 60 : 40, - left: 20, - zIndex: 10, width: 44, height: 44, borderRadius: 22, - backgroundColor: "rgba(255, 255, 255, 0.1)", + backgroundColor: colors.glass.background, justifyContent: "center", alignItems: "center", + marginRight: spacing.md, }, - centerContent: { - justifyContent: "center", - alignItems: "center", + title: { + ...typography.title1, + color: colors.text, }, content: { flex: 1, - padding: 24, + paddingHorizontal: spacing.lg, + justifyContent: "center", + }, + iconSection: { + alignItems: "center", + marginBottom: spacing.xl, + }, + iconGradient: { + width: 88, + height: 88, + borderRadius: 44, justifyContent: "center", alignItems: "center", - }, - emoji: { - fontSize: 64, - marginBottom: 16, - }, - title: { - fontSize: 32, - fontWeight: "bold", - color: "#fff", - marginBottom: 8, + marginBottom: spacing.lg, + ...shadows.lg, }, subtitle: { - fontSize: 16, - color: "#888", - marginBottom: 48, + ...typography.body, + color: colors.textSecondary, textAlign: "center", + maxWidth: 280, }, - card: { - width: "100%", - backgroundColor: "#16213e", - borderRadius: 16, - padding: 24, - borderWidth: 1, - borderColor: "#0f3460", + section: { + marginBottom: spacing.md, }, - cardTitle: { - fontSize: 18, - fontWeight: "600", - color: "#fff", - marginBottom: 16, + sectionTitle: { + ...typography.caption1, + color: colors.textTertiary, + textTransform: "uppercase", + letterSpacing: 1.5, + marginBottom: spacing.sm, + marginLeft: spacing.xs, }, input: { - backgroundColor: "#1a1a2e", - borderRadius: 12, - padding: 16, - fontSize: 18, - color: "#fff", - marginBottom: 16, + backgroundColor: colors.glass.background, + borderRadius: radius.md, borderWidth: 1, - borderColor: "#0f3460", + borderColor: colors.glass.border, + padding: spacing.md, + ...typography.headline, + color: colors.text, textAlign: "center", - letterSpacing: 2, + letterSpacing: 4, fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", - }, - button: { - backgroundColor: "#e94560", - borderRadius: 12, - padding: 16, - alignItems: "center", - }, - buttonDisabled: { - opacity: 0.7, - }, - buttonText: { - color: "#fff", - fontSize: 16, - fontWeight: "600", + marginBottom: spacing.md, }, divider: { flexDirection: "row", alignItems: "center", - marginVertical: 32, - width: "100%", + marginVertical: spacing.lg, }, dividerLine: { flex: 1, height: 1, - backgroundColor: "#333", + backgroundColor: colors.glass.border, }, dividerText: { - color: "#666", - paddingHorizontal: 16, - }, - secondaryButton: { - padding: 16, - }, - secondaryButtonText: { - color: "#e94560", - fontSize: 16, - fontWeight: "600", - }, - iconContainer: { - width: 80, - height: 80, - borderRadius: 40, - backgroundColor: "rgba(233, 69, 96, 0.1)", - justifyContent: "center", - alignItems: "center", - marginBottom: 24, + ...typography.subhead, + color: colors.textTertiary, + paddingHorizontal: spacing.md, }, codeContainer: { flexDirection: "row", alignItems: "center", - backgroundColor: "#16213e", - padding: 20, - borderRadius: 16, - marginTop: 32, - marginBottom: 24, - borderWidth: 1, - borderColor: "#e94560", - borderStyle: "dashed", + justifyContent: "center", }, code: { - fontSize: 32, - fontWeight: "bold", - color: "#fff", - letterSpacing: 4, - marginRight: 16, + ...typography.largeTitle, + color: colors.text, + letterSpacing: 6, fontFamily: Platform.OS === "ios" ? "Courier" : "monospace", }, - shareButton: { - flexDirection: "row", - backgroundColor: "#e94560", - paddingHorizontal: 32, - paddingVertical: 16, - borderRadius: 12, + copyIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.glass.background, + justifyContent: "center", alignItems: "center", + marginLeft: spacing.md, + }, + tapToCopy: { + ...typography.caption1, + color: colors.textTertiary, + textAlign: "center", + marginTop: spacing.sm, + }, + waitingSection: { + alignItems: "center", + marginTop: spacing.xl, + }, + waitingBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: colors.glass.background, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.full, + gap: spacing.sm, }, waitingText: { - marginTop: 48, - color: "#666", - fontSize: 14, + ...typography.subhead, + color: colors.textTertiary, }, - signOutButton: { - flexDirection: "row", + cancelSection: { alignItems: "center", - marginTop: 48, - padding: 12, + marginTop: spacing.xl, }, - signOutText: { - color: "#888", - fontSize: 16, - marginLeft: 8, + cancelButton: { + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + cancelButtonText: { + ...typography.subhead, + color: colors.error, }, }); diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index 7f0710e..01420f7 100755 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -9,7 +9,7 @@ import { Platform, ScrollView, } from "react-native"; -import { Redirect } from "expo-router"; +import { Redirect, router } from "expo-router"; import * as Linking from "expo-linking"; import Animated, { FadeIn, @@ -472,14 +472,26 @@ export default function LoginScreen() { /> {authMode === "password" && ( - { clearError(); setPassword(text); }} - secureTextEntry - autoCapitalize="none" - icon={} - /> + <> + { clearError(); setPassword(text); }} + secureTextEntry + autoCapitalize="none" + icon={} + /> + {!isSignUp && ( + router.push("/(auth)/forgot-password")} + > + + Forgot password? + + + )} + )} {authMode === "magic-link" ? ( @@ -673,6 +685,15 @@ const styles = StyleSheet.create({ ...typography.subhead, color: colors.primary, }, + forgotPassword: { + alignSelf: "flex-end", + marginTop: -spacing.xs, + marginBottom: spacing.xs, + }, + forgotPasswordText: { + ...typography.subhead, + color: colors.primary, + }, socialContainer: { marginTop: spacing.md, }, diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index edb401b..39a4b04 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -29,7 +29,7 @@ if (Platform.OS === "web" && typeof window === "undefined") { }; } -import { Stack } from "expo-router"; +import { Stack, router } from "expo-router"; import { StatusBar } from "expo-status-bar"; // GestureHandlerRootView is only needed on native platforms // On web, we use a simple View to avoid findNodeHandle errors @@ -103,6 +103,12 @@ export default function RootLayout() { type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email', }); console.log('[DeepLink] verifyOtp result:', { data: !!data?.session, error: error?.message }); + + // If this is a password recovery flow, navigate to reset password screen + if (type === 'recovery' && data?.session && !error) { + console.log('[DeepLink] Password recovery flow - navigating to reset-password'); + router.replace("/(auth)/reset-password"); + } } else { // Fallback for web or other scenarios console.log('[DeepLink] No auth params found, falling back to getSession'); diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 3bb9ef2..9609dab 100755 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -19,6 +19,11 @@ export default function Index() { return ; } + // If user hasn't completed onboarding, go to onboarding first + if (!user?.onboarding_completed) { + return ; + } + // If user has no couple, go to pairing if (!user?.couple_id) { return ; diff --git a/apps/supabase/email-templates/confirmation.html b/apps/supabase/email-templates/confirmation.html index 49e9b6b..35649a8 100644 --- a/apps/supabase/email-templates/confirmation.html +++ b/apps/supabase/email-templates/confirmation.html @@ -1,9 +1,11 @@ - + + + Confirm Your Email - Sauci + - - + +
+ +
+ + - - + +
+ - - - - + + + + - - - - + + + + + +
+ + + + +
 
+
- -
-

- This link will expire in 24 hours. -

-

- If you didn't create an account, you can safely ignore this email. -

-
+ + + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {{ .ConfirmationURL }} +

+
- - + + + + + + + + + +

+ This link will expire in 24 hours. +

+

+ If you didn't create an account, you can safely ignore this email. +

+ + + + + +   + + + + + + + + diff --git a/apps/supabase/email-templates/email-change.html b/apps/supabase/email-templates/email-change.html index 0141b2c..e2db028 100644 --- a/apps/supabase/email-templates/email-change.html +++ b/apps/supabase/email-templates/email-change.html @@ -1,9 +1,11 @@ - + + + Confirm Email Change - Sauci + - - + +
+ +
+ + - - + + +
+ - - - - + + + + - - - - + + + + + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {{ .ConfirmationURL }} +

+
- -
-

- Didn't request this change? -

-

- If you didn't request an email change, please secure your account by resetting your password immediately. -

-
+ + - - + + + +

+ Didn't request this change? +

+

+ If you didn't request an email change, please secure your account by resetting your password immediately. +

+ + + + + +   + + + + + + + + diff --git a/apps/supabase/email-templates/invite.html b/apps/supabase/email-templates/invite.html index 5616efc..e4c364f 100644 --- a/apps/supabase/email-templates/invite.html +++ b/apps/supabase/email-templates/invite.html @@ -1,9 +1,11 @@ - + + + You're Invited to Sauci + - - + +
+ +
+ + - - + + +
+ - - - - + + + + - - - - + + + + + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {{ .ConfirmationURL }} +

+
- -
-

- This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email. -

-
+ + - - + + + +

+ This invitation link will expire in 7 days. If you didn't expect this invitation, you can safely ignore this email. +

+ + + + + +   + + + + + + + + diff --git a/apps/supabase/email-templates/magic-link.html b/apps/supabase/email-templates/magic-link.html index 8b1a5d2..a943b41 100644 --- a/apps/supabase/email-templates/magic-link.html +++ b/apps/supabase/email-templates/magic-link.html @@ -1,9 +1,11 @@ - + + + Sign In to Sauci + - - + +
+ +
+ + - - + + +
+ - - - - + + + + - - - - + + + + + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {{ .ConfirmationURL }} +

+
- -
-

- If you didn't request this sign in link, you can safely ignore this email. -

-
+ + - - + + + +

+ If you didn't request this sign in link, you can safely ignore this email. +

+ + + + + +   + + + + + + + + diff --git a/apps/supabase/email-templates/password-reset.html b/apps/supabase/email-templates/password-reset.html index 3b7428e..3669c0d 100644 --- a/apps/supabase/email-templates/password-reset.html +++ b/apps/supabase/email-templates/password-reset.html @@ -1,9 +1,11 @@ - + + + Reset Your Password - Sauci + - - + +
+ +
+ + - - + + +
+ - - - - + + + + - - - - + + + + + +
+

+ If the button doesn't work, copy and paste this link into your browser: +

+

+ {{ .ConfirmationURL }} +

+
- -
-

- Didn't request this? -

-

- If you didn't request a password reset, you can safely ignore this email. Your password won't change until you click the link above. -

-
+ + - - + + + +

+ Didn't request this? +

+

+ If you didn't request a password reset, you can safely ignore this email. Your password won't change until you click the link above. +

+ + + + + +   + + + + + + + + diff --git a/apps/supabase/functions/manage-couple/index.ts b/apps/supabase/functions/manage-couple/index.ts index c4e0e34..78ac358 100755 --- a/apps/supabase/functions/manage-couple/index.ts +++ b/apps/supabase/functions/manage-couple/index.ts @@ -47,13 +47,28 @@ Deno.serve(async (req) => { if (method === "POST") { // Check if user is already in a couple - const { data: existingProfile } = await supabase + const { data: existingProfile, error: profileError } = await supabase .from("profiles") .select("couple_id") .eq("id", user.id) - .single(); + .maybeSingle(); - if (existingProfile?.couple_id) { + if (profileError) { + console.error("Profile lookup error:", profileError); + return new Response( + JSON.stringify({ error: "Failed to check profile" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (!existingProfile) { + return new Response( + JSON.stringify({ error: "Profile not found. Please complete signup first." }), + { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (existingProfile.couple_id) { return new Response( JSON.stringify({ error: "You are already in a couple" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } @@ -67,27 +82,28 @@ Deno.serve(async (req) => { const { invite_code }: JoinCoupleBody = body; // Validate invite code format (alphanumeric, 8 chars) - const sanitizedCode = invite_code.trim().toUpperCase(); - if (!/^[A-Z0-9]{8}$/.test(sanitizedCode)) { + const sanitizedCode = invite_code.trim().toLowerCase(); + if (!/^[a-z0-9]{8}$/.test(sanitizedCode)) { return new Response( JSON.stringify({ error: "Invalid invite code format" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } - // Use secure RPC function for invite code verification - const { data: verifyResult, error: verifyError } = await supabase - .rpc("verify_invite_code", { code: sanitizedCode }); + // Find couple by invite code (stored as lowercase in DB) + const { data: couple, error: coupleError } = await supabase + .from("couples") + .select("id") + .eq("invite_code", sanitizedCode) + .maybeSingle(); - if (verifyError || !verifyResult?.valid) { + if (coupleError || !couple) { return new Response( - JSON.stringify({ error: verifyResult?.error || "Invalid invite code" }), + JSON.stringify({ error: "Invalid invite code" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } - const couple = { id: verifyResult.couple_id }; - // Check if couple already has 2 members const { count } = await supabase .from("profiles") @@ -151,21 +167,62 @@ Deno.serve(async (req) => { } if (method === "DELETE") { - // Leave couple - const { error } = await supabase + // Get user's current couple + const { data: profile, error: profileFetchError } = await supabase + .from("profiles") + .select("couple_id") + .eq("id", user.id) + .maybeSingle(); + + if (profileFetchError || !profile?.couple_id) { + return new Response( + JSON.stringify({ error: "You are not in a couple" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const coupleId = profile.couple_id; + + // Count members in this couple + const { count, error: countError } = await supabase + .from("profiles") + .select("*", { count: "exact", head: true }) + .eq("couple_id", coupleId); + + if (countError) { + return new Response( + JSON.stringify({ error: "Failed to check couple members" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Remove ALL members from couple (both partners get unlinked) + // This ensures the remaining partner isn't left in a "waiting" state + const { error: updateError } = await supabase .from("profiles") .update({ couple_id: null }) - .eq("id", user.id); + .eq("couple_id", coupleId); - if (error) { + if (updateError) { return new Response( JSON.stringify({ error: "Failed to leave couple" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } + // Delete the couple entirely (cascades to all related data) + const { error: deleteError } = await supabase + .from("couples") + .delete() + .eq("id", coupleId); + + if (deleteError) { + console.error("Failed to delete couple:", deleteError); + // Don't fail the request, users already unlinked + } + return new Response( - JSON.stringify({ success: true }), + JSON.stringify({ success: true, couple_deleted: true }), { headers: { ...corsHeaders, "Content-Type": "application/json" } } ); } diff --git a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql index c67088c..bd42647 100644 --- a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql +++ b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql @@ -673,10 +673,10 @@ CREATE TRIGGER on_new_message AFTER INSERT ON messages FOR EACH ROW EXECUTE FUNCTION notify_new_message(); --- Auth trigger (in auth schema - handled separately by Supabase) --- CREATE TRIGGER on_auth_user_created --- AFTER INSERT ON auth.users --- FOR EACH ROW EXECUTE FUNCTION handle_new_user(); +-- Auth trigger to create profile on signup +CREATE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION handle_new_user(); -- ============================================================================ -- ENABLE ROW LEVEL SECURITY