From e750b02781276841650bf933d21889e17a1afe3a Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 12 Dec 2025 00:24:53 -0500 Subject: [PATCH 1/4] added notification settings to local storage and synced to supabase --- frontend/app/settings/notifications.tsx | 116 +++---- frontend/constants/supabase.ts | 13 +- frontend/hooks/use-notification-settings.ts | 125 +++++++ .../services/push-notification-service.ts | 316 +++++++++++------- .../20251212000000_notification_settings.sql | 71 ++++ frontend/types/database.ts | 32 ++ frontend/types/notifications.ts | 7 + 7 files changed, 501 insertions(+), 179 deletions(-) create mode 100644 frontend/hooks/use-notification-settings.ts create mode 100644 frontend/supabase/migrations/20251212000000_notification_settings.sql diff --git a/frontend/app/settings/notifications.tsx b/frontend/app/settings/notifications.tsx index d11d1d7..dcf9e16 100644 --- a/frontend/app/settings/notifications.tsx +++ b/frontend/app/settings/notifications.tsx @@ -1,10 +1,13 @@ -import React, { useState } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, SafeAreaView, ScrollView, Switch } from 'react-native'; +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Switch } from 'react-native'; import { router } from 'expo-router'; -import { ArrowLeft, Bell, MessageCircle, Users, Calendar } from 'lucide-react-native'; +import { ArrowLeft, Bell, MessageCircle, Users, Calendar, UserPlus } from 'lucide-react-native'; +import { NotificationSettings } from '@/types/notifications'; +import { useNotificationSettings } from '@/hooks/use-notification-settings'; +import { SafeAreaView } from 'react-native-safe-area-context'; interface NotificationSetting { - id: string; + id: NotificationSettings; title: string; description: string; icon: any; @@ -12,69 +15,48 @@ interface NotificationSetting { color: string; } +const DEFAULT_SETTINGS: NotificationSetting[] = [ + { + id: NotificationSettings.PUSH_NOTIFICATIONS, + title: 'Push Notifications', + description: 'Receive notifications on your device', + icon: Bell, + enabled: true, + color: '#8B5CF6', + }, + { + id: NotificationSettings.FRIEND_ACTIVITY, + title: 'Friend Activity', + description: 'When friends share moments with you', + icon: Users, + enabled: true, + color: '#059669', + }, + { + id: NotificationSettings.ENTRY_REMINDER, + title: 'Memory Reminders', + description: 'Daily prompts to capture moments', + icon: Calendar, + enabled: false, + color: '#F59E0B', + }, + { + id: NotificationSettings.FRIEND_REQUESTS, + title: 'Friend Requests', + description: 'When someone sends you a friend request or accepts your request', + icon: UserPlus, + enabled: true, + color: '#EF4444', + }, +]; + export default function NotificationsScreen() { - const [settings, setSettings] = useState([ - { - id: 'push', - title: 'Push Notifications', - description: 'Receive notifications on your device', - icon: Bell, - enabled: true, - color: '#8B5CF6', - }, - { - id: 'friends', - title: 'Friend Activity', - description: 'When friends share moments with you', - icon: Users, - enabled: true, - color: '#059669', - }, - { - id: 'memories', - title: 'Memory Reminders', - description: 'Daily prompts to capture moments', - icon: Calendar, - enabled: false, - color: '#F59E0B', - }, - { - id: 'comments', - title: 'Comments & Reactions', - description: 'When someone reacts to your moments', - icon: MessageCircle, - enabled: true, - color: '#EF4444', - }, - ]); + const { settings: settingsMap, toggleSetting } = useNotificationSettings(); - const toggleSetting = (id: string) => { - setSettings(prev => { - if (id === 'push') { - // If turning off push notifications, turn off all others - const pushEnabled = !prev.find(s => s.id === 'push')?.enabled; - if (!pushEnabled) { - return prev.map(setting => ({ ...setting, enabled: false })); - } else { - return prev.map(setting => - setting.id === 'push' ? { ...setting, enabled: true } : setting - ); - } - } else { - // For other settings, only allow toggle if push is enabled - const pushEnabled = prev.find(s => s.id === 'push')?.enabled; - if (!pushEnabled) { - return prev; // Don't allow changes if push is disabled - } - - return prev.map(setting => - setting.id === id - ? { ...setting, enabled: !setting.enabled } - : setting - ); - } - }); - }; + const settings: NotificationSetting[] = DEFAULT_SETTINGS.map((setting) => ({ + ...setting, + enabled: settingsMap[setting.id] ?? setting.enabled, + })); return ( @@ -100,6 +82,8 @@ export default function NotificationsScreen() { {settings.map((setting) => { const IconComponent = setting.icon; + const pushEnabled = settingsMap[NotificationSettings.PUSH_NOTIFICATIONS]; + return ( @@ -114,7 +98,7 @@ export default function NotificationsScreen() { toggleSetting(setting.id)} - disabled={setting.id !== 'push' && !settings.find(s => s.id === 'push')?.enabled} + disabled={setting.id !== NotificationSettings.PUSH_NOTIFICATIONS && !pushEnabled} trackColor={{ false: '#E5E7EB', true: '#C7D2FE' }} thumbColor={setting.enabled ? '#8B5CF6' : '#F3F4F6'} /> diff --git a/frontend/constants/supabase.ts b/frontend/constants/supabase.ts index bf08951..e033f0a 100644 --- a/frontend/constants/supabase.ts +++ b/frontend/constants/supabase.ts @@ -9,7 +9,8 @@ export const TABLES = { INVITES: 'invites', ENTRY_REACTIONS: 'entry_reactions', ENTRY_COMMENTS: 'entry_comments', - PUSH_TOKENS: 'push_tokens' + PUSH_TOKENS: 'push_tokens', + NOTIFICATION_SETTINGS: 'notification_settings', } as const; // Storage Bucket Names @@ -99,5 +100,15 @@ export const SCHEMA = { device_id: 'text', created_at: 'timestamptz DEFAULT now()', updated_at: 'timestamptz DEFAULT now()', + }, + NOTIFICATION_SETTINGS: { + id: 'uuid PRIMARY KEY DEFAULT gen_random_uuid()', + user_id: 'uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE', + friend_requests: 'boolean NOT NULL DEFAULT true', + push_notifications: 'boolean NOT NULL DEFAULT true', + entry_reminder: 'boolean NOT NULL DEFAULT false', + friend_activity: 'boolean NOT NULL DEFAULT true', + created_at: 'timestamptz DEFAULT now()', + updated_at: 'timestamptz DEFAULT now()', } } as const; \ No newline at end of file diff --git a/frontend/hooks/use-notification-settings.ts b/frontend/hooks/use-notification-settings.ts new file mode 100644 index 0000000..1980927 --- /dev/null +++ b/frontend/hooks/use-notification-settings.ts @@ -0,0 +1,125 @@ +import { useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useAuthContext } from '@/providers/auth-provider'; +import { + PushNotificationService, + NotificationSettingsMap, +} from '@/services/push-notification-service'; +import { NotificationSettings } from '@/types/notifications'; + +const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettingsMap = { + [NotificationSettings.PUSH_NOTIFICATIONS]: true, + [NotificationSettings.FRIEND_ACTIVITY]: true, + [NotificationSettings.ENTRY_REMINDER]: false, + [NotificationSettings.FRIEND_REQUESTS]: true, +}; + +interface UseNotificationSettingsResult { + settings: NotificationSettingsMap; + isLoading: boolean; + isSaving: boolean; + error: Error | null; + toggleSetting: (id: NotificationSettings) => void; +} + +export function useNotificationSettings(): UseNotificationSettingsResult { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + + const queryKey = useMemo( + () => ['notification-settings', user?.id], + [user?.id], + ); + + const { + data, + isLoading, + error, + } = useQuery({ + queryKey, + enabled: !!user?.id, + queryFn: async () => { + if (!user?.id) return DEFAULT_NOTIFICATION_SETTINGS; + + const stored = await PushNotificationService.getNotificationSettings(user.id); + return stored ?? DEFAULT_NOTIFICATION_SETTINGS; + }, + }); + + const { mutate: saveSettings, isPending: isSaving, error: mutationError } = useMutation({ + mutationFn: async (next: NotificationSettingsMap) => { + if (!user?.id) return; + await PushNotificationService.saveNotificationSettings(user.id, next); + return next; + }, + onMutate: async (next: NotificationSettingsMap) => { + await queryClient.cancelQueries({ queryKey }); + const previous = queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, next); + + return { previous }; + }, + onError: (_err, _next, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKey, context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey }); + }, + }); + + const currentSettings: NotificationSettingsMap = data ?? DEFAULT_NOTIFICATION_SETTINGS; + + const toggleSetting = (id: NotificationSettings) => { + if (!user?.id) return; + + const isPushToggle = id === NotificationSettings.PUSH_NOTIFICATIONS; + const pushCurrentlyEnabled = currentSettings[NotificationSettings.PUSH_NOTIFICATIONS]; + + let next: NotificationSettingsMap = { ...currentSettings }; + + if (isPushToggle) { + const nextPushEnabled = !pushCurrentlyEnabled; + + if (!nextPushEnabled) { + // Turning off push disables all notifications + next = { + [NotificationSettings.PUSH_NOTIFICATIONS]: false, + [NotificationSettings.FRIEND_ACTIVITY]: false, + [NotificationSettings.ENTRY_REMINDER]: false, + [NotificationSettings.FRIEND_REQUESTS]: false, + }; + } else { + // Turning on push only enables the push toggle, keep others as-is + next = { + ...currentSettings, + [NotificationSettings.PUSH_NOTIFICATIONS]: true, + }; + } + } else { + // For other settings, only allow toggle if push is enabled + if (!pushCurrentlyEnabled) { + return; + } + + next = { + ...currentSettings, + [id]: !currentSettings[id], + }; + } + + saveSettings(next); + }; + + return { + settings: currentSettings, + isLoading, + isSaving, + error: (error as Error | null) ?? (mutationError as Error | null) ?? null, + toggleSetting, + }; +} + + diff --git a/frontend/services/push-notification-service.ts b/frontend/services/push-notification-service.ts index 4889da5..e9bef7c 100644 --- a/frontend/services/push-notification-service.ts +++ b/frontend/services/push-notification-service.ts @@ -4,128 +4,220 @@ import Constants from "expo-constants"; import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; +import { deviceStorage } from '@/services/device-storage'; +import { NotificationSettings } from '@/types/notifications'; +export type NotificationSettingsMap = Record; + +const NOTIFICATION_SETTINGS_STORAGE_KEY = (userId: string) => + `notification_settings_${userId}`; export class PushNotificationService { - private supabase: SupabaseClient = supabase; - private currentToken: string | null = null; - private userId: string | null = null; - - - // Initialize push notifications - async initialize(userId?: string): Promise { - try { - this.userId = userId || null; - - // Check if device supports push notifications - if (!Device.isDevice) { - console.warn('Push notifications only work on physical devices'); - return null; - } - - // Request permissions - const { status: existingStatus } = await Notifications.getPermissionsAsync(); - let finalStatus = existingStatus; - - if (existingStatus !== 'granted') { - const { status } = await Notifications.requestPermissionsAsync(); - finalStatus = status; - } - - if (finalStatus !== 'granted') { - console.warn('Permission for push notifications denied'); - return null; - } - - if (Platform.OS === 'android') { - await Notifications.setNotificationChannelAsync('default', { - name: 'default', - importance: Notifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#FF231F7C', - }); - } - - // Get push token - const tokenData = await Notifications.getExpoPushTokenAsync({ - projectId: Constants.expoConfig?.extra?.eas?.projectId || Constants.easConfig?.projectId, - }); - - console.log('Push token:', this.currentToken); - - // Save token to Supabase - if (this.userId && !this.currentToken) { - this.currentToken = tokenData.data; - //await this.savePushToken(this.currentToken, this.userId); - } - - return this.currentToken; - } catch (error) { - console.error('Error initializing push notifications:', error); + private supabase: SupabaseClient = supabase; + private currentToken: string | null = null; + private userId: string | null = null; + + // Initialize push notifications + async initialize(userId?: string): Promise { + try { + this.userId = userId || null; + + // Check if device supports push notifications + if (!Device.isDevice) { + console.warn('Push notifications only work on physical devices'); return null; } + + // Request permissions + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + + if (existingStatus !== 'granted') { + const { status } = await Notifications.requestPermissionsAsync(); + finalStatus = status; + } + + if (finalStatus !== 'granted') { + console.warn('Permission for push notifications denied'); + return null; + } + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + } + + // Get push token + const tokenData = await Notifications.getExpoPushTokenAsync({ + projectId: Constants.expoConfig?.extra?.eas?.projectId || Constants.easConfig?.projectId, + }); + + console.log('Push token:', this.currentToken); + + // Save token to Supabase + if (this.userId && !this.currentToken) { + this.currentToken = tokenData.data; + //await this.savePushToken(this.currentToken, this.userId); + } + + return this.currentToken; + } catch (error) { + console.error('Error initializing push notifications:', error); + return null; } - - // Save push token to Supabase - static async savePushToken(token: string, userId: string): Promise { - try { - const deviceId = Constants.installationId || Device.osName; - const platform = Platform.OS as 'ios' | 'android'; - - const { error } = await supabase - .from('push_tokens') - .upsert({ - user_id: userId, - token: token, - platform: platform, - device_id: deviceId, - updated_at: new Date().toISOString(), - } as never, { - onConflict: 'user_id,device_id' - } as never); - - if (error) { - console.error('Error saving push token:', error); - } else { - console.log('Push token saved successfully'); - } - } catch (error) { - console.error('Error in savePushToken:', error); + } + + // Save push token to Supabase + static async savePushToken(token: string, userId: string): Promise { + try { + const deviceId = Constants.installationId || Device.osName; + const platform = Platform.OS as 'ios' | 'android'; + + const { error } = await supabase + .from('push_tokens') + .upsert({ + user_id: userId, + token: token, + platform: platform, + device_id: deviceId, + updated_at: new Date().toISOString(), + } as never, { + onConflict: 'user_id,device_id' + } as never); + + if (error) { + console.error('Error saving push token:', error); + } else { + console.log('Push token saved successfully'); } + } catch (error) { + console.error('Error in savePushToken:', error); } - - // Remove push token (logout) - async removePushToken(userId: string): Promise { - try { - const deviceId = Constants.installationId || Device.osName; - - const { error } = await this.supabase - .from('push_tokens') - .delete() - .eq('user_id', userId) - .eq('device_id', deviceId); - - if (error) { - console.error('Error removing push token:', error); - } else { - this.currentToken = null; - console.log('Push token removed successfully'); - } - } catch (error) { - console.error('Error in removePushToken:', error); + } + + // Remove push token (logout) + async removePushToken(userId: string): Promise { + try { + const deviceId = Constants.installationId || Device.osName; + + const { error } = await this.supabase + .from('push_tokens') + .delete() + .eq('user_id', userId) + .eq('device_id', deviceId); + + if (error) { + console.error('Error removing push token:', error); + } else { + this.currentToken = null; + console.log('Push token removed successfully'); } + } catch (error) { + console.error('Error in removePushToken:', error); } - - // Get current push token - getCurrentToken(): string | null { - return this.currentToken; + } + + // Get current push token + getCurrentToken(): string | null { + return this.currentToken; + } + + // Update user ID and save token + async updateUserId(newUserId: string): Promise { + this.userId = newUserId; + if (this.currentToken) { + //await this.savePushToken(this.currentToken, newUserId); } - - // Update user ID and save token - async updateUserId(newUserId: string): Promise { - this.userId = newUserId; - if (this.currentToken) { - //await this.savePushToken(this.currentToken, newUserId); + } + + /** + * Load notification settings for a user. + * Priority: + * 1) Local device storage + * 2) Supabase `notification_settings` table + */ + static async getNotificationSettings(userId: string): Promise { + try { + // 1. Try local storage first + const local = await deviceStorage.getItem( + NOTIFICATION_SETTINGS_STORAGE_KEY(userId), + ); + if (local) { + return local; + } + + // 2. Fallback to Supabase (single row per user) + const { data, error } = await supabase + .from('notification_settings') + .select('friend_requests, push_notifications, entry_reminder, friend_activity') + .eq('user_id', userId) + .maybeSingle<{ + friend_requests: boolean | null; + push_notifications: boolean | null; + entry_reminder: boolean | null; + friend_activity: boolean | null; + }>(); + + if (error) { + console.error('Error fetching notification settings from Supabase:', error); + return null; + } + + if (!data) { + return null; + } + + const fromRemote: NotificationSettingsMap = { + [NotificationSettings.FRIEND_REQUESTS]: data.friend_requests ?? true, + [NotificationSettings.PUSH_NOTIFICATIONS]: data.push_notifications ?? true, + [NotificationSettings.ENTRY_REMINDER]: data.entry_reminder ?? false, + [NotificationSettings.FRIEND_ACTIVITY]: data.friend_activity ?? true, + }; + + // Cache remotely-loaded settings locally + await deviceStorage.setItem(NOTIFICATION_SETTINGS_STORAGE_KEY(userId), fromRemote); + + return fromRemote; + } catch (error) { + console.error('Error in getNotificationSettings:', error); + return null; + } + } + + /** + * Save notification settings for a user. + * Writes to local storage first, then syncs to Supabase. + */ + static async saveNotificationSettings( + userId: string, + settings: NotificationSettingsMap, + ): Promise { + try { + // 1. Save to local storage + await deviceStorage.setItem(NOTIFICATION_SETTINGS_STORAGE_KEY(userId), settings); + + // 2. Sync to Supabase (one row per user) + const row = { + user_id: userId, + friend_requests: settings[NotificationSettings.FRIEND_REQUESTS], + push_notifications: settings[NotificationSettings.PUSH_NOTIFICATIONS], + entry_reminder: settings[NotificationSettings.ENTRY_REMINDER], + friend_activity: settings[NotificationSettings.FRIEND_ACTIVITY], + }; + + const { error } = await supabase + .from('notification_settings') + .upsert(row as never, { onConflict: 'user_id' } as never); + + if (error) { + console.error('Error saving notification settings to Supabase:', error); } + } catch (error) { + console.error('Error in saveNotificationSettings:', error); } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/frontend/supabase/migrations/20251212000000_notification_settings.sql b/frontend/supabase/migrations/20251212000000_notification_settings.sql new file mode 100644 index 0000000..e4ad8a6 --- /dev/null +++ b/frontend/supabase/migrations/20251212000000_notification_settings.sql @@ -0,0 +1,71 @@ +/* + # Notification Settings Table + + 1. New Table + - `notification_settings` - Per-user notification preferences (1 row per user) + - `id` (bigint, primary key) + - `user_id` (uuid, references profiles) + - `friend_requests` (boolean) + - `push_notifications` (boolean) + - `entry_reminder` (boolean) + - `friend_activity` (boolean) + - `created_at` (timestamp) + - `updated_at` (timestamp) + + 2. Security + - Enable RLS + - Policies so users can manage only their own notification settings +*/ + +-- Create notification_settings table +CREATE TABLE IF NOT EXISTS notification_settings ( + id bigserial PRIMARY KEY, + user_id uuid REFERENCES profiles(id) ON DELETE CASCADE NOT NULL, + friend_requests boolean NOT NULL DEFAULT true, + push_notifications boolean NOT NULL DEFAULT true, + entry_reminder boolean NOT NULL DEFAULT false, + friend_activity boolean NOT NULL DEFAULT true, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE(user_id) +); + +-- Enable Row Level Security +ALTER TABLE notification_settings ENABLE ROW LEVEL SECURITY; + +-- Policies: users can manage their own notification settings +CREATE POLICY "Users can read own notification settings" + ON notification_settings + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own notification settings" + ON notification_settings + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own notification settings" + ON notification_settings + FOR UPDATE + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own notification settings" + ON notification_settings + FOR DELETE + TO authenticated + USING (auth.uid() = user_id); + +-- Indexes for performance +CREATE INDEX IF NOT EXISTS idx_notification_settings_user_id + ON notification_settings(user_id); + +-- updated_at trigger +CREATE TRIGGER update_notification_settings_updated_at + BEFORE UPDATE ON notification_settings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + + diff --git a/frontend/types/database.ts b/frontend/types/database.ts index ae70381..cabc716 100644 --- a/frontend/types/database.ts +++ b/frontend/types/database.ts @@ -227,6 +227,38 @@ export interface Database { created_at?: string } } + notification_settings: { + Row: { + id: number + user_id: string + friend_requests: boolean + push_notifications: boolean + entry_reminder: boolean + friend_activity: boolean + created_at: string + updated_at: string + } + Insert: { + id?: number + user_id: string + friend_requests?: boolean + push_notifications?: boolean + entry_reminder?: boolean + friend_activity?: boolean + created_at?: string + updated_at?: string + } + Update: { + id?: number + user_id?: string + friend_requests?: boolean + push_notifications?: boolean + entry_reminder?: boolean + friend_activity?: boolean + created_at?: string + updated_at?: string + } + } } Views: { [_ in never]: never diff --git a/frontend/types/notifications.ts b/frontend/types/notifications.ts index c07180e..a909c76 100644 --- a/frontend/types/notifications.ts +++ b/frontend/types/notifications.ts @@ -23,4 +23,11 @@ export interface SupabaseNotification extends NotificationPayload { status?: 'pending' | 'sent' | 'failed'; expo_ticket_id?: string; expo_receipt_id?: string; +} + +export enum NotificationSettings { + FRIEND_REQUESTS = 'friend_requests', + PUSH_NOTIFICATIONS = 'push_notifications', + ENTRY_REMINDER = 'entry_reminder', + FRIEND_ACTIVITY = 'friend_activity', } \ No newline at end of file From 5735f9854319ecf53cafc6e5a44dd527619a9b82 Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 12 Dec 2025 01:15:13 -0500 Subject: [PATCH 2/4] fixed code issues --- frontend/constants/supabase.ts | 2 +- .../services/push-notification-service.ts | 40 ++++++++++--------- .../20251212000000_notification_settings.sql | 1 + 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/frontend/constants/supabase.ts b/frontend/constants/supabase.ts index e033f0a..41a8fac 100644 --- a/frontend/constants/supabase.ts +++ b/frontend/constants/supabase.ts @@ -102,7 +102,7 @@ export const SCHEMA = { updated_at: 'timestamptz DEFAULT now()', }, NOTIFICATION_SETTINGS: { - id: 'uuid PRIMARY KEY DEFAULT gen_random_uuid()', + id: 'bigserial PRIMARY KEY', user_id: 'uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE', friend_requests: 'boolean NOT NULL DEFAULT true', push_notifications: 'boolean NOT NULL DEFAULT true', diff --git a/frontend/services/push-notification-service.ts b/frontend/services/push-notification-service.ts index e9bef7c..ea8aaa2 100644 --- a/frontend/services/push-notification-service.ts +++ b/frontend/services/push-notification-service.ts @@ -196,28 +196,30 @@ export class PushNotificationService { userId: string, settings: NotificationSettingsMap, ): Promise { + // 1. Save to local storage (best-effort cache; don't throw) try { - // 1. Save to local storage await deviceStorage.setItem(NOTIFICATION_SETTINGS_STORAGE_KEY(userId), settings); - - // 2. Sync to Supabase (one row per user) - const row = { - user_id: userId, - friend_requests: settings[NotificationSettings.FRIEND_REQUESTS], - push_notifications: settings[NotificationSettings.PUSH_NOTIFICATIONS], - entry_reminder: settings[NotificationSettings.ENTRY_REMINDER], - friend_activity: settings[NotificationSettings.FRIEND_ACTIVITY], - }; - - const { error } = await supabase - .from('notification_settings') - .upsert(row as never, { onConflict: 'user_id' } as never); - - if (error) { - console.error('Error saving notification settings to Supabase:', error); - } } catch (error) { - console.error('Error in saveNotificationSettings:', error); + console.error('Error caching notification settings locally:', error); + // Continue; remote save is the source of truth for mutations + } + + // 2. Sync to Supabase (one row per user) - failures MUST throw + const row = { + user_id: userId, + friend_requests: settings[NotificationSettings.FRIEND_REQUESTS], + push_notifications: settings[NotificationSettings.PUSH_NOTIFICATIONS], + entry_reminder: settings[NotificationSettings.ENTRY_REMINDER], + friend_activity: settings[NotificationSettings.FRIEND_ACTIVITY], + }; + + const { error } = await supabase + .from('notification_settings') + .upsert(row as never, { onConflict: 'user_id' } as never); + + if (error) { + console.error('Error saving notification settings to Supabase:', error); + throw new Error(error.message || 'Failed to save notification settings'); } } } \ No newline at end of file diff --git a/frontend/supabase/migrations/20251212000000_notification_settings.sql b/frontend/supabase/migrations/20251212000000_notification_settings.sql index e4ad8a6..2d072b8 100644 --- a/frontend/supabase/migrations/20251212000000_notification_settings.sql +++ b/frontend/supabase/migrations/20251212000000_notification_settings.sql @@ -51,6 +51,7 @@ CREATE POLICY "Users can update own notification settings" FOR UPDATE TO authenticated USING (auth.uid() = user_id); + WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users can delete own notification settings" ON notification_settings From 68767fd9a87c14393907a2646194ed0469a516fc Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 12 Dec 2025 01:18:41 -0500 Subject: [PATCH 3/4] added push_tokens table --- .../migrations/20250825200450_misty_boat.sql | 53 +++++++++++++++++++ frontend/types/database.ts | 29 ++++++++++ 2 files changed, 82 insertions(+) diff --git a/frontend/supabase/migrations/20250825200450_misty_boat.sql b/frontend/supabase/migrations/20250825200450_misty_boat.sql index ffb7926..185c711 100644 --- a/frontend/supabase/migrations/20250825200450_misty_boat.sql +++ b/frontend/supabase/migrations/20250825200450_misty_boat.sql @@ -51,6 +51,15 @@ - `is_active` (boolean) - `created_at` (timestamp) + - `push_tokens` - Device push notification tokens + - `id` (uuid, primary key) + - `user_id` (uuid, references auth.users) + - `token` (text, Expo push token) + - `platform` (text: ios, android, web) + - `device_id` (text, optional) + - `created_at` (timestamp) + - `updated_at` (timestamp) + 2. Security - Enable RLS on all tables - Add policies for authenticated users to manage their own data @@ -128,12 +137,24 @@ CREATE TABLE IF NOT EXISTS invites ( created_at timestamptz DEFAULT now() ); +-- Create push_tokens table +CREATE TABLE IF NOT EXISTS push_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + token text NOT NULL, + platform text CHECK (platform IN ('ios', 'android', 'web')), + device_id text, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now() +); + -- Enable Row Level Security ALTER TABLE profiles ENABLE ROW LEVEL SECURITY; ALTER TABLE entries ENABLE ROW LEVEL SECURITY; ALTER TABLE friendships ENABLE ROW LEVEL SECURITY; ALTER TABLE entry_shares ENABLE ROW LEVEL SECURITY; ALTER TABLE invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE push_tokens ENABLE ROW LEVEL SECURITY; -- Profiles policies CREATE POLICY "Users can read own profile" @@ -262,6 +283,31 @@ CREATE POLICY "Users can update own invites" TO authenticated USING (auth.uid() = inviter_id); +-- Push tokens policies +CREATE POLICY "Users can read own push tokens" + ON push_tokens + FOR SELECT + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own push tokens" + ON push_tokens + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own push tokens" + ON push_tokens + FOR UPDATE + TO authenticated + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own push tokens" + ON push_tokens + FOR DELETE + TO authenticated + USING (auth.uid() = user_id); + -- Create indexes for better performance CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id); CREATE INDEX IF NOT EXISTS idx_entries_created_at ON entries(created_at DESC); @@ -272,6 +318,8 @@ CREATE INDEX IF NOT EXISTS idx_entry_shares_entry_id ON entry_shares(entry_id); CREATE INDEX IF NOT EXISTS idx_entry_shares_user_id ON entry_shares(shared_with_user_id); CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(invite_code); CREATE INDEX IF NOT EXISTS idx_invites_expires_at ON invites(expires_at); +CREATE INDEX IF NOT EXISTS idx_push_tokens_user_id ON push_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_push_tokens_token ON push_tokens(token); -- Create updated_at trigger function CREATE OR REPLACE FUNCTION update_updated_at_column() @@ -298,6 +346,11 @@ CREATE TRIGGER update_friendships_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_push_tokens_updated_at + BEFORE UPDATE ON push_tokens + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- Create storage buckets INSERT INTO storage.buckets (id, name, public) VALUES diff --git a/frontend/types/database.ts b/frontend/types/database.ts index cabc716..ffb1970 100644 --- a/frontend/types/database.ts +++ b/frontend/types/database.ts @@ -259,6 +259,35 @@ export interface Database { updated_at?: string } } + push_tokens: { + Row: { + id: string + user_id: string + token: string + platform: 'ios' | 'android' | 'web' + device_id: string | null + created_at: string + updated_at: string + } + Insert: { + id?: string + user_id: string + token: string + platform: 'ios' | 'android' | 'web' + device_id?: string | null + created_at?: string + updated_at?: string + } + Update: { + id?: string + user_id?: string + token?: string + platform?: 'ios' | 'android' | 'web' + device_id?: string | null + created_at?: string + updated_at?: string + } + } } Views: { [_ in never]: never From 3c900dd6afb1b099474c6e73034e76aa9d61809c Mon Sep 17 00:00:00 2001 From: Fortune Oluwasemilore Alebiosu Date: Fri, 12 Dec 2025 01:29:59 -0500 Subject: [PATCH 4/4] fixed bad code --- frontend/services/push-notification-service.ts | 8 +++++++- .../migrations/20250825200450_misty_boat.sql | 3 ++- .../20251212000000_notification_settings.sql | 17 +++++++++++++++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/services/push-notification-service.ts b/frontend/services/push-notification-service.ts index ea8aaa2..b4c96ab 100644 --- a/frontend/services/push-notification-service.ts +++ b/frontend/services/push-notification-service.ts @@ -75,7 +75,13 @@ export class PushNotificationService { static async savePushToken(token: string, userId: string): Promise { try { const deviceId = Constants.installationId || Device.osName; - const platform = Platform.OS as 'ios' | 'android'; + // Only persist tokens for native platforms we support + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + console.warn('Skipping push token save for unsupported platform:', Platform.OS); + return; + } + + const platform: 'ios' | 'android' = Platform.OS; const { error } = await supabase .from('push_tokens') diff --git a/frontend/supabase/migrations/20250825200450_misty_boat.sql b/frontend/supabase/migrations/20250825200450_misty_boat.sql index 185c711..ed3e8a6 100644 --- a/frontend/supabase/migrations/20250825200450_misty_boat.sql +++ b/frontend/supabase/migrations/20250825200450_misty_boat.sql @@ -145,7 +145,8 @@ CREATE TABLE IF NOT EXISTS push_tokens ( platform text CHECK (platform IN ('ios', 'android', 'web')), device_id text, created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now() + updated_at timestamptz DEFAULT now(), + CONSTRAINT push_tokens_user_device_key UNIQUE (user_id, device_id) ); -- Enable Row Level Security diff --git a/frontend/supabase/migrations/20251212000000_notification_settings.sql b/frontend/supabase/migrations/20251212000000_notification_settings.sql index 2d072b8..d8df643 100644 --- a/frontend/supabase/migrations/20251212000000_notification_settings.sql +++ b/frontend/supabase/migrations/20251212000000_notification_settings.sql @@ -25,11 +25,24 @@ CREATE TABLE IF NOT EXISTS notification_settings ( push_notifications boolean NOT NULL DEFAULT true, entry_reminder boolean NOT NULL DEFAULT false, friend_activity boolean NOT NULL DEFAULT true, - created_at timestamptz DEFAULT now(), - updated_at timestamptz DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE(user_id) ); +-- Backfill and enforce NOT NULL constraints for existing rows (if any) +UPDATE notification_settings +SET created_at = now() +WHERE created_at IS NULL; + +UPDATE notification_settings +SET updated_at = now() +WHERE updated_at IS NULL; + +ALTER TABLE notification_settings + ALTER COLUMN created_at SET NOT NULL, + ALTER COLUMN updated_at SET NOT NULL; + -- Enable Row Level Security ALTER TABLE notification_settings ENABLE ROW LEVEL SECURITY;