const {onRequest} = require("firebase-functions/v2/https"); const {getAuth} = require("firebase-admin/auth"); const {getFirestore, Timestamp} = require("firebase-admin/firestore"); const logger = require("firebase-functions/logger"); const functions = require('firebase-functions'); const admin = require('firebase-admin'); const {Expo} = require('expo-server-sdk'); admin.initializeApp(); const db = admin.firestore(); let expo = new Expo({accessToken: process.env.EXPO_ACCESS_TOKEN}); let notificationTimeout = null; let eventCount = 0; 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, title } = eventData; if (!!eventData?.externalOrigin) { console.log('Externally synced event, ignoring.') return; } if (!familyId || !creatorId) { console.error('Missing familyId or creatorId in event data'); return; } let pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId); if (!pushTokens.length) { console.log('No push tokens available for the event.'); return; } eventCount++; // 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 "${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; } 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 = []; 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 for record-keeping const notificationData = { creatorId, familyId, content: eventMessage, eventId: context.params.eventId, timestamp: Timestamp.now(), }; 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 after notifications are sent eventCount = 0; pushTokens = []; notificationInProgress = false; }, 5000); } }); exports.createSubUser = onRequest(async (request, response) => { const authHeader = request.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { logger.warn("Missing or incorrect Authorization header", {authHeader}); response.status(401).json({error: 'Unauthorized'}); return; } try { const token = authHeader.split('Bearer ')[1]; logger.info("Verifying ID token", {token}); let decodedToken; try { decodedToken = await getAuth().verifyIdToken(token); logger.info("ID token verified successfully", {uid: decodedToken.uid}); } catch (verifyError) { logger.error("ID token verification failed", {error: verifyError.message}); response.status(401).json({error: 'Unauthorized: Invalid token'}); return; } logger.info("Processing user creation", {requestBody: request.body.data}); const {userType, firstName, lastName, email, password, familyId} = request.body.data; if (!email || !password || !firstName || !userType || !familyId) { logger.warn("Missing required fields in request body", {requestBody: request.body.data}); response.status(400).json({error: "Missing required fields"}); return; } let userRecord; try { userRecord = await getAuth().createUser({ email, password, displayName: `${firstName} ${lastName}`, }); logger.info("User record created", {userId: userRecord.uid}); } catch (createUserError) { logger.error("User creation failed", {error: createUserError.message}); response.status(500).json({error: "Failed to create user"}); return; } const userProfile = { userType, firstName, lastName, familyId, email, uid: userRecord.uid }; try { await getFirestore().collection("Profiles").doc(userRecord.uid).set(userProfile); logger.info("User profile saved to Firestore", {userId: userRecord.uid}); } catch (firestoreError) { logger.error("Failed to save user profile to Firestore", {error: firestoreError.message}); response.status(500).json({error: "Failed to save user profile"}); return; } response.status(200).json({ data: { message: "User created successfully", userId: userRecord.uid, } }); } catch (error) { logger.error("Error in createSubUser function", {error: error.message}); response.status(500).json({data: {error: error.message}}); } }); exports.generateCustomToken = onRequest(async (request, response) => { try { const {userId} = request.body.data; console.log("Generating custom token for userId", {userId}); if (!userId) { response.status(400).json({error: 'Missing userId'}); return; } const customToken = await getAuth().createCustomToken(userId); response.status(200).json({data: {token: customToken}}); } catch (error) { console.error("Error generating custom token", {error: error.message}); response.status(500).json({error: "Failed to generate custom token"}); } }); exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(async (context) => { console.log('Running token refresh job...'); const profilesSnapshot = await db.collection('Profiles').get(); profilesSnapshot.forEach(async (profileDoc) => { const profileData = profileDoc.data(); if (profileData.googleAccounts) { try { for (const googleEmail of Object.keys(profileData?.googleAccounts)) { const googleToken = profileData?.googleAccounts?.[googleEmail]?.refreshToken; if (googleToken) { const {refreshedGoogleToken, refreshedRefreshToken} = await refreshGoogleToken(googleToken); const updatedGoogleAccounts = { ...profileData.googleAccounts, [googleEmail]: {accessToken: refreshedGoogleToken, refreshToken: refreshedRefreshToken} }; await profileDoc.ref.update({googleAccounts: updatedGoogleAccounts}); console.log(`Google token updated for user ${profileDoc.id}`); } } } catch (error) { console.error(`Error refreshing Google token for user ${profileDoc.id}:`, error.message); } } if (profileData.microsoftAccounts) { try { for (const microsoftEmail of Object.keys(profileData?.microsoftAccounts)) { const microsoftToken = profileData?.microsoftAccounts?.[microsoftEmail]; if (microsoftToken) { const refreshedMicrosoftToken = await refreshMicrosoftToken(microsoftToken); const updatedMicrosoftAccounts = { ...profileData.microsoftAccounts, [microsoftEmail]: refreshedMicrosoftToken }; await profileDoc.ref.update({microsoftAccounts: updatedMicrosoftAccounts}); console.log(`Microsoft token updated for user ${profileDoc.id}`); } } } catch (error) { console.error(`Error refreshing Microsoft token for user ${profileDoc.id}:`, error.message); } } if (profileData.appleAccounts) { try { for (const appleEmail of Object.keys(profileData?.appleAccounts)) { const appleToken = profileData?.appleAccounts?.[appleEmail]; const refreshedAppleToken = await refreshAppleToken(appleToken); const updatedAppleAccounts = {...profileData.appleAccounts, [appleEmail]: refreshedAppleToken}; await profileDoc.ref.update({appleAccunts: updatedAppleAccounts}); console.log(`Apple token updated for user ${profileDoc.id}`); } } catch (error) { console.error(`Error refreshing Apple token for user ${profileDoc.id}:`, error.message); } } }); return null; }); async function refreshMicrosoftToken(refreshToken) { try { 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 }); return response.data.access_token; // Return the new access token } catch (error) { console.error("Error refreshing Microsoft token:", error); throw error; } } async function getPushTokensForEvent() { const usersRef = db.collection('Profiles'); const snapshot = await usersRef.get(); let pushTokens = []; snapshot.forEach(doc => { const data = doc.data(); if (data.pushToken) { pushTokens.push(data.pushToken); } }); console.log('Push Tokens:', pushTokens); return pushTokens; } async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) { const usersRef = db.collection('Profiles'); const snapshot = await usersRef.where('familyId', '==', familyId).get(); let pushTokens = []; snapshot.forEach(doc => { const data = doc.data(); // Exclude the creator if (data.uid !== creatorId && data.pushToken) { pushTokens.push(data.pushToken); } }); return pushTokens; } async function removeInvalidPushToken(pushToken) { // TODO } const fetch = require("node-fetch"); // Function to refresh Google Token with additional logging async function refreshGoogleToken(refreshToken) { try { console.log("Refreshing Google token..."); const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com", }), }); if (!response.ok) { const errorData = await response.json(); console.error("Error refreshing Google token:", errorData); throw new Error(`Failed to refresh Google token: ${errorData.error || response.statusText}`); } 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 }; } catch (error) { console.error("Error refreshing Google token:", error.message); throw error; } } // 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 = {}; const profilesSnapshot = await db.collection("Profiles").get(); await Promise.all( profilesSnapshot.docs.map(async (doc) => { const profileData = doc.data(); 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; if (refreshToken) { try { console.log(`Refreshing token for user ${doc.id} (email: ${googleEmail})`); const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken); tokens[doc.id] = accessToken; console.log(`Token refreshed successfully for user ${doc.id}`); } catch (error) { tokens[doc.id] = accountInfo?.accessToken; console.error(`Failed to refresh token for user ${doc.id}:`, error.message); } } else { console.log(`No refresh token available for user ${doc.id} (email: ${googleEmail})`); } } }) ); console.log("Access tokens fetched and refreshed as needed"); 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`; // 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}`, }, } ); 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 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", }, }), }); 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 result = JSON.parse(responseText); console.log(`Successfully set up Google Calendar watch for user ${userId}`, result); // 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); } catch (error) { console.error(`Error renewing Google Calendar watch for user ${userId}:`, error.message); } } console.log("Google Calendar watch renewal process completed"); } catch (error) { console.error("Error in renewGoogleCalendarWatch function:", error.message); } }); // 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 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 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."); // 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."); } }); 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(); let events = []; let pageToken = null; 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; } } 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, externalOrigin: "google", }; 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]; } 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); res.status(500).send("Failed to send sync notification."); } });