diff --git a/components/pages/notifications/NotificationsPage.tsx b/components/pages/notifications/NotificationsPage.tsx index a17d779..3dd5926 100644 --- a/components/pages/notifications/NotificationsPage.tsx +++ b/components/pages/notifications/NotificationsPage.tsx @@ -1,5 +1,5 @@ -import {FlatList, StyleSheet} from "react-native"; -import React, {useCallback} from "react"; +import {ActivityIndicator, Animated, FlatList, StyleSheet} from "react-native"; +import React, {useCallback, useState} from "react"; import {Card, Text, View} from "react-native-ui-lib"; import HeaderTemplate from "@/components/shared/HeaderTemplate"; import {Notification, useGetNotifications} from "@/hooks/firebase/useGetNotifications"; @@ -7,12 +7,88 @@ import {formatDistanceToNow} from "date-fns"; import {useRouter} from "expo-router"; import {useSetAtom} from "jotai"; import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms"; +import {Swipeable} from 'react-native-gesture-handler'; +import {useDeleteNotification} from "@/hooks/firebase/useDeleteNotification"; -const NotificationsPage = () => { +interface NotificationItemProps { + item: Notification; + onDelete: (id: string) => void; + onPress: () => void; + isDeleting: boolean; +} + +const NotificationItem: React.FC = React.memo(({ + item, + onDelete, + onPress, + isDeleting + }) => { + const renderRightActions = useCallback(( + progress: Animated.AnimatedInterpolation, + dragX: Animated.AnimatedInterpolation + ) => { + const trans = dragX.interpolate({ + inputRange: [-100, 0], + outputRange: [0, 100], + extrapolate: 'clamp' + }); + + return ( + + Delete + + ); + }, []); + + return ( + onDelete(item.id)} + overshootRight={false} + enabled={!isDeleting} + > + + {isDeleting && ( + + + + )} + {item.content} + + + {formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})} + + + {item.timestamp.toLocaleDateString()} + + + + + ); +}); + +const NotificationsPage: React.FC = () => { const setSelectedDate = useSetAtom(selectedDateAtom); const setMode = useSetAtom(modeAtom); const {data: notifications} = useGetNotifications(); + const deleteNotification = useDeleteNotification(); const {push} = useRouter(); + const [deletingIds, setDeletingIds] = useState>(new Set()); + const goToEventDay = useCallback((notification: Notification) => () => { if (notification?.date) { setSelectedDate(notification.date); @@ -21,6 +97,27 @@ const NotificationsPage = () => { push({pathname: "/calendar"}); }, [push, setSelectedDate]); + const handleDelete = useCallback((notificationId: string) => { + setDeletingIds(prev => new Set(prev).add(notificationId)); + deleteNotification.mutate(notificationId, { + onSettled: () => { + setDeletingIds(prev => { + const newSet = new Set(prev); + newSet.delete(notificationId); + return newSet; + }); + } + }); + }, [deleteNotification]); + + const renderNotificationItem = useCallback(({item}: { item: Notification }) => ( + + ), [handleDelete, goToEventDay, deletingIds]); return ( @@ -39,27 +136,8 @@ const NotificationsPage = () => { ( - - {item.content} - - - {formatDistanceToNow(new Date(item.timestamp), {addSuffix: true})} - - - {item.timestamp.toLocaleDateString()} - - - - )} + renderItem={renderNotificationItem} + keyExtractor={(item) => item.id} /> @@ -79,14 +157,26 @@ const styles = StyleSheet.create({ fontFamily: "Manrope_400Regular", fontSize: 14, }, - searchField: { - borderWidth: 0.7, - borderColor: "#9b9b9b", - borderRadius: 15, - height: 42, - paddingLeft: 10, - marginVertical: 20, + deleteAction: { + backgroundColor: '#FF3B30', + justifyContent: 'center', + alignItems: 'flex-end', + paddingRight: 30, + marginBottom: 10, + width: 100, + borderRadius: 10, + }, + deleteActionText: { + color: 'white', + fontWeight: '600', + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, }, }); -export default NotificationsPage; \ No newline at end of file +export default NotificationsPage diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index cd60b22..c18cb1e 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -164,19 +164,15 @@ export const AuthContextProvider: FC<{ children: ReactNode }> = ({children}) => } }, [user, ready, redirectOverride]); - // useEffect(() => { - // const handleNotification = async (notification: Notifications.Notification) => { - // const eventId = notification?.request?.content?.data?.eventId; - // - // // if (eventId) { - // queryClient.invalidateQueries(['events']); - // // } - // }; - // - // const sub = Notifications.addNotificationReceivedListener(handleNotification); - // - // return () => sub.remove(); - // }, []); + useEffect(() => { + const handleNotification = async (notification: Notifications.Notification) => { + queryClient.invalidateQueries(["notifications"]); + }; + + const sub = Notifications.addNotificationReceivedListener(handleNotification); + + return () => sub.remove(); + }, []); if (!ready) { return null; diff --git a/firebase/functions/index.js b/firebase/functions/index.js index 8070c7f..fdfd394 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -197,105 +197,184 @@ exports.sendNotificationOnEventCreation = functions.firestore return; } - // Get push tokens - exclude creator for manual events, include everyone for synced events - let pushTokens = await getPushTokensForFamily( - familyId, - externalOrigin ? null : creatorId // Only exclude creator for manual events - ); + const timeWindow = Math.floor(Date.now() / 5000); + const batchId = `${timeWindow}_${familyId}_${creatorId}_${externalOrigin ? 'sync' : 'manual'}`; + const batchRef = admin.firestore().collection('EventBatches').doc(batchId); - if (!pushTokens.length) { - console.log('No push tokens available for the event.'); - return; - } + try { + await admin.firestore().runTransaction(async (transaction) => { + const batchDoc = await transaction.get(batchRef); - eventCount++; - - // Only set up the notification timeout if it's not already in progress - if (!notificationInProgress) { - notificationInProgress = true; - - notificationTimeout = setTimeout(async () => { - let eventMessage; - if (externalOrigin) { - eventMessage = eventCount === 1 - ? `Calendar sync completed: "${title}" has been added.` - : `Calendar sync completed: ${eventCount} new events have been added.`; + if (!batchDoc.exists) { + transaction.set(batchRef, { + familyId, + creatorId, + externalOrigin, + events: [{ + id: context.params.eventId, + title: title, + timestamp: new Date().toISOString() + }], + createdAt: admin.firestore.FieldValue.serverTimestamp(), + processed: false, + expiresAt: new Date(Date.now() + 10000) + }); } else { - eventMessage = eventCount === 1 - ? `New event "${title}" has been added to the family calendar.` - : `${eventCount} new events have been added to the family calendar.`; + const existingEvents = batchDoc.data().events || []; + transaction.update(batchRef, { + events: [...existingEvents, { + id: context.params.eventId, + title: title, + timestamp: new Date().toISOString() + }] + }); } - - let messages = pushTokens.map(pushToken => { - if (!Expo.isExpoPushToken(pushToken)) { - console.error(`Push token ${pushToken} is not a valid Expo push token`); - return null; - } - - return { - to: pushToken, - sound: 'default', - title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events', - body: eventMessage, - data: { - eventId: context.params.eventId, - type: externalOrigin ? 'sync' : 'manual' - }, - }; - }).filter(Boolean); - - let chunks = expo.chunkPushNotifications(messages); - let tickets = []; - - for (let chunk of chunks) { - try { - let ticketChunk = await expo.sendPushNotificationsAsync(chunk); - tickets.push(...ticketChunk); - - for (let ticket of ticketChunk) { - if (ticket.status === 'ok') { - console.log('Notification successfully sent:', ticket.id); - } else if (ticket.status === 'error') { - console.error(`Notification error: ${ticket.message}`); - if (ticket.details?.error === 'DeviceNotRegistered') { - await removeInvalidPushToken(ticket.to); - } - } - } - } catch (error) { - console.error('Error sending notification:', error); - } - } - - // Save the notification in Firestore - const notificationData = { - creatorId, - familyId, - content: eventMessage, - eventId: context.params.eventId, - type: externalOrigin ? 'sync' : 'manual', - timestamp: Timestamp.now(), - date: eventData.startDate - }; - - try { - await db.collection("Notifications").add(notificationData); - console.log("Notification stored in Firestore:", notificationData); - } catch (error) { - console.error("Error saving notification to Firestore:", error); - } - - // Reset state variables - eventCount = 0; - pushTokens = []; - notificationInProgress = false; - }, 5000); + }); + } catch (error) { + console.error('Error adding to event batch:', error); + throw error; } }); -// Store batches in Firestore instead of memory +exports.processEventBatches = functions.pubsub + .schedule('every 1 minutes') + .onRun(async (context) => { + const batchesRef = admin.firestore().collection('EventBatches'); + const now = admin.firestore.Timestamp.fromDate(new Date()); + const snapshot = await batchesRef + .where('processed', '==', false) + .where('expiresAt', '<=', now) + .limit(100) + .get(); + + if (snapshot.empty) return null; + + const processPromises = snapshot.docs.map(async (doc) => { + const batchData = doc.data(); + const {familyId, creatorId, externalOrigin, events} = batchData; + + try { + const pushTokens = await getPushTokensForFamily( + familyId, + externalOrigin ? null : creatorId + ); + + if (pushTokens.length) { + let notificationMessage; + if (externalOrigin) { + notificationMessage = `Calendar sync completed: ${events.length} ${events.length === 1 ? 'event has' : 'events have'} been added.`; + } else { + notificationMessage = events.length === 1 + ? `New event "${events[0].title}" has been added to the family calendar.` + : `${events.length} new events have been added to the family calendar.`; + } + + await Promise.all([ + sendNotifications(pushTokens, { + title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events', + body: notificationMessage, + data: { + type: externalOrigin ? 'sync' : 'manual', + count: events.length + } + }), + storeNotification({ + type: externalOrigin ? 'sync' : 'manual', + familyId, + content: notificationMessage, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + creatorId, + eventCount: events.length + }) + ]); + } + + await doc.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp() + }); + + } catch (error) { + console.error(`Error processing event batch ${doc.id}:`, error); + await doc.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp(), + error: error.message + }); + } + }); + + await Promise.all(processPromises); + }); + + +exports.processEventBatchesRealtime = functions.firestore + .document('EventBatches/{batchId}') + .onWrite(async (change, context) => { + const batchData = change.after.data(); + + + if (!batchData || batchData.processed) { + return null; + } + + const {familyId, creatorId, externalOrigin, events} = batchData; + + try { + const pushTokens = await getPushTokensForFamily( + familyId, + externalOrigin ? null : creatorId + ); + + if (pushTokens.length) { + let notificationMessage; + if (externalOrigin) { + notificationMessage = `Calendar sync completed: ${events.length} ${events.length === 1 ? 'event has' : 'events have'} been added.`; + } else { + notificationMessage = events.length === 1 + ? `New event "${events[0].title}" has been added to the family calendar.` + : `${events.length} new events have been added to the family calendar.`; + } + + await Promise.all([ + sendNotifications(pushTokens, { + title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events', + body: notificationMessage, + data: { + type: externalOrigin ? 'sync' : 'manual', + count: events.length + } + }), + storeNotification({ + type: externalOrigin ? 'sync' : 'manual', + familyId, + content: notificationMessage, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + creatorId, + eventCount: events.length + }) + ]); + } + + await change.after.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp() + }); + + } catch (error) { + console.error(`Error processing batch ${context.params.batchId}:`, error); + await change.after.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp(), + error: error.message + }); + } + }); + + async function addToUpdateBatch(eventData, eventId) { - const batchId = `${eventData.familyId}_${eventData.lastModifiedBy}`; + const timeWindow = Math.floor(Date.now() / 2000); + const batchId = `${timeWindow}_${eventData.familyId}_${eventData.lastModifiedBy}`; const batchRef = admin.firestore().collection('UpdateBatches').doc(batchId); try { @@ -303,7 +382,6 @@ async function addToUpdateBatch(eventData, eventId) { const batchDoc = await transaction.get(batchRef); if (!batchDoc.exists) { - // Create new batch transaction.set(batchRef, { familyId: eventData.familyId, lastModifiedBy: eventData.lastModifiedBy, @@ -314,10 +392,10 @@ async function addToUpdateBatch(eventData, eventId) { startDate: eventData.startDate }], createdAt: admin.firestore.FieldValue.serverTimestamp(), - processed: false + processed: false, + expiresAt: new Date(Date.now() + 3000) }); } else { - // Update existing batch const existingEvents = batchDoc.data().events || []; transaction.update(batchRef, { events: [...existingEvents, { @@ -334,47 +412,44 @@ async function addToUpdateBatch(eventData, eventId) { } } -exports.onEventUpdate = functions.firestore - .document('Events/{eventId}') - .onUpdate(async (change, context) => { - const beforeData = change.before.data(); - const afterData = change.after.data(); - const {familyId, title, lastModifiedBy, externalOrigin, startDate} = afterData; +exports.cleanupEventBatches = functions.pubsub + .schedule('every 24 hours') + .onRun(async (context) => { + const batchesRef = admin.firestore().collection('EventBatches'); + const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000)); - if (JSON.stringify(beforeData) === JSON.stringify(afterData)) { - return null; - } + while (true) { + const oldBatches = await batchesRef + .where('processedAt', '<=', dayAgo) + .limit(500) + .get(); - try { - await addToUpdateBatch({ - familyId, - title, - lastModifiedBy, - externalOrigin, - startDate - }, context.params.eventId); - } catch (error) { - console.error('Error in onEventUpdate:', error); + if (oldBatches.empty) break; + + const batch = admin.firestore().batch(); + oldBatches.docs.forEach(doc => batch.delete(doc.ref)); + await batch.commit(); } }); -// Separate function to process batches exports.processUpdateBatches = functions.pubsub .schedule('every 1 minutes') .onRun(async (context) => { const batchesRef = admin.firestore().collection('UpdateBatches'); - // Find unprocessed batches older than 5 seconds - const cutoff = new Date(Date.now() - 5000); + + const now = admin.firestore.Timestamp.fromDate(new Date()); const snapshot = await batchesRef .where('processed', '==', false) - .where('createdAt', '<=', cutoff) + .where('expiresAt', '<=', now) + .limit(100) .get(); const processPromises = snapshot.docs.map(async (doc) => { const batchData = doc.data(); try { + const pushTokens = await getPushTokensForFamily( batchData.familyId, batchData.lastModifiedBy @@ -390,116 +465,133 @@ exports.processUpdateBatches = functions.pubsub : `${batchData.events.length} events have been updated`; } - await sendNotifications(pushTokens, { - title: batchData.externalOrigin ? "Calendar Sync Complete" : "Events Updated", - body: message, - data: { - type: 'event_update', - count: batchData.events.length - } - }); - await storeNotification({ - type: 'event_update', - familyId: batchData.familyId, - content: message, - excludedUser: batchData.lastModifiedBy, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - count: batchData.events.length, - date: batchData.events[0].startDate + await admin.firestore().runTransaction(async (transaction) => { + + await sendNotifications(pushTokens, { + title: batchData.externalOrigin ? "Calendar Sync Complete" : "Events Updated", + body: message, + data: { + type: 'event_update', + count: batchData.events.length + } + }); + + + const notificationRef = admin.firestore().collection('Notifications').doc(); + transaction.set(notificationRef, { + type: 'event_update', + familyId: batchData.familyId, + content: message, + excludedUser: batchData.lastModifiedBy, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + count: batchData.events.length, + date: batchData.events[0].startDate + }); + + + transaction.update(doc.ref, { + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp() + }); + }); + } else { + + await doc.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp() }); } - - // Mark batch as processed - await doc.ref.update({ - processed: true, - processedAt: admin.firestore.FieldValue.serverTimestamp() - }); - } catch (error) { console.error(`Error processing batch ${doc.id}:`, error); + + await doc.ref.update({ + processed: true, + processedAt: admin.firestore.FieldValue.serverTimestamp(), + error: error.message + }); } }); await Promise.all(processPromises); }); -// Cleanup old batches exports.cleanupUpdateBatches = functions.pubsub .schedule('every 24 hours') .onRun(async (context) => { const batchesRef = admin.firestore().collection('UpdateBatches'); - const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000)); - const oldBatches = await batchesRef - .where('processedAt', '<=', dayAgo) - .get(); + while (true) { + const oldBatches = await batchesRef + .where('processedAt', '<=', dayAgo) + .limit(500) + .get(); - const deletePromises = oldBatches.docs.map(doc => doc.ref.delete()); - await Promise.all(deletePromises); + if (oldBatches.empty) break; + + const batch = admin.firestore().batch(); + oldBatches.docs.forEach(doc => batch.delete(doc.ref)); + await batch.commit(); + } }); -// Upcoming Event Reminders exports.checkUpcomingEvents = functions.pubsub .schedule('every 5 minutes') .onRun(async (context) => { const now = admin.firestore.Timestamp.now(); - const eventsSnapshot = await admin.firestore().collection('Events').get(); + const eventsSnapshot = await admin.firestore() + .collection('Events') + .where('startDate', '>=', now) + .get(); - for (const doc of eventsSnapshot.docs) { + const processPromises = eventsSnapshot.docs.map(async (doc) => { const event = doc.data(); - const {startDate, familyId, title, allDay, creatorId} = event; - - if (startDate.toDate() < now.toDate()) continue; + if (!event?.startDate) return; + const {familyId, title, allDay} = event; try { const familyDoc = await admin.firestore().collection('Families').doc(familyId).get(); - const familySettings = familyDoc.data()?.settings || {}; - const reminderTime = familySettings.defaultReminderTime || 15; // minutes + if (!familyDoc.exists) return; - const eventTime = startDate.toDate(); + const familySettings = familyDoc.data()?.settings || {}; + const reminderTime = familySettings.defaultReminderTime || 15; + const eventTime = event.startDate.toDate(); const reminderThreshold = new Date(now.toDate().getTime() + (reminderTime * 60 * 1000)); - // For all-day events, send reminder the evening before if (allDay) { const eveningBefore = new Date(eventTime); eveningBefore.setDate(eveningBefore.getDate() - 1); eveningBefore.setHours(20, 0, 0, 0); if (now.toDate() >= eveningBefore && !event.eveningReminderSent) { - // Get all family members' tokens (including creator for reminders) const pushTokens = await getPushTokensForFamily(familyId); - await sendNotifications(pushTokens, { - title: "Tomorrow's All-Day Event", - body: `Tomorrow: ${title}`, - data: { - type: 'event_reminder', - eventId: doc.id - } - }); - - await doc.ref.update({eveningReminderSent: true}); - } - } - // For regular events, check if within reminder threshold - else if (eventTime <= reminderThreshold && !event.reminderSent) { - // Include creator for reminders - const pushTokens = await getPushTokensForFamily(familyId); - await sendNotifications(pushTokens, { - title: "Upcoming Event", - body: `In ${reminderTime} minutes: ${title}`, - data: { - type: 'event_reminder', - eventId: doc.id + if (pushTokens.length) { + await sendNotifications(pushTokens, { + title: "Tomorrow's All-Day Event", + body: `Tomorrow: ${title}`, + data: {type: 'event_reminder', eventId: doc.id} + }); + await doc.ref.update({eveningReminderSent: true}); } - }); - - await doc.ref.update({reminderSent: true}); + } + } else if (eventTime <= reminderThreshold && !event.reminderSent) { + const pushTokens = await getPushTokensForFamily(familyId); + if (pushTokens.length) { + await sendNotifications(pushTokens, { + title: "Upcoming Event", + body: `In ${reminderTime} minutes: ${title}`, + data: {type: 'event_reminder', eventId: doc.id} + }); + await doc.ref.update({reminderSent: true}); + } } } catch (error) { console.error(`Error processing reminder for event ${doc.id}:`, error); } - } + }); + + await Promise.all(processPromises); }); async function storeNotification(notificationData) { @@ -781,11 +873,11 @@ async function refreshMicrosoftToken(refreshToken) { const response = await axios.post('https://login.microsoftonline.com/common/oauth2/v2.0/token', { grant_type: 'refresh_token', refresh_token: refreshToken, - client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af", // Client ID from microsoftConfig - scope: "openid profile email offline_access Calendars.ReadWrite User.Read", // Scope from microsoftConfig + client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af", + scope: "openid profile email offline_access Calendars.ReadWrite User.Read", }); - return response.data.access_token; // Return the new access token + return response.data.access_token; } catch (error) { console.error("Error refreshing Microsoft token:", error); throw error; @@ -815,7 +907,7 @@ async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) { snapshot.forEach(doc => { const data = doc.data(); - // Exclude the creator + if (data.uid !== creatorId && data.pushToken) { pushTokens.push(data.pushToken); } @@ -845,7 +937,7 @@ async function removeInvalidPushToken(pushToken) { const fetch = require("node-fetch"); -// Function to refresh Google Token with additional logging + async function refreshGoogleToken(refreshToken) { try { console.log("Refreshing Google token..."); @@ -870,10 +962,10 @@ async function refreshGoogleToken(refreshToken) { const data = await response.json(); console.log("Google token refreshed successfully"); - // Return both the access token and refresh token (if a new one is provided) + return { refreshedGoogleToken: data.access_token, - refreshedRefreshToken: data.refresh_token || refreshToken, // Return the existing refresh token if a new one is not provided + refreshedRefreshToken: data.refresh_token || refreshToken, }; } catch (error) { console.error("Error refreshing Google token:", error.message); @@ -881,7 +973,7 @@ async function refreshGoogleToken(refreshToken) { } } -// Helper function to get Google access tokens for all users and refresh them if needed with logging + async function getGoogleAccessTokens() { console.log("Fetching Google access tokens for all users..."); const tokens = {}; @@ -893,7 +985,7 @@ async function getGoogleAccessTokens() { const googleAccounts = profileData?.googleAccounts || {}; for (const googleEmail of Object.keys(googleAccounts)) { - // Check if the googleAccount entry exists and has a refreshToken + const accountInfo = googleAccounts[googleEmail]; const refreshToken = accountInfo?.refreshToken; @@ -918,194 +1010,198 @@ async function getGoogleAccessTokens() { return tokens; } -// Function to watch Google Calendar events with additional logging -const watchCalendarEvents = async (userId, token) => { - const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`; +exports.renewGoogleCalendarWatch = functions.pubsub + .schedule("every 10 minutes") + .onRun(async (context) => { + console.log("Starting calendar watch renewal check"); - // Verify the token is valid - console.log(`Attempting to watch calendar for user ${userId}`); - console.log(`Token being used: ${token ? 'present' : 'missing'}`); - console.log(`Calendar ID: ${GOOGLE_CALENDAR_ID}`); - console.log(`Webhook URL: ${WEBHOOK_URL}?userId=${userId}`); - try { - // Test the token first - const testResponse = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events?maxResults=1`, - { - headers: { - Authorization: `Bearer ${token}`, - }, + const profilesWithGoogle = await db.collection('Profiles') + .where('googleAccounts', '!=', null) + .get(); + + + const existingWatches = await db.collection('CalendarWatches').get(); + const now = Date.now(); + + + const watchMap = new Map(); + existingWatches.forEach(doc => { + watchMap.set(doc.id, doc.data()); + }); + + + const batch = db.batch(); + let processedCount = 0; + let newWatchCount = 0; + let renewalCount = 0; + + for (const profile of profilesWithGoogle.docs) { + const userId = profile.id; + const userData = profile.data(); + const existingWatch = watchMap.get(userId); + + + if (!userData.googleAccounts || Object.keys(userData.googleAccounts).length === 0) { + continue; } - ); - if (!testResponse.ok) { - console.error(`Token validation failed for user ${userId}:`, await testResponse.text()); - throw new Error('Token validation failed'); - } - console.log(`Token validated successfully for user ${userId}`); + const firstAccount = Object.values(userData.googleAccounts)[0]; + const token = firstAccount?.accessToken; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: `${CHANNEL_ID}-${userId}`, - type: "web_hook", - address: `${WEBHOOK_URL}?userId=${userId}`, - params: { - ttl: "80000", - }, - }), - }); + if (!token) { + console.log(`No token found for user ${userId}`); + continue; + } - const responseText = await response.text(); - console.log(`Watch response for user ${userId}:`, responseText); - if (!response.ok) { - console.error(`Failed to watch calendar for user ${userId}:`, responseText); - throw new Error(`Failed to watch calendar: ${responseText}`); - } + const needsRenewal = !existingWatch || + !existingWatch.expiration || + existingWatch.expiration < (now + 30 * 60 * 1000); - const result = JSON.parse(responseText); - console.log(`Successfully set up Google Calendar watch for user ${userId}`, result); + if (!needsRenewal) { + continue; + } - // Store the watch details in Firestore for monitoring - await db.collection('CalendarWatches').doc(userId).set({ - watchId: result.id, - resourceId: result.resourceId, - expiration: result.expiration, - createdAt: admin.firestore.FieldValue.serverTimestamp(), - }); - - return result; - } catch (error) { - console.error(`Error in watchCalendarEvents for user ${userId}:`, error); - // Store the error in Firestore for monitoring - await db.collection('CalendarWatchErrors').add({ - userId, - error: error.message, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - }); - throw error; - } -}; - -// Add this to test webhook connectivity -exports.testWebhook = functions.https.onRequest(async (req, res) => { - console.log('Test webhook received'); - console.log('Headers:', req.headers); - console.log('Body:', req.body); - console.log('Query:', req.query); - - res.status(200).send('Test webhook received successfully'); -}); - -// Schedule function to renew Google Calendar watch every 20 hours for each user with logging -exports.renewGoogleCalendarWatch = functions.pubsub.schedule("every 10 minutes").onRun(async (context) => { - console.log("Starting Google Calendar watch renewal process..."); - try { - const tokens = await getGoogleAccessTokens(); - console.log("Tokens: ", tokens); - - for (const [userId, token] of Object.entries(tokens)) { try { - await watchCalendarEvents(userId, token); + console.log(`${existingWatch ? 'Renewing' : 'Creating new'} watch for user ${userId}`); + + const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`; + + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: `${CHANNEL_ID}-${userId}`, + type: "web_hook", + address: `${WEBHOOK_URL}?userId=${userId}`, + params: {ttl: "604800"}, + }), + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const result = await response.json(); + + batch.set(db.collection('CalendarWatches').doc(userId), { + watchId: result.id, + resourceId: result.resourceId, + expiration: result.expiration, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }, {merge: true}); + + processedCount++; + if (existingWatch) { + renewalCount++; + } else { + newWatchCount++; + } + + console.log(`Successfully ${existingWatch ? 'renewed' : 'created'} watch for user ${userId}`); + } catch (error) { - console.error(`Error renewing Google Calendar watch for user ${userId}:`, error.message); + console.error(`Failed to ${existingWatch ? 'renew' : 'create'} watch for user ${userId}:`, error); + batch.set(db.collection('CalendarWatchErrors').doc(), { + userId, + error: error.message, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + }); } } - console.log("Google Calendar watch renewal process completed"); - } catch (error) { - console.error("Error in renewGoogleCalendarWatch function:", error.message); - } -}); + if (processedCount > 0) { + await batch.commit(); + } + + console.log(`Completed calendar watch processing:`, { + totalProcessed: processedCount, + newWatches: newWatchCount, + renewals: renewalCount + }); + }); -// Function to handle notifications from Google Calendar with additional logging exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { - const userId = req.query.userId; // Extract userId from query params + const userId = req.query.userId; const calendarId = req.body.resourceId; console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`); try { - // Fetch user profile data for the specific user const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); - // Ensure pushTokens is an array - let pushTokens = []; - if (userData && userData.pushToken) { - pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken]; - } - - if (pushTokens.length === 0) { - console.log(`No push tokens found for user ${userId}`); - res.status(200).send("No push tokens found for user."); - return; - } - - // Call calendarSync with necessary parameters const {googleAccounts} = userData; - const email = Object.keys(googleAccounts || {})[0]; // Assuming the first account is the primary + const email = Object.keys(googleAccounts || {})[0]; const accountData = googleAccounts[email] || {}; const token = accountData.accessToken; const refreshToken = accountData.refreshToken; const familyId = userData.familyId; + + if (userData.pushToken) { + await sendNotifications( + Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken], + { + title: "Calendar Sync", + body: "Calendar sync in progress...", + data: {type: 'sync_started'} + } + ); + } + console.log("Starting calendar sync..."); - await calendarSync({userId, email, token, refreshToken, familyId}); + const eventCount = await calendarSync({userId, email, token, refreshToken, familyId}); console.log("Calendar sync completed."); - // Prepare and send push notifications after sync - // const syncMessage = "New events have been synced."; - // - // let messages = pushTokens.map(pushToken => { - // if (!Expo.isExpoPushToken(pushToken)) { - // console.error(`Push token ${pushToken} is not a valid Expo push token`); - // return null; - // } - // - // return { - // to: pushToken, - // sound: "default", - // title: "Event Sync", - // body: syncMessage, - // data: { userId, calendarId }, - // }; - // }).filter(Boolean); - // - // let chunks = expo.chunkPushNotifications(messages); - // let tickets = []; - // - // for (let chunk of chunks) { - // try { - // let ticketChunk = await expo.sendPushNotificationsAsync(chunk); - // tickets.push(...ticketChunk); - // - // for (let ticket of ticketChunk) { - // if (ticket.status === "ok") { - // console.log("Notification successfully sent:", ticket.id); - // } else if (ticket.status === "error") { - // console.error(`Notification error: ${ticket.message}`); - // if (ticket.details?.error === "DeviceNotRegistered") { - // await removeInvalidPushToken(ticket.to); - // } - // } - // } - // } catch (error) { - // console.error("Error sending notification:", error.message); - // } - // } - // - // console.log(`Sync notification sent for user ${userId}`); - res.status(200).send("Sync notification sent."); + + if (userData.pushToken) { + const syncMessage = `Calendar sync completed: ${eventCount} ${eventCount === 1 ? 'event has' : 'events have'} been synced.`; + + await Promise.all([ + + sendNotifications( + Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken], + { + title: "Calendar Sync", + body: syncMessage, + data: { + type: 'sync_completed', + count: eventCount + } + } + ), + + storeNotification({ + type: 'sync', + familyId, + content: syncMessage, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + creatorId: userId, + date: new Date() + }) + ]); + } + + res.status(200).send("Sync completed successfully."); } catch (error) { console.error(`Error in sendSyncNotification for user ${userId}:`, error.message); + + if (userData?.pushToken) { + await sendNotifications( + Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken], + { + title: "Calendar Sync Error", + body: "There was an error syncing your calendar. Please try again later.", + data: {type: 'sync_error'} + } + ); + } res.status(500).send("Failed to send sync notification."); } }); @@ -1115,18 +1211,20 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString(); const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString(); - let events = []; + let totalEvents = 0; let pageToken = null; + const batchSize = 50; try { console.log(`Fetching events for user: ${email}`); - // Fetch all events from Google Calendar within the specified time range do { + let events = []; const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`); url.searchParams.set("singleEvents", "true"); url.searchParams.set("timeMin", timeMin); url.searchParams.set("timeMax", timeMax); + url.searchParams.set("maxResults", batchSize.toString()); if (pageToken) url.searchParams.set("pageToken", pageToken); const response = await fetch(url.toString(), { @@ -1135,8 +1233,6 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c }, }); - const data = await response.json(); - if (response.status === 401 && refreshToken) { console.log(`Token expired for user: ${email}, attempting to refresh`); const refreshedToken = await refreshGoogleToken(refreshToken); @@ -1144,18 +1240,14 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c if (token) { return fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId}); - } else { - console.error(`Failed to refresh token for user: ${email}`); - await clearToken(email); - return; } } + const data = await response.json(); if (!response.ok) { throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`); } - console.log(`Processing events for user: ${email}`); data.items?.forEach((item) => { const googleEvent = { id: item.id, @@ -1173,16 +1265,21 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c externalOrigin: "google", }; events.push(googleEvent); - console.log(`Processed event: ${JSON.stringify(googleEvent)}`); }); + + if (events.length > 0) { + await saveEventsToFirestore(events); + totalEvents += events.length; + } + pageToken = data.nextPageToken; } while (pageToken); - console.log(`Saving events to Firestore for user: ${email}`); - await saveEventsToFirestore(events); + return totalEvents; } catch (error) { console.error(`Error fetching Google Calendar events for ${email}:`, error); + throw error; } } @@ -1198,7 +1295,7 @@ async function saveEventsToFirestore(events) { async function calendarSync({userId, email, token, refreshToken, familyId}) { console.log(`Starting calendar sync for user ${userId} with email ${email}`); try { - await fetchAndSaveGoogleEvents({ + const eventCount = await fetchAndSaveGoogleEvents({ token, refreshToken, email, @@ -1206,11 +1303,11 @@ async function calendarSync({userId, email, token, refreshToken, familyId}) { creatorId: userId, }); console.log("Calendar events synced successfully."); + return eventCount; } catch (error) { console.error(`Error syncing calendar for user ${userId}:`, error); throw error; } - console.log(`Finished calendar sync for user ${userId}`); } exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { @@ -1297,7 +1394,7 @@ async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId const url = `https://graph.microsoft.com/v1.0/me/calendar/events`; const queryParams = new URLSearchParams({ - $select: 'subject,start,end,id', + $select: 'subject,start,end,id,isAllDay', $filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'` }); @@ -1328,84 +1425,124 @@ async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`); } - const events = data.value.map(item => ({ - id: item.id, - title: item.subject || "", - startDate: new Date(item.start.dateTime + 'Z'), - endDate: new Date(item.end.dateTime + 'Z'), - allDay: false, // Microsoft Graph API handles all-day events differently - familyId, - email, - creatorId, - externalOrigin: "microsoft" - })); + const events = data.value.map(item => { + let startDate, endDate; + + if (item.isAllDay) { + startDate = new Date(item.start.date + 'T00:00:00'); + endDate = new Date(new Date(item.end.date + 'T00:00:00').setDate(new Date(item.end.date).getDate() - 1)); + } else { + startDate = new Date(item.start.dateTime + 'Z'); + endDate = new Date(item.end.dateTime + 'Z'); + } + + return { + id: item.id, + title: item.subject || "", + startDate, + endDate, + allDay: item.isAllDay, + familyId, + email, + creatorId, + externalOrigin: "microsoft" + }; + }); console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`); await saveEventsToFirestore(events); + return events.length; + } catch (error) { console.error(`Error fetching Microsoft Calendar events for ${email}:`, error); throw error; } } -async function subscribeMicrosoftCalendar(accessToken, userId) { - try { - console.log(`Setting up Microsoft calendar subscription for user ${userId}`); +exports.renewMicrosoftSubscriptions = functions.pubsub + .schedule('every 24 hours') + .onRun(async (context) => { + const now = Date.now(); + const subs = await db.collection('MicrosoftSubscriptions') + .where('expirationDateTime', '<=', new Date(now + 24 * 60 * 60 * 1000)) + .get(); - const subscription = { - changeType: "created,updated,deleted", - notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`, - resource: "/me/calendar/events", - expirationDateTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days - clientState: userId - }; + if (subs.empty) return null; - const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(subscription) + const profilesSnapshot = await db.collection('Profiles') + .where('uid', 'in', subs.docs.map(doc => doc.id)) + .get(); + + const batch = db.batch(); + const userTokenMap = {}; + + profilesSnapshot.forEach(doc => { + const data = doc.data(); + if (data.microsoftAccounts) { + const [email, token] = Object.entries(data.microsoftAccounts)[0] || []; + if (token) userTokenMap[doc.id] = {token, email}; + } }); - if (!response.ok) { - throw new Error(`Failed to create subscription: ${response.statusText}`); + for (const subDoc of subs.docs) { + const userId = subDoc.id; + const userTokens = userTokenMap[userId]; + + if (!userTokens) continue; + + try { + const subscription = { + changeType: "created,updated,deleted", + notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`, + resource: "/me/calendar/events", + expirationDateTime: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(), + clientState: userId + }; + + const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${userTokens.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(subscription) + }); + + if (!response.ok) throw new Error(await response.text()); + + const subscriptionData = await response.json(); + batch.set(db.collection('MicrosoftSubscriptions').doc(userId), { + subscriptionId: subscriptionData.id, + expirationDateTime: subscriptionData.expirationDateTime, + updatedAt: admin.firestore.FieldValue.serverTimestamp() + }, {merge: true}); + + } catch (error) { + console.error(`Failed to renew Microsoft subscription for ${userId}:`, error); + batch.set(db.collection('MicrosoftSubscriptionErrors').doc(), { + userId, + error: error.message, + timestamp: admin.firestore.FieldValue.serverTimestamp() + }); + } } - const subscriptionData = await response.json(); - - // Store subscription details in Firestore - await admin.firestore().collection('MicrosoftSubscriptions').doc(userId).set({ - subscriptionId: subscriptionData.id, - expirationDateTime: subscriptionData.expirationDateTime, - createdAt: admin.firestore.FieldValue.serverTimestamp() - }); - - console.log(`Microsoft calendar subscription created for user ${userId}`); - return subscriptionData; - } catch (error) { - console.error(`Error creating Microsoft calendar subscription for user ${userId}:`, error); - throw error; - } -} + await batch.commit(); + }); exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => { const userId = req.query.userId; - console.log(`Received Microsoft calendar webhook for user ${userId}`, req.body); - try { - const userDoc = await admin.firestore().collection("Profiles").doc(userId).get(); + const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); if (!userData?.microsoftAccounts) { - console.log(`No Microsoft account found for user ${userId}`); return res.status(200).send(); } - const email = Object.keys(userData.microsoftAccounts)[0]; - const token = userData.microsoftAccounts[email]; + const [email, token] = Object.entries(userData.microsoftAccounts)[0] || []; + if (!token) return res.status(200).send(); await fetchAndSaveMicrosoftEvents({ token, @@ -1416,36 +1553,7 @@ exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => res.status(200).send(); } catch (error) { - console.error(`Error processing Microsoft calendar webhook for user ${userId}:`, error); + console.error(`Error processing Microsoft webhook for ${userId}:`, error); res.status(500).send(); } -}); -exports.renewMicrosoftSubscriptions = functions.pubsub.schedule('every 24 hours').onRun(async (context) => { - console.log('Starting Microsoft subscription renewal process'); - - try { - const subscriptionsSnapshot = await admin.firestore() - .collection('MicrosoftSubscriptions') - .get(); - - for (const doc of subscriptionsSnapshot.docs) { - const userId = doc.id; - const userDoc = await admin.firestore().collection('Profiles').doc(userId).get(); - const userData = userDoc.data(); - - if (userData?.microsoftAccounts) { - const email = Object.keys(userData.microsoftAccounts)[0]; - const token = userData.microsoftAccounts[email]; - - try { - await subscribeMicrosoftCalendar(token, userId); - console.log(`Renewed Microsoft subscription for user ${userId}`); - } catch (error) { - console.error(`Failed to renew Microsoft subscription for user ${userId}:`, error); - } - } - } - } catch (error) { - console.error('Error in Microsoft subscription renewal process:', error); - } }); \ No newline at end of file diff --git a/hooks/firebase/useDeleteNotification.ts b/hooks/firebase/useDeleteNotification.ts new file mode 100644 index 0000000..14167ce --- /dev/null +++ b/hooks/firebase/useDeleteNotification.ts @@ -0,0 +1,37 @@ +import {useMutation, useQueryClient} from "react-query"; +import {useAuthContext} from "@/contexts/AuthContext"; +import firestore from "@react-native-firebase/firestore"; +import {Notification} from "@/hooks/firebase/useGetNotifications"; + +export const useDeleteNotification = () => { + const queryClient = useQueryClient(); + const {user} = useAuthContext(); + + return useMutation({ + mutationFn: async (id: string) => { + await firestore() + .collection("Notifications") + .doc(id) + .delete(); + }, + onMutate: async (deletedId) => { + await queryClient.cancelQueries(["notifications", user?.uid]); + + const previousNotifications = queryClient.getQueryData(["notifications", user?.uid]); + + queryClient.setQueryData(["notifications", user?.uid], (old) => + old?.filter((notification) => notification?.id! !== deletedId) ?? [] + ); + + return {previousNotifications}; + }, + onError: (_err, _deletedId, context) => { + if (context?.previousNotifications) { + queryClient.setQueryData(["notifications", user?.uid], context.previousNotifications); + } + }, + onSettled: () => { + queryClient.invalidateQueries(["notifications", user?.uid]); + }, + }); +}; \ No newline at end of file diff --git a/hooks/firebase/useGetNotifications.ts b/hooks/firebase/useGetNotifications.ts index b726c09..6b5dd22 100644 --- a/hooks/firebase/useGetNotifications.ts +++ b/hooks/firebase/useGetNotifications.ts @@ -17,6 +17,7 @@ interface NotificationFirestore { } export interface Notification { + id: string; creatorId: string; familyId: string; content: string; @@ -40,11 +41,14 @@ export const useGetNotifications = () => { const data = doc.data() as NotificationFirestore; return { + id: doc.id, ...data, timestamp: new Date(data.timestamp.seconds * 1000 + data.timestamp.nanoseconds / 1e6), date: data.date ? new Date(data.date.seconds * 1000 + data.date.nanoseconds / 1e6) : undefined }; }); - } + }, + refetchOnWindowFocus: true, + staleTime: 60000, }); }; \ No newline at end of file