const {onRequest} = require("firebase-functions/v2/https"); const {getAuth} = require("firebase-admin/auth"); const {getFirestore} = 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 pushTokens = []; 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; } let pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId); if (!pushTokens.length) { console.log('No push tokens available for the event.'); return; } eventCount++; if (notificationTimeout) { clearTimeout(notificationTimeout); } 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; } 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); } } eventCount = 0; pushTokens = []; }, 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 push tokens 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; } 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."); } });