diff --git a/firebase/functions/index.js b/firebase/functions/index.js index 8ca6724..ff9d576 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -9,27 +9,21 @@ const {Expo} = require('expo-server-sdk'); admin.initializeApp(); const db = admin.firestore(); -let expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN }); +let expo = new Expo({accessToken: process.env.EXPO_ACCESS_TOKEN}); let notificationTimeout = null; let eventCount = 0; -let pushTokens = []; +let notificationInProgress = false; const GOOGLE_CALENDAR_ID = "primary"; const CHANNEL_ID = "cally-family-calendar"; const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification"; - exports.sendNotificationOnEventCreation = functions.firestore .document('Events/{eventId}') .onCreate(async (snapshot, context) => { const eventData = snapshot.data(); const { familyId, creatorId, email } = eventData; - // if (email) { - // console.log('Event has an email field. Skipping notification.'); - // return; - // } - if (!familyId || !creatorId) { console.error('Missing familyId or creatorId in event data'); return; @@ -44,60 +38,62 @@ exports.sendNotificationOnEventCreation = functions.firestore eventCount++; - if (notificationTimeout) { - clearTimeout(notificationTimeout); - } + // Only set up the notification timeout if it's not already in progress + if (!notificationInProgress) { + notificationInProgress = true; - notificationTimeout = setTimeout(async () => { - const eventMessage = eventCount === 1 - ? `An event "${eventData.title}" has been added. Check it out!` - : `${eventCount} new events have been added.`; + notificationTimeout = setTimeout(async () => { + const eventMessage = eventCount === 1 + ? `An event "${eventData.title}" has been added. Check it out!` + : `${eventCount} new events have been added.`; - let messages = pushTokens.map(pushToken => { - if (!Expo.isExpoPushToken(pushToken)) { - console.error(`Push token ${pushToken} is not a valid Expo push token`); - return null; - } + 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: 'New Events Added!', - body: eventMessage, - data: { eventId: context.params.eventId }, - }; - }).filter(Boolean); + return { + to: pushToken, + sound: 'default', + title: 'New Events Added!', + body: eventMessage, + data: { eventId: context.params.eventId }, + }; + }).filter(Boolean); - let chunks = expo.chunkPushNotifications(messages); - let tickets = []; + let chunks = expo.chunkPushNotifications(messages); + let tickets = []; - for (let chunk of chunks) { - try { - let ticketChunk = await expo.sendPushNotificationsAsync(chunk); - tickets.push(...ticketChunk); + 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); + 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); } - } catch (error) { - console.error('Error sending notification:', error); } - } - eventCount = 0; - pushTokens = []; + // Reset state variables after notifications are sent + eventCount = 0; + pushTokens = []; + notificationInProgress = false; - }, 5000); + }, 5000); + } }); - exports.createSubUser = onRequest(async (request, response) => { const authHeader = request.get('Authorization'); @@ -200,7 +196,10 @@ exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(asyn const googleToken = profileData?.googleAccounts?.[googleEmail]?.refreshToken; if (googleToken) { const {refreshedGoogleToken, refreshedRefreshToken} = await refreshGoogleToken(googleToken); - const updatedGoogleAccounts = {...profileData.googleAccounts, [googleEmail]: {accessToken: refreshedGoogleToken, refreshToken: refreshedRefreshToken}}; + const updatedGoogleAccounts = { + ...profileData.googleAccounts, + [googleEmail]: {accessToken: refreshedGoogleToken, refreshToken: refreshedRefreshToken} + }; await profileDoc.ref.update({googleAccounts: updatedGoogleAccounts}); console.log(`Google token updated for user ${profileDoc.id}`); } @@ -216,7 +215,10 @@ exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(asyn const microsoftToken = profileData?.microsoftAccounts?.[microsoftEmail]; if (microsoftToken) { const refreshedMicrosoftToken = await refreshMicrosoftToken(microsoftToken); - const updatedMicrosoftAccounts = {...profileData.microsoftAccounts, [microsoftEmail]: refreshedMicrosoftToken}; + const updatedMicrosoftAccounts = { + ...profileData.microsoftAccounts, + [microsoftEmail]: refreshedMicrosoftToken + }; await profileDoc.ref.update({microsoftAccounts: updatedMicrosoftAccounts}); console.log(`Microsoft token updated for user ${profileDoc.id}`); } @@ -486,7 +488,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`); try { - // Fetch push tokens for the specific user + // Fetch user profile data for the specific user const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); @@ -502,47 +504,197 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { return; } - const syncMessage = "New events have been synced."; + // Call calendarSync with necessary parameters + const {googleAccounts} = userData; + const email = Object.keys(googleAccounts || {})[0]; // Assuming the first account is the primary + const accountData = googleAccounts[email] || {}; + const token = accountData.accessToken; + const refreshToken = accountData.refreshToken; + const familyId = userData.familyId; - let messages = pushTokens.map(pushToken => { - if (!Expo.isExpoPushToken(pushToken)) { - console.error(`Push token ${pushToken} is not a valid Expo push token`); - return null; - } + console.log("Starting calendar sync..."); + await calendarSync({userId, email, token, refreshToken, familyId}); + console.log("Calendar sync completed."); - return { - to: pushToken, - sound: "default", - title: "Event Sync", - body: syncMessage, - data: { userId, calendarId }, - }; - }).filter(Boolean); + // 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."); + } catch (error) { + console.error(`Error in sendSyncNotification for user ${userId}:`, error.message); + res.status(500).send("Failed to send sync notification."); + } +}); - let chunks = expo.chunkPushNotifications(messages); - let tickets = []; +async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }) { + const baseDate = new Date(); + const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString(); + const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString(); - for (let chunk of chunks) { - try { - let ticketChunk = await expo.sendPushNotificationsAsync(chunk); - tickets.push(...ticketChunk); + let events = []; + let pageToken = null; - 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); - } - } + try { + console.log(`Fetching events for user: ${email}`); + + // Fetch all events from Google Calendar within the specified time range + do { + 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); + if (pageToken) url.searchParams.set("pageToken", pageToken); + + const response = await fetch(url.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + 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); + token = refreshedToken; + + if (token) { + return fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }); + } else { + console.error(`Failed to refresh token for user: ${email}`); + await clearToken(email); + return; } - } catch (error) { - console.error("Error sending notification:", error.message); } + + 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, + title: item.summary || "", + startDate: item.start?.dateTime ? new Date(item.start.dateTime) : new Date(item.start.date), + endDate: item.end?.dateTime ? new Date(item.end.dateTime) : new Date(item.end.date), + allDay: !item.start?.dateTime, + familyId, + email, + creatorId, // Add creatorId to each event + }; + events.push(googleEvent); + console.log(`Processed event: ${JSON.stringify(googleEvent)}`); + }); + + pageToken = data.nextPageToken; + } while (pageToken); + + console.log(`Saving events to Firestore for user: ${email}`); + await saveEventsToFirestore(events); + } catch (error) { + console.error(`Error fetching Google Calendar events for ${email}:`, error); + } +} + +async function saveEventsToFirestore(events) { + const batch = db.batch(); + events.forEach((event) => { + const eventRef = db.collection("Events").doc(event.id); + batch.set(eventRef, event, { merge: true }); + }); + await batch.commit(); +} + +async function calendarSync({ userId, email, token, refreshToken, familyId }) { + console.log(`Starting calendar sync for user ${userId} with email ${email}`); + try { + await fetchAndSaveGoogleEvents({ + token, + refreshToken, + email, + familyId, + creatorId: userId, + }); + console.log("Calendar events synced successfully."); + } 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) => { + const userId = req.query.userId; + const calendarId = req.body.resourceId; + + console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`); + + try { + const userDoc = await db.collection("Profiles").doc(userId).get(); + const userData = userDoc.data(); + + let pushTokens = []; + if (userData && userData.pushToken) { + pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken]; } - console.log(`Sync notification sent for user ${userId}`); + if (pushTokens.length === 0) { + console.log(`No push tokens found for user ${userId}`); + res.status(200).send("No push tokens found for user."); + return; + } + + const { googleAccounts } = userData; + const email = Object.keys(googleAccounts || {})[0]; + const accountData = googleAccounts[email] || {}; + const token = accountData.accessToken; + const refreshToken = accountData.refreshToken; + const familyId = userData.familyId; + + console.log("Starting calendar sync..."); + await calendarSync({ userId, email, token, refreshToken, familyId }); + console.log("Calendar sync completed."); + res.status(200).send("Sync notification sent."); } catch (error) { console.error(`Error in sendSyncNotification for user ${userId}:`, error.message); diff --git a/hooks/useCalSync.ts b/hooks/useCalSync.ts index 4f670a2..a6dc5f1 100644 --- a/hooks/useCalSync.ts +++ b/hooks/useCalSync.ts @@ -308,8 +308,8 @@ export const useCalSync = () => { const handleNotification = async (notification: Notifications.Notification) => { const eventId = notification?.request?.content?.data?.eventId; - await resyncAllCalendars(); - // queryClient.invalidateQueries(["events"]); + // await resyncAllCalendars(); + queryClient.invalidateQueries(["events"]); }; const sub = Notifications.addNotificationReceivedListener(handleNotification); diff --git a/package.json b/package.json index d20b1d9..0757d8f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "prebuild-build-submit-ios": "yarn run prebuild && yarn run build-ios && yarn run submit", "prebuild-build-submit-ios-cicd": "yarn build-ios-cicd", "prebuild-build-submit-cicd": "yarn build-cicd", - "postinstall": "patch-package" + "postinstall": "patch-package", + "functions-deploy": "cd firebase/functions && firebase deploy --only functions" }, "jest": { "preset": "jest-expo"