From 693ae59321a40e3caa14c739e8b7bb5a3957f701 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Wed, 7 Jan 2026 13:24:30 +0000 Subject: [PATCH] Add message reporting and account deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add user message reporting via long press menu - Add delete-account edge function for App Store compliance - Update admin moderation page with AI/User tabs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 3 + apps/admin/src/pages/FlaggedMessagesPage.tsx | 629 ++++++++++++++---- apps/mobile/app.json | 141 ++++ apps/mobile/src/features/chat/ChatScreen.tsx | 61 +- .../chat/components/ReportMessageSheet.tsx | 297 +++++++++ .../src/features/chat/components/index.ts | 1 + .../features/chat/hooks/useMessageActions.ts | 106 +-- apps/mobile/src/features/chat/types.ts | 10 + .../src/features/profile/ProfileScreen.tsx | 9 +- .../profile/components/DangerZone.tsx | 235 ++++++- .../profile/hooks/useCoupleManagement.ts | 32 + apps/mobile/src/lib/analytics.ts | 1 + .../functions/delete-account/index.ts | 204 ++++++ .../20260107000000_add_message_reports.sql | 65 ++ 14 files changed, 1583 insertions(+), 211 deletions(-) create mode 100644 apps/mobile/src/features/chat/components/ReportMessageSheet.tsx create mode 100644 apps/supabase/functions/delete-account/index.ts create mode 100644 apps/supabase/migrations/20260107000000_add_message_reports.sql diff --git a/CLAUDE.md b/CLAUDE.md index 6c0c883..95442db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,8 @@ When using Claude Code with MCP tools: Edge functions in `apps/supabase/functions/` handle complex operations: - `submit-response` - Saves response, checks for matches, triggers notifications - `manage-couple` - Join/leave couple operations +- `delete-relationship` - Deletes couple data (matches, messages, media) while keeping accounts +- `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement) - `send-notification` - Push notifications for matches - `revenuecat-webhook` - Subscription webhook handler - `sync-subscription` - Syncs RevenueCat subscription status @@ -127,6 +129,7 @@ This approach: | `submit-response` | false | Manual via getUser() | | `sync-subscription` | false | Manual via getUser() | | `delete-relationship` | false | Manual via getUser() | +| `delete-account` | false | Manual via getUser() | | `redeem-code` | false | Manual via getUser() | | `revenuecat-webhook` | false | Webhook signature validation | | `send-notification` | false | Internal trigger (no auth) | diff --git a/apps/admin/src/pages/FlaggedMessagesPage.tsx b/apps/admin/src/pages/FlaggedMessagesPage.tsx index 4706382..6d39cb2 100644 --- a/apps/admin/src/pages/FlaggedMessagesPage.tsx +++ b/apps/admin/src/pages/FlaggedMessagesPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, TableBody, @@ -13,7 +14,7 @@ import { TableRow, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles } from 'lucide-react'; +import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles, Flag, AlertTriangle, XCircle } from 'lucide-react'; import { toast } from 'sonner'; import { format } from 'date-fns'; import { describeImage } from '@/lib/ai/analyzers/media'; @@ -30,21 +31,59 @@ interface FlaggedMessage { media_type?: string; } +interface UserReport { + id: string; + message_id: string; + reporter_id: string; + reason: string; + status: 'pending' | 'reviewed' | 'dismissed'; + created_at: string; + message: { + id: string; + user_id: string; + match_id: string | null; + content: string | null; + media_path: string | null; + media_type: string | null; + } | null; + reporter_profile: { + name: string | null; + email: string | null; + } | null; +} + +const REASON_LABELS: Record = { + harassment: 'Harassment', + spam: 'Spam', + inappropriate_content: 'Inappropriate Content', + other: 'Other', +}; + export function FlaggedMessagesPage() { const navigate = useNavigate(); - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); + + // AI Flagged state + const [aiMessages, setAiMessages] = useState([]); + const [aiLoading, setAiLoading] = useState(true); + + // User Reports state + const [userReports, setUserReports] = useState([]); + const [reportsLoading, setReportsLoading] = useState(true); + + // Shared state const [selectedMessage, setSelectedMessage] = useState(null); + const [selectedReport, setSelectedReport] = useState(null); const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null }); const [decrypting, setDecrypting] = useState(false); - const [processing, setProcessing] = useState(null); // messageId being processed + const [processing, setProcessing] = useState(null); const [imageDescription, setImageDescription] = useState(null); const [describing, setDescribing] = useState(false); const [inlineDescriptions, setInlineDescriptions] = useState>({}); const [describingInline, setDescribingInline] = useState(null); - const fetchMessages = async () => { - setLoading(true); + // Fetch AI flagged messages + const fetchAiMessages = async () => { + setAiLoading(true); try { const { data, error } = await supabase .from('messages') @@ -53,19 +92,49 @@ export function FlaggedMessagesPage() { .order('created_at', { ascending: false }); if (error) throw error; - setMessages(data || []); + setAiMessages(data || []); } catch (error) { console.error('Failed to load flagged messages:', error); toast.error('Failed to load messages'); } finally { - setLoading(false); + setAiLoading(false); } }; - + + // Fetch user reports + const fetchUserReports = async () => { + setReportsLoading(true); + try { + const { data, error } = await supabase + .from('message_reports') + .select(` + id, + message_id, + reporter_id, + reason, + status, + created_at, + message:messages(id, user_id, match_id, content, media_path, media_type), + reporter_profile:profiles!reporter_id(name, email) + `) + .eq('status', 'pending') + .order('created_at', { ascending: false }); + + if (error) throw error; + setUserReports((data || []) as unknown as UserReport[]); + } catch (error) { + console.error('Failed to load user reports:', error); + toast.error('Failed to load reports'); + } finally { + setReportsLoading(false); + } + }; + useEffect(() => { - fetchMessages(); + fetchAiMessages(); + fetchUserReports(); }, []); - + // Custom media fetcher because supabase.functions.invoke is JSON-centric const fetchDecryptedMedia = async (messageId: string) => { try { @@ -82,7 +151,7 @@ export function FlaggedMessagesPage() { }); if (!response.ok) throw new Error('Failed to fetch media'); - + const blob = await response.blob(); return URL.createObjectURL(blob); } catch (e) { @@ -90,27 +159,61 @@ export function FlaggedMessagesPage() { return null; } }; - - const handleView = async (message: FlaggedMessage) => { + + const handleViewAiMessage = async (message: FlaggedMessage) => { setSelectedMessage(message); + setSelectedReport(null); setDecrypting(true); setDecryptedContent({ text: null, mediaUrl: null }); setImageDescription(null); - + try { - // Text const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', { body: { messageId: message.id } }); - + let mediaUrl = null; if (message.media_path) { mediaUrl = await fetchDecryptedMedia(message.id); } - - setDecryptedContent({ - text: textData?.content || (textError ? 'Error decrypting text' : null), - mediaUrl + + setDecryptedContent({ + text: textData?.content || (textError ? 'Error decrypting text' : null), + mediaUrl + }); + + } catch (e) { + toast.error("Error loading content"); + } finally { + setDecrypting(false); + } + }; + + const handleViewReport = async (report: UserReport) => { + if (!report.message) { + toast.error('Message no longer exists'); + return; + } + + setSelectedReport(report); + setSelectedMessage(null); + setDecrypting(true); + setDecryptedContent({ text: null, mediaUrl: null }); + setImageDescription(null); + + try { + const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', { + body: { messageId: report.message.id } + }); + + let mediaUrl = null; + if (report.message.media_path) { + mediaUrl = await fetchDecryptedMedia(report.message.id); + } + + setDecryptedContent({ + text: textData?.content || (textError ? 'Error decrypting text' : null), + mediaUrl }); } catch (e) { @@ -140,7 +243,6 @@ export function FlaggedMessagesPage() { setDescribingInline(message.id); try { - // First fetch the decrypted media as blob const { data: { session } } = await supabase.auth.getSession(); if (!session) { toast.error('Not authenticated'); @@ -161,13 +263,11 @@ export function FlaggedMessagesPage() { return; } - // Convert blob to base64 const blob = await response.blob(); const base64 = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const result = reader.result as string; - // Extract base64 part from data URL const base64Data = result.split(',')[1]; resolve(base64Data); }; @@ -175,7 +275,6 @@ export function FlaggedMessagesPage() { reader.readAsDataURL(blob); }); - // Then describe it const description = await describeImage(base64); setInlineDescriptions(prev => ({ ...prev, [message.id]: description })); } catch (error) { @@ -195,9 +294,9 @@ export function FlaggedMessagesPage() { .eq('id', messageId); if (error) throw error; - + toast.success('Message marked as safe'); - setMessages(prev => prev.filter(m => m.id !== messageId)); + setAiMessages(prev => prev.filter(m => m.id !== messageId)); if (selectedMessage?.id === messageId) setSelectedMessage(null); } catch (error) { toast.error('Failed to update status'); @@ -206,132 +305,293 @@ export function FlaggedMessagesPage() { } }; + const handleDismissReport = async (reportId: string) => { + setProcessing(reportId); + try { + const { error } = await supabase + .from('message_reports') + .update({ status: 'dismissed', reviewed_at: new Date().toISOString() }) + .eq('id', reportId); + + if (error) throw error; + + toast.success('Report dismissed'); + setUserReports(prev => prev.filter(r => r.id !== reportId)); + if (selectedReport?.id === reportId) setSelectedReport(null); + } catch (error) { + toast.error('Failed to dismiss report'); + } finally { + setProcessing(null); + } + }; + + const handleReviewReport = async (reportId: string) => { + setProcessing(reportId); + try { + const { error } = await supabase + .from('message_reports') + .update({ status: 'reviewed', reviewed_at: new Date().toISOString() }) + .eq('id', reportId); + + if (error) throw error; + + toast.success('Report marked as reviewed'); + setUserReports(prev => prev.filter(r => r.id !== reportId)); + if (selectedReport?.id === reportId) setSelectedReport(null); + } catch (error) { + toast.error('Failed to update report'); + } finally { + setProcessing(null); + } + }; + const handleJumpToChat = () => { - if (!selectedMessage || !selectedMessage.match_id) { + const matchId = selectedMessage?.match_id || selectedReport?.message?.match_id; + const userId = selectedMessage?.user_id || selectedReport?.message?.user_id; + + if (!matchId) { toast.error("No match context available"); return; } - navigate(`/users/${selectedMessage.user_id}/matches/${selectedMessage.match_id}`); + navigate(`/users/${userId}/matches/${matchId}`); + }; + + const closeDialogs = () => { + setSelectedMessage(null); + setSelectedReport(null); }; return (
-

Flagged Messages

+

Content Moderation

- Review content flagged by AI moderation + Review AI-flagged content and user reports

-
- - - - - Time - Reason - Type - Actions - - - - {messages.length === 0 ? ( - - -
- -

No flagged messages found. Good job!

-
-
-
- ) : ( - messages.map((message) => ( - + + + + + AI Flagged + {aiMessages.length > 0 && ( + {aiMessages.length} + )} + + + + User Reports + {userReports.length > 0 && ( + {userReports.length} + )} + + + + +
+ +
+ + +
+ - - {format(new Date(message.created_at), 'MMM d, HH:mm')} - - - - {message.flag_reason || 'Unknown'} - - - - {message.media_path ? ( - {message.media_type || 'Media'} - ) : ( - Text - )} - - - - {message.media_path && message.media_type !== 'video' && ( - - )} - - + Time + Reason + Type + Actions - {inlineDescriptions[message.id] && ( - - -
- -

- {inlineDescriptions[message.id]} -

+ + + {aiMessages.length === 0 ? ( + + +
+ +

No AI-flagged messages found. Good job!

+ ) : ( + aiMessages.map((message) => ( + + + + {format(new Date(message.created_at), 'MMM d, HH:mm')} + + + + {message.flag_reason || 'Unknown'} + + + + {message.media_path ? ( + {message.media_type || 'Media'} + ) : ( + Text + )} + + + + {message.media_path && message.media_type !== 'video' && ( + + )} + + + + {inlineDescriptions[message.id] && ( + + +
+ +

+ {inlineDescriptions[message.id]} +

+
+
+
+ )} +
+ )) )} - - )) - )} -
-
-
+ + + + - !open && setSelectedMessage(null)}> + +
+ +
+ + + + + + Time + Reporter + Reason + Type + Actions + + + + {userReports.length === 0 ? ( + + +
+ +

No pending user reports. All clear!

+
+
+
+ ) : ( + userReports.map((report) => ( + + + {format(new Date(report.created_at), 'MMM d, HH:mm')} + + + {report.reporter_profile?.name || report.reporter_profile?.email || 'Unknown'} + + + + {REASON_LABELS[report.reason] || report.reason} + + + + {report.message?.media_path ? ( + {report.message.media_type || 'Media'} + ) : ( + Text + )} + + + + + + + + )) + )} +
+
+
+
+ + + {/* Review Dialog - AI Flagged Messages */} + !open && closeDialogs()}> - Review Content + Review AI Flagged Content Decrypted content for review. Flagged for: {selectedMessage?.flag_reason} - +
{decrypting ? (
@@ -348,15 +608,15 @@ export function FlaggedMessagesPage() {
)} - + {decryptedContent.mediaUrl && (

Media Content

{selectedMessage?.media_type !== 'video' && ( - )}
- +
{selectedMessage?.media_type === 'video' ? (
- - +
+ + {/* Review Dialog - User Reports */} + !open && closeDialogs()}> + + + Review User Report + + Reported by: {selectedReport?.reporter_profile?.name || selectedReport?.reporter_profile?.email || 'Unknown'} +
+ Reason: {selectedReport?.reason ? (REASON_LABELS[selectedReport.reason] || selectedReport.reason) : 'Unknown'} +
+
+ +
+ {decrypting ? ( +
+ +

Decrypting secure content...

+
+ ) : ( +
+ {decryptedContent.text && ( +
+

Message Text

+
+ {decryptedContent.text} +
+
+ )} + + {decryptedContent.mediaUrl && ( +
+
+

Media Content

+ {selectedReport?.message?.media_type !== 'video' && ( + + )} +
+ +
+ {selectedReport?.message?.media_type === 'video' ? ( +
+ + {imageDescription && ( +
+
+ + AI Description +
+

{imageDescription}

+
+ )} +
+ )} + + {!decryptedContent.text && !decryptedContent.mediaUrl && ( +

No content displayed

+ )} +
+ )} +
+ + +
+ +
+
+ + + +
+
+
+
); } - diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 35507d7..9944b04 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -27,6 +27,147 @@ "NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.", "NSPhotoLibraryAddUsageDescription": "Sauci saves photos and videos from your partner's messages to your photo library when you tap the save button.", "NSMicrophoneUsageDescription": "Sauci uses your microphone to record audio when you capture videos to share privately with your partner." + }, + "privacyManifest": { + "NSPrivacyTracking": false, + "NSPrivacyCollectedDataTypes": [ + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeEmailAddress", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeName", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeSensitiveInfo", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality", + "NSPrivacyCollectedDataTypePurposeProductPersonalization" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeUserID", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality", + "NSPrivacyCollectedDataTypePurposeAnalytics" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeDeviceID", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePhotosorVideos", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeAudioData", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeOtherUserContent", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeProductInteraction", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAnalytics" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeCrashData", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAnalytics" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePerformanceData", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAnalytics" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeOtherDiagnosticData", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAnalytics" + ] + }, + { + "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePurchaseHistory", + "NSPrivacyCollectedDataTypeLinked": true, + "NSPrivacyCollectedDataTypeTracking": false, + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] + } + ], + "NSPrivacyAccessedAPITypes": [ + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", + "NSPrivacyAccessedAPITypeReasons": [ + "C617.1", + "0A2A.1", + "3B52.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", + "NSPrivacyAccessedAPITypeReasons": [ + "CA92.1", + "C56D.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", + "NSPrivacyAccessedAPITypeReasons": [ + "E174.1", + "85F4.1" + ] + }, + { + "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", + "NSPrivacyAccessedAPITypeReasons": [ + "35F9.1" + ] + } + ] } }, "android": { diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index 2e8aeb1..cc7027d 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -9,9 +9,10 @@ import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated"; -import { InputBar, ChatHeader, ChatMessages } from "./components"; +import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet } from "./components"; import { useMediaUpload } from "./hooks/useMediaUpload"; import { useMessageActions } from "./hooks/useMessageActions"; +import type { ReportReason } from "./types"; import { supabase } from "../../lib/supabase"; import { useAuthStore, useMessageStore } from "../../store"; @@ -42,6 +43,7 @@ export const ChatScreen: React.FC = () => { const [fullScreenImage, setFullScreenImage] = useState(null); const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true); const [fullScreenVideo, setFullScreenVideo] = useState(null); + const [reportingMessage, setReportingMessage] = useState(null); // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation({ @@ -75,16 +77,60 @@ export const ChatScreen: React.FC = () => { // E2EE encryption hook for sending messages const { encryptMessage, isE2EEAvailable } = useEncryptedSend(); - // Message actions hook for deletion - const { showDeleteOptions } = useMessageActions({ userId: user?.id }); + // Message actions hook for deletion and reporting + const { showDeleteOptions, deleteForSelf } = useMessageActions({ + userId: user?.id, + onReport: (message) => setReportingMessage(message), + }); - // Handle long press on message to show delete options + // Handle long press on message to show options const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => { // Don't show options for already deleted messages if (message.deleted_at) return; showDeleteOptions(message, isMe); }, [showDeleteOptions]); + // Handle report submission + const handleSubmitReport = async (reason: ReportReason) => { + if (!reportingMessage || !user?.id) return; + + try { + // Insert the report + const { error: reportError } = await supabase + .from('message_reports') + .insert({ + message_id: reportingMessage.id, + reporter_id: user.id, + reason: reason, + }); + + if (reportError) { + if (reportError.code === '23505') { + // Unique violation - already reported + Alert.alert('Already Reported', 'You have already reported this message.'); + } else { + console.error('Report error:', reportError); + Alert.alert('Error', 'Failed to submit report'); + } + setReportingMessage(null); + return; + } + + // Also delete the message for the reporter (so they don't see it anymore) + await deleteForSelf(reportingMessage.id); + + // Remove from local state + setMessages(prev => prev.filter(m => m.id !== reportingMessage.id)); + + Alert.alert('Report Submitted', 'Thank you for your report. We will review it shortly.'); + setReportingMessage(null); + } catch (err) { + console.error('Report error:', err); + Alert.alert('Error', 'Failed to submit report'); + setReportingMessage(null); + } + }; + // Track active chat to prevent notifications for current chat useFocusEffect( useCallback(() => { @@ -436,6 +482,13 @@ export const ChatScreen: React.FC = () => { )} + + {/* Report Message Sheet */} + setReportingMessage(null)} + onSubmit={handleSubmitReport} + /> ); }; diff --git a/apps/mobile/src/features/chat/components/ReportMessageSheet.tsx b/apps/mobile/src/features/chat/components/ReportMessageSheet.tsx new file mode 100644 index 0000000..fcde456 --- /dev/null +++ b/apps/mobile/src/features/chat/components/ReportMessageSheet.tsx @@ -0,0 +1,297 @@ +import { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + TouchableWithoutFeedback, + ActivityIndicator, + Platform, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { BlurView } from "expo-blur"; +import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated"; +import { colors, spacing, typography, radius } from "../../../theme"; +import { REPORT_REASONS, type ReportReason } from "../types"; + +interface ReportMessageSheetProps { + visible: boolean; + onClose: () => void; + onSubmit: (reason: ReportReason) => Promise; +} + +export function ReportMessageSheet({ visible, onClose, onSubmit }: ReportMessageSheetProps) { + const [selectedReason, setSelectedReason] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Reset state when modal closes + useEffect(() => { + if (!visible) { + setSelectedReason(null); + setIsLoading(false); + } + }, [visible]); + + const handleSubmit = async () => { + if (!selectedReason || isLoading) return; + + setIsLoading(true); + await onSubmit(selectedReason); + setIsLoading(false); + }; + + const ReasonButton = ({ reason }: { reason: typeof REPORT_REASONS[number] }) => { + const isSelected = selectedReason === reason.value; + + return ( + setSelectedReason(reason.value)} + disabled={isLoading} + activeOpacity={0.7} + > + + + {isSelected && } + + + {reason.label} + + + + ); + }; + + return ( + + + + + + + + {Platform.OS === "ios" ? ( + + + + ) : ( + + + + )} + + + + ); +} + +// Sheet content extracted for reuse between iOS blur and Android solid background +function SheetContent({ + ReasonButton, + selectedReason, + isLoading, + onClose, + onSubmit, +}: { + ReasonButton: React.FC<{ reason: typeof REPORT_REASONS[number] }>; + selectedReason: ReportReason | null; + isLoading: boolean; + onClose: () => void; + onSubmit: () => void; +}) { + return ( + <> + {/* Handle */} + + + {/* Header */} + + Report Message + + + + + + {/* Description */} + + Why are you reporting this message? + + + {/* Reason buttons */} + + {REPORT_REASONS.map((reason) => ( + + ))} + + + {/* Submit button */} + + {isLoading ? ( + + ) : ( + Submit Report + )} + + + {/* Bottom padding for safe area */} + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: "flex-end", + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.6)", + }, + sheetContainer: { + maxHeight: "80%", + }, + sheet: { + borderTopLeftRadius: radius.xl, + borderTopRightRadius: radius.xl, + overflow: "hidden", + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + }, + sheetAndroid: { + backgroundColor: colors.backgroundLight, + }, + handle: { + width: 40, + height: 4, + backgroundColor: "rgba(255, 255, 255, 0.3)", + borderRadius: 2, + alignSelf: "center", + marginBottom: spacing.md, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + marginBottom: spacing.md, + }, + headerTitle: { + ...typography.title3, + color: colors.text, + }, + closeButton: { + position: "absolute", + right: 0, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: "rgba(255, 255, 255, 0.1)", + justifyContent: "center", + alignItems: "center", + }, + description: { + ...typography.body, + color: colors.textSecondary, + textAlign: "center", + marginBottom: spacing.lg, + }, + reasonButtons: { + gap: spacing.sm, + marginBottom: spacing.lg, + }, + reasonButton: { + paddingVertical: spacing.md, + paddingHorizontal: spacing.lg, + borderRadius: radius.lg, + backgroundColor: "rgba(255, 255, 255, 0.05)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + }, + reasonButtonSelected: { + backgroundColor: "rgba(231, 76, 60, 0.1)", + borderColor: "rgba(231, 76, 60, 0.3)", + }, + reasonContent: { + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + }, + radioOuter: { + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: colors.textTertiary, + justifyContent: "center", + alignItems: "center", + }, + radioOuterSelected: { + borderColor: colors.error, + }, + radioInner: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: colors.error, + }, + reasonText: { + ...typography.body, + color: colors.textSecondary, + }, + reasonTextSelected: { + color: colors.text, + fontWeight: "600", + }, + submitButton: { + paddingVertical: spacing.md, + borderRadius: radius.lg, + backgroundColor: colors.error, + alignItems: "center", + justifyContent: "center", + }, + submitButtonDisabled: { + backgroundColor: "rgba(231, 76, 60, 0.3)", + }, + submitButtonText: { + ...typography.callout, + color: colors.text, + fontWeight: "600", + }, + bottomPadding: { + height: Platform.OS === "ios" ? 34 : 20, + }, +}); diff --git a/apps/mobile/src/features/chat/components/index.ts b/apps/mobile/src/features/chat/components/index.ts index bdaed5d..d603453 100644 --- a/apps/mobile/src/features/chat/components/index.ts +++ b/apps/mobile/src/features/chat/components/index.ts @@ -15,3 +15,4 @@ export type { MessageBubbleProps } from './MessageBubble'; export { ChatHeader } from './ChatHeader'; export { ChatMessages } from './ChatMessages'; export { MatchCard } from './MatchCard'; +export { ReportMessageSheet } from './ReportMessageSheet'; diff --git a/apps/mobile/src/features/chat/hooks/useMessageActions.ts b/apps/mobile/src/features/chat/hooks/useMessageActions.ts index e863ad3..03552f2 100644 --- a/apps/mobile/src/features/chat/hooks/useMessageActions.ts +++ b/apps/mobile/src/features/chat/hooks/useMessageActions.ts @@ -1,6 +1,6 @@ /** - * useMessageActions - Hook for message deletion actions - * Handles "delete for self" and "delete for everyone" functionality. + * useMessageActions - Hook for message actions + * Handles "delete for self", "delete for everyone", and "report" functionality. */ import { Platform, ActionSheetIOS, Alert } from 'react-native'; import * as Haptics from 'expo-haptics'; @@ -9,9 +9,10 @@ import type { Message } from '../types'; interface UseMessageActionsConfig { userId: string | undefined; + onReport?: (message: Message) => void; } -export function useMessageActions({ userId }: UseMessageActionsConfig) { +export function useMessageActions({ userId, onReport }: UseMessageActionsConfig) { /** * Delete a message for the current user only. * Message will still be visible to the partner. @@ -46,8 +47,8 @@ export function useMessageActions({ userId }: UseMessageActionsConfig) { }; /** - * Show delete options for a message. - * Author gets both options, non-author only gets "delete for me". + * Show options for a message. + * Author gets delete options, non-author gets delete for me + report. */ const showDeleteOptions = (message: Message, isMe: boolean) => { // Don't show options for already deleted messages @@ -57,44 +58,73 @@ export function useMessageActions({ userId }: UseMessageActionsConfig) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); if (Platform.OS === 'ios') { - const options = ['Cancel', 'Delete for me']; - if (isMe) options.push('Delete for everyone'); - - ActionSheetIOS.showActionSheetWithOptions( - { - options, - destructiveButtonIndex: isMe ? 2 : 1, - cancelButtonIndex: 0, - title: 'Delete message?', - }, - (buttonIndex) => { - if (buttonIndex === 1) { - deleteForSelf(message.id); - } else if (buttonIndex === 2 && isMe) { - deleteForEveryone(message.id); + if (isMe) { + // Author sees: Cancel, Delete for me, Delete for everyone + const options = ['Cancel', 'Delete for me', 'Delete for everyone']; + ActionSheetIOS.showActionSheetWithOptions( + { + options, + destructiveButtonIndex: 2, + cancelButtonIndex: 0, + title: 'Delete message?', + }, + (buttonIndex) => { + if (buttonIndex === 1) { + deleteForSelf(message.id); + } else if (buttonIndex === 2) { + deleteForEveryone(message.id); + } } - } - ); + ); + } else { + // Non-author sees: Cancel, Delete for me, Report + const options = ['Cancel', 'Delete for me', 'Report']; + ActionSheetIOS.showActionSheetWithOptions( + { + options, + destructiveButtonIndex: 2, + cancelButtonIndex: 0, + title: 'Message options', + }, + (buttonIndex) => { + if (buttonIndex === 1) { + deleteForSelf(message.id); + } else if (buttonIndex === 2 && onReport) { + onReport(message); + } + } + ); + } } else { // Android fallback using Alert - const buttons: Array<{ text: string; style?: 'cancel' | 'default' | 'destructive'; onPress?: () => void }> = [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete for me', - style: 'destructive', - onPress: () => deleteForSelf(message.id), - }, - ]; - if (isMe) { - buttons.push({ - text: 'Delete for everyone', - style: 'destructive', - onPress: () => deleteForEveryone(message.id), - }); + Alert.alert('Delete message?', undefined, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete for me', + style: 'destructive', + onPress: () => deleteForSelf(message.id), + }, + { + text: 'Delete for everyone', + style: 'destructive', + onPress: () => deleteForEveryone(message.id), + }, + ]); + } else { + Alert.alert('Message options', undefined, [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete for me', + onPress: () => deleteForSelf(message.id), + }, + { + text: 'Report', + style: 'destructive', + onPress: () => onReport?.(message), + }, + ]); } - - Alert.alert('Delete message?', undefined, buttons); } }; diff --git a/apps/mobile/src/features/chat/types.ts b/apps/mobile/src/features/chat/types.ts index b9b02ed..56b275e 100644 --- a/apps/mobile/src/features/chat/types.ts +++ b/apps/mobile/src/features/chat/types.ts @@ -39,3 +39,13 @@ export const CHAT_COLORS = { ROSE: 'rgb(232, 164, 174)', ROSE_RGBA: 'rgba(232, 164, 174, ', } as const; + +/** Report reasons for message reporting */ +export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other'; + +export const REPORT_REASONS: { value: ReportReason; label: string }[] = [ + { value: 'harassment', label: 'Harassment' }, + { value: 'spam', label: 'Spam' }, + { value: 'inappropriate_content', label: 'Inappropriate Content' }, + { value: 'other', label: 'Other' }, +]; diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 6417dfe..84bd6b9 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -51,6 +51,7 @@ export function ProfileScreen() { handleDeleteRelationship, handleResetProgress, handleSignOut, + handleDeleteAccount, navigateToPairing } = useCoupleManagement(); @@ -373,9 +374,11 @@ export function ProfileScreen() { )} {/* Danger Zone */} - {couple && ( - - )} + {/* Version */} Promise; + onDeleteRelationship?: () => Promise; + onDeleteAccount: () => Promise; + hasRelationship?: boolean; } -export const DangerZone: React.FC = ({ onDeleteRelationship }) => { - const [showDeleteModal, setShowDeleteModal] = useState(false); +export const DangerZone: React.FC = ({ + onDeleteRelationship, + onDeleteAccount, + hasRelationship = false +}) => { + const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false); + const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); - const [isDeleting, setIsDeleting] = useState(false); + const [isDeletingRelationship, setIsDeletingRelationship] = useState(false); + const [isDeletingAccount, setIsDeletingAccount] = useState(false); - const handleDelete = async () => { - if (deleteConfirmText !== "DELETE") return; - setIsDeleting(true); + const handleDeleteRelationship = async () => { + if (deleteConfirmText !== "DELETE" || !onDeleteRelationship) return; + setIsDeletingRelationship(true); try { await onDeleteRelationship(); - setShowDeleteModal(false); + setShowDeleteRelationshipModal(false); setDeleteConfirmText(""); } catch (error) { // Error handling relies on parent throwing or managing its own alerts } finally { - setIsDeleting(false); + setIsDeletingRelationship(false); + } + }; + + const handleDeleteAccount = async () => { + if (deleteConfirmText !== "DELETE") return; + setIsDeletingAccount(true); + try { + await onDeleteAccount(); + // No need to close modal - user will be signed out + } catch (error) { + // Error handling relies on parent throwing or managing its own alerts + setIsDeletingAccount(false); + } + }; + + const closeRelationshipModal = () => { + if (!isDeletingRelationship) { + setShowDeleteRelationshipModal(false); + setDeleteConfirmText(""); + } + }; + + const closeAccountModal = () => { + if (!isDeletingAccount) { + setShowDeleteAccountModal(false); + setDeleteConfirmText(""); } }; @@ -36,23 +70,53 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) style={styles.section} > Danger Zone - + + {/* Delete Relationship - only show if in a relationship */} + {hasRelationship && onDeleteRelationship && ( + + + + + + + + Delete Relationship Data + + Delete your relationship including all matches, chats, and shared photos. Your account will remain. + + + + setShowDeleteRelationshipModal(true)} + icon={} + style={styles.dangerButton} + > + Delete + + + + )} + + {/* Delete Account - always show */} + - + - Delete All Data + Delete Account - Permanently delete your relationship including all matches, chats, and shared photos. + Permanently delete your account and all associated data. This cannot be undone. setShowDeleteModal(true)} + onPress={() => setShowDeleteAccountModal(true)} icon={} style={styles.dangerButton} > @@ -62,27 +126,28 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) + {/* Delete Relationship Modal */} !isDeleting && setShowDeleteModal(false)} + onRequestClose={closeRelationshipModal} > - - - + - Delete All Data? + Delete Relationship Data? @@ -120,17 +185,14 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) placeholderTextColor={colors.textTertiary} autoCapitalize="characters" autoCorrect={false} - editable={!isDeleting} + editable={!isDeletingRelationship} /> { - setShowDeleteModal(false); - setDeleteConfirmText(""); - }} - disabled={isDeleting} + onPress={closeRelationshipModal} + disabled={isDeletingRelationship} activeOpacity={0.7} > Cancel @@ -139,13 +201,13 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) - {isDeleting ? ( + {isDeletingRelationship ? ( ) : ( <> @@ -159,6 +221,110 @@ export const DangerZone: React.FC = ({ onDeleteRelationship }) + + {/* Delete Account Modal */} + + + + + + + + + Delete Account? + + + + This action cannot be undone. This will permanently delete: + + + + + + Your account and profile + + {hasRelationship && ( + <> + + + Your relationship data + + + + All chat messages and photos + + + )} + + + Your encryption keys + + + + All cloud-stored data + + + + + Type DELETE to confirm: + + + + + + + Cancel + + + + {isDeletingAccount ? ( + + ) : ( + <> + + Delete Account + + )} + + + + + + ); }; @@ -180,6 +346,9 @@ const styles = StyleSheet.create({ borderColor: 'rgba(231, 76, 60, 0.3)', borderWidth: 1, }, + dangerCardSpacing: { + marginTop: spacing.md, + }, dangerContent: { gap: spacing.md, }, diff --git a/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts b/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts index 463f830..b74f688 100644 --- a/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts +++ b/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts @@ -4,6 +4,7 @@ import { useRouter } from 'expo-router'; import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store'; import { supabase } from '../../../lib/supabase'; import { Events } from '../../../lib/analytics'; +import { clearKeys } from '../../../lib/encryption'; export function useCoupleManagement() { const router = useRouter(); @@ -13,6 +14,7 @@ export function useCoupleManagement() { const { clearPacks } = usePacksStore(); const [isResettingProgress, setIsResettingProgress] = useState(false); + const [isDeletingAccount, setIsDeletingAccount] = useState(false); const handleUnpair = async () => { Alert.alert( @@ -99,16 +101,46 @@ export function useCoupleManagement() { ); }; + const handleDeleteAccount = async () => { + setIsDeletingAccount(true); + try { + const { error } = await supabase.functions.invoke("delete-account", { + method: "DELETE", + }); + + if (error) throw error; + + // Clear all local data + clearMatches(); + clearMessages(); + clearPacks(); + await clearKeys(); + + Events.accountDeleted(); + + // Sign out the user (this will redirect to login) + await signOut(); + } catch (error) { + console.error("Delete account error:", error); + Alert.alert("Error", "Failed to delete account. Please try again."); + throw error; + } finally { + setIsDeletingAccount(false); + } + }; + const navigateToPairing = () => { router.push("/(app)/pairing"); }; return { isResettingProgress, + isDeletingAccount, handleUnpair, handleDeleteRelationship, handleResetProgress, handleSignOut, + handleDeleteAccount, navigateToPairing, }; } diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index c3a1bf2..d63f25a 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -157,6 +157,7 @@ export const Events = { // Relationship events relationshipEnded: () => logEvent("relationship_ended"), + accountDeleted: () => logEvent("account_deleted"), // Milestone events firstMatch: () => logEvent("first_match"), diff --git a/apps/supabase/functions/delete-account/index.ts b/apps/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..4e1bd27 --- /dev/null +++ b/apps/supabase/functions/delete-account/index.ts @@ -0,0 +1,204 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", +}; + +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); + } + + return response.json(); +} + +Deno.serve(async (req) => { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (req.method !== "DELETE") { + return new Response( + JSON.stringify({ error: "Method not allowed" }), + { status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + try { + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + // Get user from JWT + const authHeader = req.headers.get("Authorization"); + if (!authHeader) { + return new Response( + JSON.stringify({ error: "Missing authorization header" }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + const { data: { user }, error: authError } = await supabase.auth.getUser( + authHeader.replace("Bearer ", "") + ); + + if (authError || !user) { + console.error("Auth Error:", authError); + return new Response( + JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }), + { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Get user's profile + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("couple_id") + .eq("id", user.id) + .maybeSingle(); + + if (profileError) { + console.error("Profile fetch error:", profileError); + return new Response( + JSON.stringify({ error: "Failed to fetch profile" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // If user is in a relationship, handle couple data deletion + if (profile?.couple_id) { + const coupleId = profile.couple_id; + + // Get partner's push token BEFORE clearing data + const { data: partnerProfile } = await supabase + .from("profiles") + .select("push_token") + .eq("couple_id", coupleId) + .neq("id", user.id) + .maybeSingle(); + + // Get all match IDs for this couple (needed for storage cleanup) + const { data: matches } = await supabase + .from("matches") + .select("id") + .eq("couple_id", coupleId); + + const matchIds = matches?.map(m => m.id) || []; + + // Delete all chat media from storage for each match + for (const matchId of matchIds) { + const { data: files } = await supabase.storage + .from("chat-media") + .list(matchId); + + if (files && files.length > 0) { + const filePaths = files.map(f => `${matchId}/${f.name}`); + await supabase.storage + .from("chat-media") + .remove(filePaths); + } + } + + // Clear couple_id from partner's profile (don't clear our own yet, will be deleted) + await supabase + .from("profiles") + .update({ couple_id: null }) + .eq("couple_id", coupleId) + .neq("id", user.id); + + // Delete the couple record (cascades to responses, matches, messages, couple_packs) + const { error: deleteError } = await supabase + .from("couples") + .delete() + .eq("id", coupleId); + + if (deleteError) { + console.error("Failed to delete couple:", deleteError); + // Continue anyway - we still want to delete the account + } + + // Notify partner about the account deletion + if (partnerProfile?.push_token) { + await sendExpoPushNotifications([{ + to: partnerProfile.push_token, + title: "Partner Left", + body: "Your partner has deleted their account. You can start fresh with a new partner anytime.", + sound: "default", + data: { type: "partner_account_deleted" }, + }]); + } + } + + // Delete any avatar from storage + const { data: avatarFiles } = await supabase.storage + .from("avatars") + .list(user.id); + + if (avatarFiles && avatarFiles.length > 0) { + const avatarPaths = avatarFiles.map(f => `${user.id}/${f.name}`); + await supabase.storage + .from("avatars") + .remove(avatarPaths); + } + + // Delete the user's profile (this will cascade delete any orphaned responses) + const { error: profileDeleteError } = await supabase + .from("profiles") + .delete() + .eq("id", user.id); + + if (profileDeleteError) { + console.error("Failed to delete profile:", profileDeleteError); + return new Response( + JSON.stringify({ error: "Failed to delete profile" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Delete the user from auth.users using admin API + const { error: authDeleteError } = await supabase.auth.admin.deleteUser(user.id); + + if (authDeleteError) { + console.error("Failed to delete auth user:", authDeleteError); + return new Response( + JSON.stringify({ error: "Failed to delete authentication record" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + return new Response( + JSON.stringify({ success: true, message: "Account deleted successfully" }), + { headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } catch (error) { + console.error("Error:", error); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/migrations/20260107000000_add_message_reports.sql b/apps/supabase/migrations/20260107000000_add_message_reports.sql new file mode 100644 index 0000000..2035d94 --- /dev/null +++ b/apps/supabase/migrations/20260107000000_add_message_reports.sql @@ -0,0 +1,65 @@ +-- Create enum for report reasons +CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other'); + +-- Create message_reports table +CREATE TABLE message_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + reporter_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + reason report_reason NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed')), + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT now(), + + -- Prevent duplicate reports from same user + UNIQUE(message_id, reporter_id) +); + +-- Indexes for efficient queries +CREATE INDEX idx_message_reports_status ON message_reports(status); +CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC); +CREATE INDEX idx_message_reports_message ON message_reports(message_id); + +-- Enable RLS +ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY; + +-- RLS Policies + +-- Users can view their own reports +CREATE POLICY "Users can view own reports" + ON message_reports FOR SELECT + USING (reporter_id = auth.uid()); + +-- Users can create reports for messages in their couple's matches (but NOT their own messages) +CREATE POLICY "Users can report partner messages" + ON message_reports FOR INSERT + WITH CHECK ( + reporter_id = auth.uid() AND + EXISTS ( + SELECT 1 FROM messages m + JOIN matches ma ON ma.id = m.match_id + JOIN profiles p ON p.couple_id = ma.couple_id + WHERE m.id = message_reports.message_id + AND p.id = auth.uid() + AND m.user_id != auth.uid() -- Cannot report own messages + ) + ); + +-- Admins can view all reports +CREATE POLICY "Admins can view all reports" + ON message_reports FOR SELECT + USING (is_admin()); + +-- Admins can update reports (for review status) +CREATE POLICY "Admins can update reports" + ON message_reports FOR UPDATE + USING (is_admin()); + +-- Add to realtime for live updates in admin +ALTER PUBLICATION supabase_realtime ADD TABLE message_reports; + +-- Comments +COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).'; +COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).'; +COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.';