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"; async function getPushTokensForFamily(familyId, excludeUserId = null) { const usersRef = db.collection('Profiles'); const snapshot = await usersRef.where('familyId', '==', familyId).get(); let pushTokens = []; snapshot.forEach(doc => { const data = doc.data(); if ((!excludeUserId || data.uid !== excludeUserId) && data.pushToken) { pushTokens.push(data.pushToken); } }); return pushTokens; } exports.sendOverviews = functions.pubsub .schedule('0 20 * * *') .onRun(async (context) => { const familiesSnapshot = await admin.firestore().collection('Families').get(); for (const familyDoc of familiesSnapshot.docs) { const familyId = familyDoc.id; const familySettings = familyDoc.data()?.settings || {}; const overviewTime = familySettings.overviewTime || '20:00'; const [hours, minutes] = overviewTime.split(':'); const now = new Date(); if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) { continue; } try { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); const tomorrowEnd = new Date(tomorrow); tomorrowEnd.setHours(23, 59, 59, 999); const weekEnd = new Date(tomorrow); weekEnd.setDate(weekEnd.getDate() + 7); weekEnd.setHours(23, 59, 59, 999); const [tomorrowEvents, weekEvents] = await Promise.all([ admin.firestore() .collection('Events') .where('familyId', '==', familyId) .where('startDate', '>=', tomorrow) .where('startDate', '<=', tomorrowEnd) .orderBy('startDate') .limit(3) .get(), admin.firestore() .collection('Events') .where('familyId', '==', familyId) .where('startDate', '>', tomorrowEnd) .where('startDate', '<=', weekEnd) .orderBy('startDate') .limit(3) .get() ]); if (tomorrowEvents.empty && weekEvents.empty) { continue; } let notificationBody = ''; if (!tomorrowEvents.empty) { notificationBody += 'Tomorrow: '; const tomorrowTitles = tomorrowEvents.docs.map(doc => doc.data().title); notificationBody += tomorrowTitles.join(', '); if (tomorrowEvents.size === 3) notificationBody += ' and more...'; } if (!weekEvents.empty) { if (notificationBody) notificationBody += '\n\n'; notificationBody += 'This week: '; const weekTitles = weekEvents.docs.map(doc => doc.data().title); notificationBody += weekTitles.join(', '); if (weekEvents.size === 3) notificationBody += ' and more...'; } const pushTokens = await getPushTokensForFamily(familyId); await sendNotifications(pushTokens, { title: "Family Calendar Overview", body: notificationBody, data: { type: 'calendar_overview', date: tomorrow.toISOString() } }); await storeNotification({ type: 'calendar_overview', familyId, content: notificationBody, timestamp: admin.firestore.FieldValue.serverTimestamp(), }); } catch (error) { console.error(`Error sending overview for family ${familyId}:`, error); } } }); exports.sendWeeklyOverview = functions.pubsub .schedule('0 20 * * 0') .onRun(async (context) => { const familiesSnapshot = await admin.firestore().collection('Families').get(); for (const familyDoc of familiesSnapshot.docs) { const familyId = familyDoc.id; const familySettings = familyDoc.data()?.settings || {}; const overviewTime = familySettings.weeklyOverviewTime || '20:00'; const [hours, minutes] = overviewTime.split(':'); const now = new Date(); if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) { continue; } try { const weekStart = new Date(); weekStart.setDate(weekStart.getDate() + 1); weekStart.setHours(0, 0, 0, 0); const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 7); const weekEvents = await admin.firestore() .collection('Events') .where('familyId', '==', familyId) .where('startDate', '>=', weekStart) .where('startDate', '<=', weekEnd) .orderBy('startDate') .limit(3) .get(); if (weekEvents.empty) continue; const eventTitles = weekEvents.docs.map(doc => doc.data().title); const notificationBody = `Next week: ${eventTitles.join(', ')}${weekEvents.size === 3 ? ' and more...' : ''}`; const pushTokens = await getPushTokensForFamily(familyId); await sendNotifications(pushTokens, { title: "Weekly Calendar Overview", body: notificationBody, data: { type: 'weekly_overview', weekStart: weekStart.toISOString() } }); await storeNotification({ type: 'weekly_overview', familyId, content: notificationBody, timestamp: admin.firestore.FieldValue.serverTimestamp() }); } catch (error) { console.error(`Error sending weekly overview for family ${familyId}:`, error); } } }); exports.sendNotificationOnEventCreation = functions.firestore .document('Events/{eventId}') .onCreate(async (snapshot, context) => { const eventData = snapshot.data(); const {familyId, creatorId, email, title, externalOrigin} = eventData; if (!familyId || !creatorId) { console.error('Missing familyId or creatorId in event data'); 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 ); 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 () => { let eventMessage; if (externalOrigin) { eventMessage = eventCount === 1 ? `Calendar sync completed: "${title}" has been added.` : `Calendar sync completed: ${eventCount} new events have been added.`; } else { eventMessage = eventCount === 1 ? `New event "${title}" has been added to the family calendar.` : `${eventCount} new events have been added to the family calendar.`; } 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); } }); // Store batches in Firestore instead of memory async function addToUpdateBatch(eventData, eventId) { const batchId = `${eventData.familyId}_${eventData.lastModifiedBy}`; const batchRef = admin.firestore().collection('UpdateBatches').doc(batchId); try { await admin.firestore().runTransaction(async (transaction) => { const batchDoc = await transaction.get(batchRef); if (!batchDoc.exists) { // Create new batch transaction.set(batchRef, { familyId: eventData.familyId, lastModifiedBy: eventData.lastModifiedBy, externalOrigin: eventData.externalOrigin, events: [{ id: eventId, title: eventData.title, startDate: eventData.startDate }], createdAt: admin.firestore.FieldValue.serverTimestamp(), processed: false }); } else { // Update existing batch const existingEvents = batchDoc.data().events || []; transaction.update(batchRef, { events: [...existingEvents, { id: eventId, title: eventData.title, startDate: eventData.startDate }] }); } }); } catch (error) { console.error('Error adding to update batch:', error); throw error; } } 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; if (JSON.stringify(beforeData) === JSON.stringify(afterData)) { return null; } try { await addToUpdateBatch({ familyId, title, lastModifiedBy, externalOrigin, startDate }, context.params.eventId); } catch (error) { console.error('Error in onEventUpdate:', error); } }); // 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 snapshot = await batchesRef .where('processed', '==', false) .where('createdAt', '<=', cutoff) .get(); const processPromises = snapshot.docs.map(async (doc) => { const batchData = doc.data(); try { const pushTokens = await getPushTokensForFamily( batchData.familyId, batchData.lastModifiedBy ); if (pushTokens.length) { let message; if (batchData.externalOrigin) { message = `Calendar sync completed: ${batchData.events.length} events have been updated`; } else { message = batchData.events.length === 1 ? `Event "${batchData.events[0].title}" has been updated` : `${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 }); } // 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 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 oldBatches = await batchesRef .where('processedAt', '<=', dayAgo) .get(); const deletePromises = oldBatches.docs.map(doc => doc.ref.delete()); await Promise.all(deletePromises); }); // 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(); for (const doc of eventsSnapshot.docs) { const event = doc.data(); const {startDate, familyId, title, allDay, creatorId} = event; if (startDate.toDate() < now.toDate()) continue; try { const familyDoc = await admin.firestore().collection('Families').doc(familyId).get(); const familySettings = familyDoc.data()?.settings || {}; const reminderTime = familySettings.defaultReminderTime || 15; // minutes const eventTime = 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 } }); await doc.ref.update({reminderSent: true}); } } catch (error) { console.error(`Error processing reminder for event ${doc.id}:`, error); } } }); async function storeNotification(notificationData) { try { await admin.firestore() .collection('Notifications') .add(notificationData); } catch (error) { console.error('Error storing notification:', error); } } async function sendNotifications(pushTokens, notification) { if (!pushTokens.length) return; const messages = pushTokens .filter(token => Expo.isExpoPushToken(token)) .map(pushToken => ({ to: pushToken, sound: 'default', priority: 'high', ...notification })); const chunks = expo.chunkPushNotifications(messages); for (let chunk of chunks) { try { const tickets = await expo.sendPushNotificationsAsync(chunk); for (let ticket of tickets) { if (ticket.status === "error") { if (ticket.details?.error === "DeviceNotRegistered") { await removeInvalidPushToken(ticket.to); } console.error('Push notification error:', ticket.message); } } } catch (error) { console.error('Error sending notifications:', error); } } } 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.removeSubUser = 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 removal", {requestBody: request.body.data}); const {userId, familyId} = request.body.data; if (!userId || !familyId) { logger.warn("Missing required fields in request body", {requestBody: request.body.data}); response.status(400).json({error: "Missing required fields"}); return; } try { const userProfile = await getFirestore() .collection("Profiles") .doc(userId) .get(); if (!userProfile.exists) { logger.error("User profile not found", {userId}); response.status(404).json({error: "User not found"}); return; } if (userProfile.data().familyId !== familyId) { logger.error("User does not belong to the specified family", { userId, requestedFamilyId: familyId, actualFamilyId: userProfile.data().familyId }); response.status(403).json({error: "User does not belong to the specified family"}); return; } await getFirestore() .collection("Profiles") .doc(userId) .delete(); logger.info("User profile deleted from Firestore", {userId}); await getAuth().deleteUser(userId); logger.info("User authentication deleted", {userId}); response.status(200).json({ data: { message: "User removed successfully", success: true } }); } catch (error) { logger.error("Failed to remove user", {error: error.message}); response.status(500).json({error: "Failed to remove user"}); return; } } catch (error) { logger.error("Error in removeSubUser 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) { try { const profilesRef = db.collection('Profiles'); const snapshot = await profilesRef.where('pushToken', '==', pushToken).get(); const batch = db.batch(); snapshot.forEach(doc => { batch.update(doc.ref, { pushToken: admin.firestore.FieldValue.delete() }); }); await batch.commit(); console.log(`Removed invalid push token: ${pushToken}`); } catch (error) { console.error('Error removing invalid push token:', error); } } 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 + 'T00:00:00'), endDate: item.end?.dateTime ? new Date(item.end.dateTime) : new Date(new Date(item.end.date + 'T00:00:00').setDate(new Date(item.end.date).getDate() - 1)), 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."); } }); async function refreshMicrosoftToken(refreshToken) { try { console.log("Refreshing Microsoft token..."); const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af", scope: "openid profile email offline_access Calendars.ReadWrite User.Read", refresh_token: refreshToken, grant_type: 'refresh_token' }) }); if (!response.ok) { const errorData = await response.json(); console.error("Error refreshing Microsoft token:", errorData); throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`); } const data = await response.json(); console.log("Microsoft token refreshed successfully"); return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken }; } catch (error) { console.error("Error refreshing Microsoft token:", error.message); throw error; } } async function fetchAndSaveMicrosoftEvents({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(); try { console.log(`Fetching Microsoft calendar events for user: ${email}`); const url = `https://graph.microsoft.com/v1.0/me/calendar/events`; const queryParams = new URLSearchParams({ $select: 'subject,start,end,id', $filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'` }); const response = await fetch(`${url}?${queryParams}`, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } }); const data = await response.json(); if (response.status === 401 && refreshToken) { console.log(`Token expired for user: ${email}, attempting to refresh`); const {accessToken: newToken} = await refreshMicrosoftToken(refreshToken); if (newToken) { return fetchAndSaveMicrosoftEvents({ token: newToken, refreshToken, email, familyId, creatorId }); } } if (!response.ok) { 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" })); console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`); await saveEventsToFirestore(events); } 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}`); 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 }; 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) }); if (!response.ok) { throw new Error(`Failed to create subscription: ${response.statusText}`); } 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; } } 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 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]; await fetchAndSaveMicrosoftEvents({ token, email, familyId: userData.familyId, creatorId: userId }); res.status(200).send(); } catch (error) { console.error(`Error processing Microsoft calendar webhook for user ${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); } });