diff --git a/apps/admin/src/lib/ai/analyzers/media.ts b/apps/admin/src/lib/ai/analyzers/media.ts new file mode 100644 index 0000000..0970425 --- /dev/null +++ b/apps/admin/src/lib/ai/analyzers/media.ts @@ -0,0 +1,39 @@ +import { getOpenAI, getModel } from '../client'; + +export async function describeImage(imageBase64OrUrl: string): Promise { + const openai = getOpenAI(); + const model = getModel('describe_image'); + + // If it's a data URL or http URL, use it as is. + // If it's raw base64, prepend the data URI scheme. + let imageUrl = imageBase64OrUrl; + if (!imageBase64OrUrl.startsWith('http') && !imageBase64OrUrl.startsWith('data:')) { + imageUrl = `data:image/jpeg;base64,${imageBase64OrUrl}`; + } + + try { + const response = await openai.chat.completions.create({ + model: model, + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Describe this image in detail. Focus on any potentially inappropriate content or safety concerns.' }, + { + type: 'image_url', + image_url: { + "url": imageUrl, + }, + }, + ], + }, + ], + max_tokens: 300, + }); + + return response.choices[0].message.content || 'No description available.'; + } catch (error) { + console.error('Error describing image:', error); + throw error; + } +} diff --git a/apps/admin/src/lib/ai/client.ts b/apps/admin/src/lib/ai/client.ts index 4082c7a..08caf0e 100644 --- a/apps/admin/src/lib/ai/client.ts +++ b/apps/admin/src/lib/ai/client.ts @@ -71,6 +71,8 @@ export function getModel(purpose?: ModelPurpose): string { return remoteConfig?.model_fix || import.meta.env.VITE_AI_MODEL_FIX || fallback; case 'polish': return remoteConfig?.model_polish || import.meta.env.VITE_AI_MODEL_POLISH || fallback; + case 'describe_image': + return remoteConfig?.classifier_model || 'gpt-4o'; default: return fallback; } diff --git a/apps/admin/src/lib/ai/types.ts b/apps/admin/src/lib/ai/types.ts index 1c046a1..9577e15 100644 --- a/apps/admin/src/lib/ai/types.ts +++ b/apps/admin/src/lib/ai/types.ts @@ -4,7 +4,7 @@ // ============================================================================= // Model purpose for selecting appropriate model -export type ModelPurpose = 'generate' | 'fix' | 'polish'; +export type ModelPurpose = 'generate' | 'fix' | 'polish' | 'describe_image'; // ============================================================================= // COUNCIL CONFIGURATION TYPES diff --git a/apps/admin/src/pages/FlaggedMessagesPage.tsx b/apps/admin/src/pages/FlaggedMessagesPage.tsx index 3269851..4706382 100644 --- a/apps/admin/src/pages/FlaggedMessagesPage.tsx +++ b/apps/admin/src/pages/FlaggedMessagesPage.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { supabase } from '@/config'; +import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card } from '@/components/ui/card'; import { Table, TableBody, @@ -13,15 +13,16 @@ import { TableRow, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Shield, CheckCircle, AlertTriangle, Eye, RefreshCw } from 'lucide-react'; +import { CheckCircle, Eye, RefreshCw, MessageSquare, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { format } from 'date-fns'; +import { describeImage } from '@/lib/ai/analyzers/media'; interface FlaggedMessage { id: string; created_at: string; user_id: string; + match_id: string | null; moderation_status: 'flagged' | 'safe' | 'unmoderated'; flag_reason: string | null; content?: string; // Encrypted usually @@ -30,19 +31,24 @@ interface FlaggedMessage { } export function FlaggedMessagesPage() { + const navigate = useNavigate(); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [selectedMessage, setSelectedMessage] = 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 [imageDescription, setImageDescription] = useState(null); + const [describing, setDescribing] = useState(false); + const [inlineDescriptions, setInlineDescriptions] = useState>({}); + const [describingInline, setDescribingInline] = useState(null); const fetchMessages = async () => { setLoading(true); try { const { data, error } = await supabase .from('messages') - .select('id, created_at, user_id, moderation_status, flag_reason, content, media_path, media_type') + .select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type') .eq('moderation_status', 'flagged') .order('created_at', { ascending: false }); @@ -55,65 +61,10 @@ export function FlaggedMessagesPage() { setLoading(false); } }; - + useEffect(() => { fetchMessages(); }, []); - - const handleView = async (message: FlaggedMessage) => { - setSelectedMessage(message); - setDecrypting(true); - setDecryptedContent({ text: null, mediaUrl: null }); - - try { - // Call edge function to decrypt - // We use admin-decrypt-message for text - let text = null; - let mediaUrl = null; - - // 1. Decrypt Text/Metadata - const { data: decryptData, error: decryptError } = await supabase.functions.invoke('admin-decrypt-message', { - body: { messageId: message.id } - }); - - if (decryptError) throw decryptError; - - if (decryptData) { - text = decryptData.content; - - // 2. Handle Media - // If it has media, admin-decrypt-message returns media_path but not the file content directly decrypted usually - // But admin-decrypt-media function returns the raw bytes - if (decryptData.media_path) { - const { data: mediaBlob, error: mediaError } = await supabase.functions.invoke('admin-decrypt-media', { - body: { messageId: message.id }, - // Response is a blob/file usually? - // supabase.functions.invoke by default parses JSON. - // We might need to handle blob response. - }); - - // Invoke returns data as parsed JSON if header is json, or blob? - // The admin-decrypt-media returns binary body. - // Supabase js client might try to parse it. - // We might need a direct fetch or handle responseType. - } - } - - // NOTE: For now, displaying text content. Media decryption in frontend via edge function binary response - // is tricky with supabase-js invoke(). - // We will just show the text for now or implement media later if critical. - // Actually, we can just use a direct fetch with the user's session token. - - setDecryptedContent({ text, mediaUrl }); - - } catch (error) { - console.error('Decryption failed:', error); - toast.error('Failed to decrypt content'); - setDecryptedContent({ text: 'Error decrypting content', mediaUrl: null }); - } finally { - setDecrypting(false); - } - }; // Custom media fetcher because supabase.functions.invoke is JSON-centric const fetchDecryptedMedia = async (messageId: string) => { @@ -140,11 +91,11 @@ export function FlaggedMessagesPage() { } }; - // Improved handleView with media support - const handleViewComplete = async (message: FlaggedMessage) => { + const handleView = async (message: FlaggedMessage) => { setSelectedMessage(message); setDecrypting(true); setDecryptedContent({ text: null, mediaUrl: null }); + setImageDescription(null); try { // Text @@ -169,6 +120,72 @@ export function FlaggedMessagesPage() { } }; + const handleDescribeImage = async () => { + if (!decryptedContent.mediaUrl) return; + + setDescribing(true); + try { + const description = await describeImage(decryptedContent.mediaUrl); + setImageDescription(description); + } catch (error) { + console.error('Failed to describe image:', error); + toast.error('Failed to describe image'); + } finally { + setDescribing(false); + } + }; + + const handleDescribeInline = async (message: FlaggedMessage) => { + if (!message.media_path || message.media_type === 'video') return; + + setDescribingInline(message.id); + try { + // First fetch the decrypted media as blob + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + toast.error('Not authenticated'); + return; + } + + const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ messageId: message.id }) + }); + + if (!response.ok) { + toast.error('Failed to decrypt media'); + 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); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // Then describe it + const description = await describeImage(base64); + setInlineDescriptions(prev => ({ ...prev, [message.id]: description })); + } catch (error) { + console.error('Failed to describe image:', error); + toast.error('Failed to describe image'); + } finally { + setDescribingInline(null); + } + }; + const handleMarkSafe = async (messageId: string) => { setProcessing(messageId); try { @@ -189,6 +206,14 @@ export function FlaggedMessagesPage() { } }; + const handleJumpToChat = () => { + if (!selectedMessage || !selectedMessage.match_id) { + toast.error("No match context available"); + return; + } + navigate(`/users/${selectedMessage.user_id}/matches/${selectedMessage.match_id}`); + }; + return (
@@ -226,7 +251,8 @@ export function FlaggedMessagesPage() { ) : ( messages.map((message) => ( - + + {format(new Date(message.created_at), 'MMM d, HH:mm')} @@ -243,16 +269,31 @@ export function FlaggedMessagesPage() { )} - - + )} + + {inlineDescriptions[message.id] && ( + + +
+ +

+ {inlineDescriptions[message.id]} +

+
+
+
+ )} +
)) )} @@ -270,7 +324,7 @@ export function FlaggedMessagesPage() { !open && setSelectedMessage(null)}> - + Review Content @@ -278,46 +332,91 @@ export function FlaggedMessagesPage() { -
+
{decrypting ? ( -
+

Decrypting secure content...

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

Message Text

+
+ {decryptedContent.text} +
)} + {decryptedContent.mediaUrl && ( -
- {selectedMessage?.media_type === 'video' ? ( -
)}
- - - + +
+ +
+
+ + +
); } + diff --git a/apps/mobile/RELEASING.md b/apps/mobile/RELEASING.md index 6e2bce4..7a16a8c 100644 --- a/apps/mobile/RELEASING.md +++ b/apps/mobile/RELEASING.md @@ -5,29 +5,130 @@ This guide covers local release builds for Android and iOS. ## Prerequisites - **Java JDK 17** - Required for Android Gradle builds -- **Android Studio** - For SDK and build tools -- **Xcode** - For iOS builds +- **Android Studio** - For SDK and build tools (including Android SDK/NDK) +- **Xcode** - For iOS builds (macOS only) - **EAS CLI** - `npm install -g eas-cli` +- **Expo account** - Run `eas login` to authenticate --- -## Recommended: EAS Build Local +## EAS Build Local (Recommended) -The easiest way to build locally with correct signing credentials: +The `--local` flag runs the EAS build process on your machine instead of Expo's cloud servers. This automatically handles signing credentials and produces the same output as cloud builds. + +### Quick Start ```bash cd apps/mobile -# Android - produces .aab file +# Android - produces .aab file (for Play Store) eas build --platform android --profile production --local -# iOS - produces .ipa file +# iOS - produces .ipa file (for App Store) eas build --platform ios --profile production --local ``` -This runs the build on your machine but automatically uses the correct keystore/certificates from EAS credentials. +Output files are placed in the current directory by default. -Output files will be in the current directory. +### Building an APK (for Testing) + +The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing: + +```bash +# Use the preview profile (configured for APK output) +eas build --platform android --profile preview --local +``` + +Or add a custom profile to `eas.json`: + +```json +{ + "build": { + "local-apk": { + "android": { + "buildType": "apk" + } + } + } +} +``` + +Then run: +```bash +eas build --platform android --profile local-apk --local +``` + +### Installing the APK + +```bash +# Via ADB (device connected via USB) +adb install ./build-*.apk + +# Or drag-and-drop onto Android Emulator +``` + +### Build Profiles + +| Profile | Output | Use Case | +|---------|--------|----------| +| `production` | .aab | Play Store / App Store submission | +| `preview` | .apk | Internal testing, device installation | +| `development` | .apk | Dev client with hot reload | + +### Environment Variables + +Useful for debugging build issues: + +| Variable | Purpose | +|----------|---------| +| `EAS_LOCAL_BUILD_SKIP_CLEANUP=1` | Keep build directory after completion | +| `EAS_LOCAL_BUILD_WORKINGDIR=/path` | Custom build directory (default: /tmp) | +| `EAS_LOCAL_BUILD_ARTIFACTS_DIR=/path` | Output directory for build artifacts | + +Example with debugging enabled: +```bash +EAS_LOCAL_BUILD_SKIP_CLEANUP=1 \ +EAS_LOCAL_BUILD_WORKINGDIR=~/eas-builds \ +eas build --platform android --profile production --local +``` + +### Limitations of Local Builds + +- **Single platform only** - Can't use `--platform all` +- **Software versions ignored** - `node`, `yarn`, `ndk`, `image` fields in eas.json are ignored +- **No caching** - Each build starts fresh +- **Secret env vars not available** - Set them in your local environment instead +- **macOS/Linux only** - Windows requires WSL (unsupported) + +### Troubleshooting Local Builds + +**Java version mismatch:** +```bash +# Check Java version (should be 17) +java -version + +# On macOS, switch Java version +export JAVA_HOME=$(/usr/libexec/java_home -v 17) +``` + +**Gradle daemon issues:** +```bash +cd android && ./gradlew --stop && cd .. +``` + +**Clean build after dependency changes:** +```bash +# Remove native folders and rebuild +rm -rf android ios +npx expo prebuild --clean +eas build --platform android --profile production --local +``` + +**Out of memory:** +```bash +# Increase Gradle memory +echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties +``` --- diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index a0c7c8c..7a6ef35 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, ActivityIndicator, Alert } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; @@ -18,7 +18,7 @@ import { clearKeys } from '../../lib/encryption'; import { useProfileSettings, useCoupleManagement } from './hooks'; // Components -import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components'; +import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, ResetProgress, SettingsSection, MenuItem } from './components'; const MAX_CONTENT_WIDTH = 500; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; @@ -36,7 +36,6 @@ export function ProfileScreen() { handleUnpair, handleDeleteRelationship, handleResetProgress, - isResettingProgress, handleSignOut, navigateToPairing } = useCoupleManagement(); @@ -216,38 +215,9 @@ export function ProfileScreen() { /> - {/* Reset Progress Layout */} + {/* Reset Progress */} {couple && ( - - - - - - - - Start Fresh - - Delete all swipes, matches, and chats while keeping your partner connection. - - - - - {isResettingProgress ? ( - - ) : ( - <> - - Reset - - )} - - - + )} {/* Debug Section */} @@ -484,57 +454,6 @@ const styles = StyleSheet.create({ ...typography.caption1, color: colors.textTertiary, }, - // Reset Progress Styles - resetContent: { - gap: spacing.md, - }, - resetInfo: { - flexDirection: "row", - alignItems: "flex-start", - }, - resetIconContainer: { - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: colors.warningLight, - justifyContent: "center", - alignItems: "center", - }, - resetTextContainer: { - flex: 1, - marginLeft: spacing.md, - }, - resetTitle: { - ...typography.headline, - color: colors.text, - marginBottom: spacing.xs, - }, - resetDescription: { - ...typography.subhead, - color: colors.textSecondary, - lineHeight: 20, - }, - resetButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.warningLight, - paddingVertical: spacing.sm, - paddingHorizontal: spacing.md, - borderRadius: radius.md, - borderWidth: 1, - borderColor: 'rgba(243, 156, 18, 0.3)', - gap: spacing.xs, - alignSelf: "flex-end", - }, - resetButtonDisabled: { - opacity: 0.5, - }, - resetButtonText: { - ...typography.subhead, - color: colors.warning, - fontWeight: "600", - }, // Version Badge versionContainer: { alignItems: "center", diff --git a/apps/mobile/src/features/profile/components/DangerZone.tsx b/apps/mobile/src/features/profile/components/DangerZone.tsx index c67a5db..ae0f74e 100644 --- a/apps/mobile/src/features/profile/components/DangerZone.tsx +++ b/apps/mobile/src/features/profile/components/DangerZone.tsx @@ -225,6 +225,9 @@ const styles = StyleSheet.create({ maxWidth: 400, padding: spacing.xl, backgroundColor: colors.backgroundLight, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: colors.glass.border, }, modalHeader: { alignItems: "center", @@ -278,11 +281,16 @@ const styles = StyleSheet.create({ fontWeight: "bold", color: colors.error, }, - inputContainer: { - marginBottom: spacing.xl, - }, confirmInput: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.error, + padding: spacing.md, + ...typography.body, + color: colors.text, textAlign: 'center', + marginBottom: spacing.xl, }, modalButtons: { flexDirection: "row", diff --git a/apps/mobile/src/features/profile/components/ResetProgress.tsx b/apps/mobile/src/features/profile/components/ResetProgress.tsx new file mode 100644 index 0000000..6218092 --- /dev/null +++ b/apps/mobile/src/features/profile/components/ResetProgress.tsx @@ -0,0 +1,362 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { BlurView } from 'expo-blur'; +import { GlassCard } from '../../../components/ui'; +import { colors, spacing, radius, typography } from '../../../theme'; + +interface ResetProgressProps { + onResetProgress: () => Promise; +} + +export const ResetProgress: React.FC = ({ onResetProgress }) => { + const [showResetModal, setShowResetModal] = useState(false); + const [resetConfirmText, setResetConfirmText] = useState(""); + const [isResetting, setIsResetting] = useState(false); + + const handleReset = async () => { + if (resetConfirmText !== "RESET") return; + setIsResetting(true); + try { + await onResetProgress(); + setShowResetModal(false); + setResetConfirmText(""); + } catch (error) { + // Error handling relies on parent + } finally { + setIsResetting(false); + } + }; + + return ( + <> + + Reset Progress + + + + + + + + Start Fresh + + Delete all swipes, matches, and chats while keeping your partner connection. + + + + setShowResetModal(true)} + activeOpacity={0.7} + > + + Reset + + + + + + !isResetting && setShowResetModal(false)} + > + + + + + + + + Reset Progress? + + + + This action cannot be undone. This will permanently delete: + + + + + + All your swipes + + + + All matches + + + + All chat messages + + + + + + + Your partner connection stays + + + + + Type RESET to confirm: + + + + + + { + setShowResetModal(false); + setResetConfirmText(""); + }} + disabled={isResetting} + activeOpacity={0.7} + > + Cancel + + + + {isResetting ? ( + + ) : ( + <> + + Reset + + )} + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + section: { + paddingHorizontal: spacing.lg, + marginBottom: spacing.lg, + }, + sectionTitle: { + ...typography.caption1, + fontWeight: '600', + letterSpacing: 2, + color: colors.textTertiary, + marginBottom: spacing.sm, + textTransform: 'uppercase', + }, + content: { + gap: spacing.md, + }, + info: { + flexDirection: "row", + alignItems: "flex-start", + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.warningLight, + justifyContent: "center", + alignItems: "center", + }, + textContainer: { + flex: 1, + marginLeft: spacing.md, + }, + title: { + ...typography.headline, + color: colors.text, + marginBottom: spacing.xs, + }, + description: { + ...typography.subhead, + color: colors.textSecondary, + lineHeight: 20, + }, + resetButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.warningLight, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.3)', + gap: spacing.xs, + alignSelf: "flex-end", + }, + resetButtonText: { + ...typography.subhead, + color: colors.warning, + fontWeight: "600", + }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "center", + alignItems: "center", + padding: spacing.lg, + }, + modalContent: { + width: "100%", + maxWidth: 400, + padding: spacing.xl, + backgroundColor: colors.backgroundLight, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: colors.glass.border, + }, + modalHeader: { + alignItems: "center", + marginBottom: spacing.lg, + }, + modalIconContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: colors.warningLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + modalTitle: { + ...typography.title2, + color: colors.text, + textAlign: "center", + }, + modalDescription: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.lg, + textAlign: "center", + }, + resetList: { + backgroundColor: "rgba(243, 156, 18, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.2)', + }, + resetListItem: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + resetListText: { + ...typography.subhead, + color: colors.text, + }, + keepList: { + backgroundColor: "rgba(46, 204, 113, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.xl, + borderWidth: 1, + borderColor: 'rgba(46, 204, 113, 0.2)', + }, + keepListItem: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + keepListText: { + ...typography.subhead, + color: colors.text, + }, + confirmLabel: { + ...typography.subhead, + color: colors.textSecondary, + marginBottom: spacing.xs, + textAlign: "center", + }, + confirmKeyword: { + fontWeight: "bold", + color: colors.warning, + }, + confirmInput: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.warning, + padding: spacing.md, + ...typography.body, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.xl, + }, + modalButtons: { + flexDirection: "row", + gap: spacing.md, + }, + cancelButton: { + flex: 1, + backgroundColor: colors.glass.backgroundLight, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + borderWidth: 1, + borderColor: colors.glass.borderLight, + }, + cancelButtonText: { + ...typography.body, + color: colors.textSecondary, + fontWeight: "600", + }, + confirmResetButton: { + flex: 1, + backgroundColor: colors.warning, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + flexDirection: "row", + justifyContent: "center", + gap: spacing.xs, + }, + confirmResetButtonDisabled: { + opacity: 0.5, + }, + confirmResetButtonText: { + ...typography.body, + color: colors.text, + fontWeight: "600", + }, +}); diff --git a/apps/mobile/src/features/profile/components/index.ts b/apps/mobile/src/features/profile/components/index.ts index 1f1c484..f575a76 100644 --- a/apps/mobile/src/features/profile/components/index.ts +++ b/apps/mobile/src/features/profile/components/index.ts @@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings'; export { CoupleStatus } from './CoupleStatus'; export { PrivacySettings } from './PrivacySettings'; export { DangerZone } from './DangerZone'; +export { ResetProgress } from './ResetProgress'; export { SettingsSection } from './SettingsSection'; export type { SettingsSectionProps } from './SettingsSection'; diff --git a/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts b/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts index c4657b6..463f830 100644 --- a/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts +++ b/apps/mobile/src/features/profile/hooks/useCoupleManagement.ts @@ -66,37 +66,22 @@ export function useCoupleManagement() { }; const handleResetProgress = async () => { - Alert.alert( - "Reset Progress", - "This will delete all your swipes, matches, and chat messages. You'll stay connected with your partner and can start fresh. This cannot be undone.", - [ - { text: "Cancel", style: "cancel" }, - { - text: "Reset", - style: "destructive", - onPress: async () => { - setIsResettingProgress(true); - try { - const { error } = await supabase.functions.invoke("reset-couple-progress", { - method: "DELETE", - }); + try { + const { error } = await supabase.functions.invoke("reset-couple-progress", { + method: "DELETE", + }); - if (error) throw error; + if (error) throw error; - // Clear local stores - clearMatches(); - clearMessages(); + // Clear local stores + clearMatches(); + clearMessages(); - Alert.alert("Success", "Your progress has been reset. You can now start swiping again!"); - } catch (error) { - Alert.alert("Error", "Failed to reset progress. Please try again."); - } finally { - setIsResettingProgress(false); - } - }, - }, - ] - ); + Alert.alert("Success", "Your progress has been reset. You can now start swiping again!"); + } catch (error) { + Alert.alert("Error", "Failed to reset progress. Please try again."); + throw error; + } }; const handleSignOut = () => { diff --git a/apps/supabase/tests/deno.json b/apps/supabase/tests/deno.json new file mode 100644 index 0000000..a22f25d --- /dev/null +++ b/apps/supabase/tests/deno.json @@ -0,0 +1,9 @@ +{ + "imports": { + "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2", + "std/": "https://deno.land/std@0.224.0/" + }, + "tasks": { + "test": "deno test --allow-net --allow-env --allow-read" + } +} diff --git a/apps/supabase/tests/redeem-code.test.ts b/apps/supabase/tests/redeem-code.test.ts new file mode 100644 index 0000000..b37494c --- /dev/null +++ b/apps/supabase/tests/redeem-code.test.ts @@ -0,0 +1,48 @@ + +import { assertEquals } from "std/testing/asserts.ts"; +import { createTestUser, createRedemptionCode, cleanup, adminClient, SUPABASE_URL } from "./utils.ts"; + +const FUNCTION_URL = `${SUPABASE_URL}/functions/v1/redeem-code`; + +Deno.test("redeem-code: successfully redeems code for user", async () => { + const email = `test_redeem_${Date.now()}@example.com`; + let userIds: string[] = []; + let codeId: string | undefined; + + try { + // 1. Setup User + const user = await createTestUser(email); + userIds = [user.id]; + + // 2. Setup Code + const codeData = await createRedemptionCode(); + codeId = codeData.id; + + // 3. Call Function + const res = await fetch(FUNCTION_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + code: codeData.code + }) + }); + + const data = await res.json(); + assertEquals(res.status, 200); + assertEquals(data.success, true); + + // 4. Verify Profile is Premium + const { data: profile } = await adminClient + .from("profiles") + .select("is_premium") + .eq("id", user.id) + .single(); + + assertEquals(profile.is_premium, true); + + } finally { + await cleanup(userIds); + if (codeId) await adminClient.from("redemption_codes").delete().eq("id", codeId); + } +}); diff --git a/apps/supabase/tests/submit-response.test.ts b/apps/supabase/tests/submit-response.test.ts new file mode 100644 index 0000000..96fa385 --- /dev/null +++ b/apps/supabase/tests/submit-response.test.ts @@ -0,0 +1,87 @@ + +import { assert, assertEquals } from "std/testing/asserts.ts"; +import { createTestUser, createCouple, cleanup, signInUser, createTestQuestion, adminClient, SUPABASE_URL } from "./utils.ts"; + +const FUNCTION_URL = `${SUPABASE_URL}/functions/v1/submit-response`; + +Deno.test("submit-response: creates match when both users answer yes", async () => { + const email1 = `test_user_1_${Date.now()}@example.com`; + const email2 = `test_user_2_${Date.now()}@example.com`; + let coupleId: string | undefined; + let questionId: string | undefined; + let packId: string | undefined; + let users: string[] = []; + + try { + // 1. Setup Users and Couple + const user1 = await createTestUser(email1); + const user2 = await createTestUser(email2); + users = [user1.id, user2.id]; + + const couple = await createCouple(user1.id, user2.id); + coupleId = couple.id; + + // 2. Setup Question + const qData = await createTestQuestion(); + questionId = qData.question!.id; + packId = qData.packId; + + // 3. Sign In Users to get tokens + const session1 = await signInUser(email1); + const session2 = await signInUser(email2); + + assert(session1?.access_token, "User 1 should have access token"); + assert(session2?.access_token, "User 2 should have access token"); + + // 4. User 1 submits 'yes' + const res1 = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${session1!.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + question_id: questionId, + answer: "yes", + }), + }); + + const data1 = await res1.json(); + assertEquals(res1.status, 200); + assertEquals(data1.match, null, "First response should not create match"); + + // 5. User 2 submits 'yes' + const res2 = await fetch(FUNCTION_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${session2!.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + question_id: questionId, + answer: "yes", + }), + }); + + const data2 = await res2.json(); + assertEquals(res2.status, 200); + assert(data2.match, "Second response should create match"); + assertEquals(data2.match.match_type, "yes_yes"); + + // 6. Verify Match in DB + const { data: match } = await adminClient + .from("matches") + .select("*") + .eq("id", data2.match.id) + .single(); + + assert(match, "Match should exist in DB"); + assertEquals(match.match_type, "yes_yes"); + + } finally { + // Cleanup + await cleanup(users, coupleId); + if (questionId) await adminClient.from("questions").delete().eq("id", questionId); + if (packId) await adminClient.from("question_packs").delete().eq("id", packId); + } +}); diff --git a/apps/supabase/tests/utils.ts b/apps/supabase/tests/utils.ts new file mode 100644 index 0000000..00f8ad6 --- /dev/null +++ b/apps/supabase/tests/utils.ts @@ -0,0 +1,113 @@ + +import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { assert } from "std/testing/asserts.ts"; + +export const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "http://127.0.0.1:54321"; +export const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + +if (!SUPABASE_SERVICE_ROLE_KEY) { + throw new Error("SUPABASE_SERVICE_ROLE_KEY is required"); +} + +export const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, +}); + +export async function createTestUser(email: string) { + const { data, error } = await adminClient.auth.admin.createUser({ + email, + password: "password123", + email_confirm: true, + }); + + if (error) throw error; + if (!data.user) throw new Error("User not created"); + + return data.user; +} + +export async function signInUser(email: string) { + const { data, error } = await adminClient.auth.signInWithPassword({ + email, + password: "password123", + }); + + if (error) throw error; + return data.session; +} + +export async function createTestQuestion(packId?: string) { + let finalPackId = packId; + if (!finalPackId) { + const { data: pack } = await adminClient.from("question_packs").insert({ + name: "Test Pack " + Math.random(), + is_premium: false, + sort_order: 999 + }).select().single(); + finalPackId = pack!.id; + } + + const { data: question } = await adminClient.from("questions").insert({ + pack_id: finalPackId, + text: "Test Question?", + intensity: 1 + }).select().single(); + + return { question, packId: finalPackId }; +} + +export async function createRedemptionCode() { + const code = "TEST-" + Math.random().toString(36).substring(7).toUpperCase(); + const { data, error } = await adminClient.from("redemption_codes").insert({ + code, + max_uses: 1, + active: true, + expires_at: new Date(Date.now() + 3600000).toISOString() // 1 hour + }).select().single(); + + if (error) throw error; + return data; +} + +export async function createCouple(user1Id: string, user2Id: string) { + // 1. Create a couple + const { data: couple, error: coupleError } = await adminClient + .from("couples") + .insert({ + // invite_code is unique, so generate a random one + invite_code: Math.random().toString(36).substring(7), + }) + .select() + .single(); + + if (coupleError) throw coupleError; + + // 2. Link users to couple + const { error: updateError } = await adminClient + .from("profiles") + .update({ couple_id: couple.id }) + .in("id", [user1Id, user2Id]); + + if (updateError) throw updateError; + + return couple; +} + +export async function cleanup(userIds: string[], coupleId?: string) { + // Delete couple (should cascade to matches, responses, etc. if configured, but let's be safe) + // Based on schema analysis, explicit cleanup is safer. + + if (coupleId) { + await adminClient.from("matches").delete().eq("couple_id", coupleId); + await adminClient.from("responses").delete().eq("couple_id", coupleId); + await adminClient.from("couples").delete().eq("id", coupleId); + } + + // Delete users (cascades to profiles) + for (const id of userIds) { + await adminClient.auth.admin.deleteUser(id); + } +} diff --git a/scripts/debug-deletion.ts b/scripts/debug-deletion.ts new file mode 100644 index 0000000..a955a48 --- /dev/null +++ b/scripts/debug-deletion.ts @@ -0,0 +1,61 @@ + +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = process.env.SUPABASE_URL!; +const supabaseKey = process.env.SUPABASE_SERVICE_KEY!; +const supabase = createClient(supabaseUrl, supabaseKey); + +const userId = 'a4b92675-5cea-4e26-a581-2e7fcd9c4789'; // Mark +const matchId = 'bd7e742e-137a-43c2-a9b0-9c2e0b7a8d5f'; // Guessing match ID from message or I can find it + +async function test() { + // 1. Get the match ID from the message + const { data: msg } = await supabase + .from('messages') + .select('match_id') + .eq('id', 'e9741569-166d-4742-bd85-b185db352ff4') + .single(); + + if (!msg) { + console.log('Message not found'); + return; + } + + const matchId = msg.match_id; + console.log('Match ID:', matchId); + + // 2. Fetch all messages + const { data: allMessages } = await supabase + .from('messages') + .select('*') + .eq('match_id', matchId) + .order('created_at', { ascending: false }); + + console.log(`Fetched ${allMessages?.length} messages`); + + // 3. Fetch deletions + const { data: deletions } = await supabase + .from('message_deletions') + .select('message_id') + .eq('user_id', userId); + + console.log(`Fetched ${deletions?.length} deletions`); + + const deletedIds = new Set(deletions?.map(d => d.message_id)); + console.log('Deleted IDs:', Array.from(deletedIds)); + + // 4. Filter + const filteredMessages = allMessages?.filter(m => !deletedIds.has(m.id)); + + // 5. Check if the image message is present + const imageMsgId = 'e9741569-166d-4742-bd85-b185db352ff4'; + const found = filteredMessages?.find(m => m.id === imageMsgId); + + if (found) { + console.log('FAIL: Image message still present in filtered list!'); + } else { + console.log('SUCCESS: Image message was filtered out.'); + } +} + +test();