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 = []; console.log('Getting push tokens:', { familyId, excludeUserId }); snapshot.forEach(doc => { const data = doc.data(); const userId = doc.id; console.log('Processing user:', { docId: userId, hasToken: !!data.pushToken, excluded: userId === excludeUserId }); if (userId !== excludeUserId && data.pushToken) { console.log('Including token for user:', { userId, excludeUserId }); pushTokens.push(data.pushToken); } else { console.log('Excluding token for user:', { userId, excludeUserId }); } }); 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 = eventData.familyId; const creatorId = eventData.creatorId; const title = eventData.title || ''; const externalOrigin = eventData.externalOrigin || false; const eventId = context.params.eventId; if (!familyId || !creatorId) { console.error('Missing familyId or creatorId in event data'); return; } const timeWindow = Math.floor(Date.now() / 5000); const batchId = `${timeWindow}_${familyId}_${creatorId}_${externalOrigin ? 'sync' : 'manual'}`; const batchRef = admin.firestore().collection('EventBatches').doc(batchId); try { await admin.firestore().runTransaction(async (transaction) => { const batchDoc = await transaction.get(batchRef); if (!batchDoc.exists) { transaction.set(batchRef, { familyId, creatorId, externalOrigin: externalOrigin || false, events: [{ id: eventId, title: title || '', timestamp: new Date().toISOString() // Using ISO string instead of serverTimestamp }], createdAt: admin.firestore.FieldValue.serverTimestamp(), processed: false, expiresAt: new Date(Date.now() + 10000) }); } else { const existingEvents = batchDoc.data().events || []; transaction.update(batchRef, { events: [...existingEvents, { id: eventId, title: title || '', timestamp: new Date().toISOString() // Using ISO string instead of serverTimestamp }] }); } }); console.log(`[HOUSEHOLD_UPDATE] Event created - Processing timestamp updates`, { eventId, familyId, eventTitle: title || 'Untitled' }); const householdsSnapshot = await admin.firestore().collection('Households') .where('familyId', '==', familyId) .get(); console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`); const householdBatch = admin.firestore().batch(); householdsSnapshot.docs.forEach((doc) => { console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`); householdBatch.update(doc.ref, { lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp() }); }); const batchStartTime = Date.now(); await householdBatch.commit(); console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, { familyId, householdsUpdated: householdsSnapshot.size, eventId }); } catch (error) { console.error('Error in event creation handler:', error); throw error; } }); exports.processEventBatches = functions.pubsub .schedule('every 1 minutes') .onRun(async (context) => { const batchesRef = admin.firestore().collection('EventBatches'); const now = admin.firestore.Timestamp.fromDate(new Date()); const snapshot = await batchesRef .where('processed', '==', false) .where('expiresAt', '<=', now) .limit(100) .get(); if (snapshot.empty) return null; const processPromises = snapshot.docs.map(async (doc) => { const batchData = doc.data(); const {familyId, creatorId, externalOrigin, events} = batchData; console.log('Processing batch:', { batchId: doc.id, creatorId, familyId }); try { const pushTokens = await getPushTokensForFamily( familyId, creatorId ); // Add logging to see what tokens are returned console.log('Push tokens retrieved:', { batchId: doc.id, tokenCount: pushTokens.length, tokens: pushTokens }); if (pushTokens.length) { let notificationMessage; if (externalOrigin) { notificationMessage = `Calendar sync completed: ${events.length} ${events.length === 1 ? 'event has' : 'events have'} been added.`; } else { notificationMessage = events.length === 1 ? `New event "${events[0].title}" has been added to the family calendar.` : `${events.length} new events have been added to the family calendar.`; } await sendNotifications(pushTokens, { title: 'New Family Calendar Events', body: notificationMessage, data: { type: externalOrigin ? 'sync' : 'manual', count: events.length } }); await storeNotification({ type: externalOrigin ? 'sync' : 'manual', familyId, content: notificationMessage, timestamp: admin.firestore.FieldValue.serverTimestamp(), creatorId, eventCount: events.length }); } await doc.ref.update({ processed: true, processedAt: admin.firestore.FieldValue.serverTimestamp() }); } catch (error) { console.error(`Error processing event batch ${doc.id}:`, error); await doc.ref.update({ processed: true, processedAt: admin.firestore.FieldValue.serverTimestamp(), error: error.message }); } }); await Promise.all(processPromises); }); async function addToUpdateBatch(eventData, eventId) { const timeWindow = Math.floor(Date.now() / 2000); const batchId = `${timeWindow}_${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) { 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, expiresAt: new Date(Date.now() + 3000) }); } else { 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.cleanupEventBatches = functions.pubsub .schedule('every 24 hours') .onRun(async (context) => { const batchesRef = admin.firestore().collection('EventBatches'); const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000)); while (true) { const oldBatches = await batchesRef .where('processedAt', '<=', dayAgo) .limit(500) .get(); if (oldBatches.empty) break; const batch = admin.firestore().batch(); oldBatches.docs.forEach(doc => batch.delete(doc.ref)); await batch.commit(); } }); exports.processUpdateBatches = functions.pubsub .schedule('every 1 minutes') .onRun(async (context) => { const batchesRef = admin.firestore().collection('UpdateBatches'); const now = admin.firestore.Timestamp.fromDate(new Date()); const snapshot = await batchesRef .where('processed', '==', false) .where('expiresAt', '<=', now) .limit(100) .get(); const processPromises = snapshot.docs.map(async (doc) => { const batchData = doc.data(); try { const pushTokens = await getPushTokensForFamily( batchData.familyId, batchData.lastModifiedBy ); 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 admin.firestore().runTransaction(async (transaction) => { await sendNotifications(pushTokens, { title: batchData.externalOrigin ? "Calendar Sync Complete" : "Events Updated", body: message, data: { type: 'event_update', count: batchData.events.length } }); const notificationRef = admin.firestore().collection('Notifications').doc(); transaction.set(notificationRef, { type: 'event_update', familyId: batchData.familyId, content: message, excludedUser: batchData.lastModifiedBy, timestamp: admin.firestore.FieldValue.serverTimestamp(), count: batchData.events.length, date: batchData.events[0].startDate }); transaction.update(doc.ref, { processed: true, processedAt: admin.firestore.FieldValue.serverTimestamp() }); }); } else { await doc.ref.update({ processed: true, processedAt: admin.firestore.FieldValue.serverTimestamp() }); } } catch (error) { console.error(`Error processing batch ${doc.id}:`, error); await doc.ref.update({ processed: true, processedAt: admin.firestore.FieldValue.serverTimestamp(), error: error.message }); } }); await Promise.all(processPromises); }); exports.cleanupUpdateBatches = functions.pubsub .schedule('every 24 hours') .onRun(async (context) => { const batchesRef = admin.firestore().collection('UpdateBatches'); const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000)); while (true) { const oldBatches = await batchesRef .where('processedAt', '<=', dayAgo) .limit(500) .get(); if (oldBatches.empty) break; const batch = admin.firestore().batch(); oldBatches.docs.forEach(doc => batch.delete(doc.ref)); await batch.commit(); } }); exports.checkUpcomingEvents = functions.pubsub .schedule('every 5 minutes') .onRun(async (context) => { const now = admin.firestore.Timestamp.now(); const eventsSnapshot = await admin.firestore() .collection('Events') .where('startDate', '>=', now) .get(); const processPromises = eventsSnapshot.docs.map(async (doc) => { const event = doc.data(); if (!event?.startDate) return; const {familyId, title, allDay} = event; try { const familyDoc = await admin.firestore().collection('Families').doc(familyId).get(); if (!familyDoc.exists) return; const familySettings = familyDoc.data()?.settings || {}; const reminderTime = familySettings.defaultReminderTime || 15; const eventTime = event.startDate.toDate(); const reminderThreshold = new Date(now.toDate().getTime() + (reminderTime * 60 * 1000)); if (allDay) { const eveningBefore = new Date(eventTime); eveningBefore.setDate(eveningBefore.getDate() - 1); eveningBefore.setHours(20, 0, 0, 0); if (now.toDate() >= eveningBefore && !event.eveningReminderSent) { const pushTokens = await getPushTokensForFamily(familyId); if (pushTokens.length) { await sendNotifications(pushTokens, { title: "Tomorrow's All-Day Event", body: `Tomorrow: ${title}`, data: {type: 'event_reminder', eventId: doc.id} }); await doc.ref.update({eveningReminderSent: true}); } } } else if (eventTime <= reminderThreshold && !event.reminderSent) { const pushTokens = await getPushTokensForFamily(familyId); if (pushTokens.length) { await sendNotifications(pushTokens, { title: "Upcoming Event", body: `In ${reminderTime} minutes: ${title}`, data: {type: 'event_reminder', eventId: doc.id} }); await doc.ref.update({reminderSent: true}); } } } catch (error) { console.error(`Error processing reminder for event ${doc.id}:`, error); } }); await Promise.all(processPromises); }); async function storeNotification(notificationData) { 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", scope: "openid profile email offline_access Calendars.ReadWrite User.Read", }); return response.data.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(); 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"); 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 { refreshedGoogleToken: data.access_token, refreshedRefreshToken: data.refresh_token || refreshToken, }; } catch (error) { console.error("Error refreshing Google token:", error.message); throw error; } } 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)) { 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; } exports.renewGoogleCalendarWatch = functions.pubsub .schedule("every 10 minutes") .onRun(async (context) => { console.log("Starting calendar watch renewal check"); const profilesWithGoogle = await db.collection('Profiles') .where('googleAccounts', '!=', null) .get(); const existingWatches = await db.collection('CalendarWatches').get(); const now = Date.now(); const watchMap = new Map(); existingWatches.forEach(doc => { watchMap.set(doc.id, doc.data()); }); const batch = db.batch(); let processedCount = 0; let newWatchCount = 0; let renewalCount = 0; for (const profile of profilesWithGoogle.docs) { const userId = profile.id; const userData = profile.data(); const existingWatch = watchMap.get(userId); if (!userData.googleAccounts || Object.keys(userData.googleAccounts).length === 0) { continue; } const firstAccount = Object.values(userData.googleAccounts)[0]; const token = firstAccount?.accessToken; if (!token) { console.log(`No token found for user ${userId}`); continue; } const needsRenewal = !existingWatch || !existingWatch.expiration || existingWatch.expiration < (now + 30 * 60 * 1000); if (!needsRenewal) { continue; } try { console.log(`${existingWatch ? 'Renewing' : 'Creating new'} watch for user ${userId}`); const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ id: `${CHANNEL_ID}-${userId}`, type: "web_hook", address: `${WEBHOOK_URL}?userId=${userId}`, params: {ttl: "604800"}, }), }); if (!response.ok) { throw new Error(await response.text()); } const result = await response.json(); batch.set(db.collection('CalendarWatches').doc(userId), { watchId: result.id, resourceId: result.resourceId, expiration: result.expiration, updatedAt: admin.firestore.FieldValue.serverTimestamp(), }, {merge: true}); processedCount++; if (existingWatch) { renewalCount++; } else { newWatchCount++; } console.log(`Successfully ${existingWatch ? 'renewed' : 'created'} watch for user ${userId}`); } catch (error) { console.error(`Failed to ${existingWatch ? 'renew' : 'create'} watch for user ${userId}:`, error); batch.set(db.collection('CalendarWatchErrors').doc(), { userId, error: error.message, timestamp: admin.firestore.FieldValue.serverTimestamp(), }); } } if (processedCount > 0) { await batch.commit(); } console.log(`Completed calendar watch processing:`, { totalProcessed: processedCount, newWatches: newWatchCount, renewals: renewalCount }); }); const tokenRefreshInProgress = new Map(); exports.cleanupTokenRefreshFlags = functions.pubsub .schedule('every 5 minutes') .onRun(() => { tokenRefreshInProgress.clear(); console.log('[CLEANUP] Cleared all token refresh flags'); return null; }); 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 totalEvents = 0; let pageToken = null; const batchSize = 50; try { console.log(`[FETCH] Starting event fetch for user: ${email}`); do { let events = []; const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`); url.searchParams.set("singleEvents", "true"); url.searchParams.set("timeMin", timeMin); url.searchParams.set("timeMax", timeMax); url.searchParams.set("maxResults", batchSize.toString()); if (pageToken) url.searchParams.set("pageToken", pageToken); console.log(`[FETCH] Making request with token: ${token.substring(0, 10)}...`); let response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, }, }); if (response.status === 401 && refreshToken) { console.log(`[TOKEN] Token expired during fetch, refreshing for ${email}`); const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken); if (refreshedGoogleToken) { console.log(`[TOKEN] Token refreshed successfully during fetch`); token = refreshedGoogleToken; // Update token in Firestore await db.collection("Profiles").doc(creatorId).update({ [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken }); // Retry the request with new token response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${refreshedGoogleToken}`, }, }); } } const data = await response.json(); if (!response.ok) { throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`); } 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); }); if (events.length > 0) { console.log(`[FETCH] Saving batch of ${events.length} events`); await saveEventsToFirestore(events); totalEvents += events.length; } pageToken = data.nextPageToken; } while (pageToken); console.log(`[FETCH] Completed with ${totalEvents} total events`); return totalEvents; } catch (error) { console.error(`[ERROR] Failed fetching events for ${email}:`, error); throw 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(`[SYNC] Starting calendar sync for user ${userId} with email ${email}`); try { if (refreshToken) { console.log(`[TOKEN] Initial token refresh for ${email}`); const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken); if (refreshedGoogleToken) { console.log(`[TOKEN] Token refreshed successfully for ${email}`); token = refreshedGoogleToken; await db.collection("Profiles").doc(userId).update({ [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken }); } } const eventCount = await fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId: userId, }); console.log(`[SYNC] Calendar sync completed. Processed ${eventCount} events`); return eventCount; } catch (error) { console.error(`[ERROR] Calendar sync failed for user ${userId}:`, error); throw error; } } exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { const userId = req.query.userId; const calendarId = req.body.resourceId; console.log(`[SYNC START] Received notification for user ${userId} with calendar ID ${calendarId}`); console.log('Request headers:', req.headers); console.log('Request body:', req.body); try { console.log(`[PROFILE] Fetching user profile for ${userId}`); const userDoc = await db.collection("Profiles").doc(userId).get(); if (!userDoc.exists) { console.error(`[ERROR] No profile found for user ${userId}`); return res.status(404).send("User profile not found"); } const userData = userDoc.data(); console.log(`[PROFILE] Found profile data for user ${userId}:`, { hasGoogleAccounts: !!userData.googleAccounts, familyId: userData.familyId }); const {googleAccounts} = userData; const email = Object.keys(googleAccounts || {})[0]; if (!email) { console.error(`[ERROR] No Google account found for user ${userId}`); return res.status(400).send("No Google account found"); } console.log(`[GOOGLE] Using Google account: ${email}`); const accountData = googleAccounts[email] || {}; const token = accountData.accessToken; const refreshToken = accountData.refreshToken; const familyId = userData.familyId; if (!familyId) { console.error(`[ERROR] No family ID found for user ${userId}`); return res.status(400).send("No family ID found"); } console.log(`[SYNC] Starting calendar sync for user ${userId} (family: ${familyId})`); const syncStartTime = Date.now(); await calendarSync({userId, email, token, refreshToken, familyId}); console.log(`[SYNC] Calendar sync completed in ${Date.now() - syncStartTime}ms`); console.log(`[HOUSEHOLDS] Fetching households for family ${familyId}`); const querySnapshot = await db.collection('Households') .where("familyId", "==", familyId) .get(); console.log(`[HOUSEHOLDS] Found ${querySnapshot.size} households to update`); const batch = db.batch(); querySnapshot.docs.forEach((doc) => { console.log(`[HOUSEHOLDS] Adding household ${doc.id} to update batch`); batch.update(doc.ref, { lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp() }); }); console.log(`[HOUSEHOLDS] Committing batch update for ${querySnapshot.size} households`); const batchStartTime = Date.now(); await batch.commit(); console.log(`[HOUSEHOLDS] Batch update completed in ${Date.now() - batchStartTime}ms`); console.log(`[SYNC COMPLETE] Successfully processed sync for user ${userId}`); res.status(200).send("Sync completed successfully."); } catch (error) { console.error(`[ERROR] Error in sendSyncNotification for user ${userId}:`, { errorMessage: error.message, errorStack: error.stack, errorCode: error.code }); 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,isAllDay', $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 => { let startDate, endDate; if (item.isAllDay) { startDate = new Date(item.start.date + 'T00:00:00'); endDate = new Date(new Date(item.end.date + 'T00:00:00').setDate(new Date(item.end.date).getDate() - 1)); } else { startDate = new Date(item.start.dateTime + 'Z'); endDate = new Date(item.end.dateTime + 'Z'); } return { id: item.id, title: item.subject || "", startDate, endDate, allDay: item.isAllDay, familyId, email, creatorId, externalOrigin: "microsoft" }; }); console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`); await saveEventsToFirestore(events); return events.length; } catch (error) { console.error(`Error fetching Microsoft Calendar events for ${email}:`, error); throw error; } } exports.renewMicrosoftSubscriptions = functions.pubsub .schedule('every 24 hours') .onRun(async (context) => { const now = Date.now(); const subs = await db.collection('MicrosoftSubscriptions') .where('expirationDateTime', '<=', new Date(now + 24 * 60 * 60 * 1000)) .get(); if (subs.empty) return null; const profilesSnapshot = await db.collection('Profiles') .where('uid', 'in', subs.docs.map(doc => doc.id)) .get(); const batch = db.batch(); const userTokenMap = {}; profilesSnapshot.forEach(doc => { const data = doc.data(); if (data.microsoftAccounts) { const [email, token] = Object.entries(data.microsoftAccounts)[0] || []; if (token) userTokenMap[doc.id] = {token, email}; } }); for (const subDoc of subs.docs) { const userId = subDoc.id; const userTokens = userTokenMap[userId]; if (!userTokens) continue; try { const subscription = { changeType: "created,updated,deleted", notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`, resource: "/me/calendar/events", expirationDateTime: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(), clientState: userId }; const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${userTokens.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); if (!response.ok) throw new Error(await response.text()); const subscriptionData = await response.json(); batch.set(db.collection('MicrosoftSubscriptions').doc(userId), { subscriptionId: subscriptionData.id, expirationDateTime: subscriptionData.expirationDateTime, updatedAt: admin.firestore.FieldValue.serverTimestamp() }, {merge: true}); } catch (error) { console.error(`Failed to renew Microsoft subscription for ${userId}:`, error); batch.set(db.collection('MicrosoftSubscriptionErrors').doc(), { userId, error: error.message, timestamp: admin.firestore.FieldValue.serverTimestamp() }); } } await batch.commit(); }); exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => { const userId = req.query.userId; try { const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); if (!userData?.microsoftAccounts) { return res.status(200).send(); } const [email, token] = Object.entries(userData.microsoftAccounts)[0] || []; if (!token) return res.status(200).send(); await fetchAndSaveMicrosoftEvents({ token, email, familyId: userData.familyId, creatorId: userId }); res.status(200).send(); } catch (error) { console.error(`Error processing Microsoft webhook for ${userId}:`, error); res.status(500).send(); } }); exports.triggerGoogleSync = functions.https.onCall(async (data, context) => { if (!context.auth) { throw new functions.https.HttpsError( 'unauthenticated', 'Authentication required' ); } try { const {email} = data; const userId = context.auth.uid; const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); if (!userData?.googleAccounts?.[email]) { throw new functions.https.HttpsError( 'failed-precondition', 'No valid Google account found' ); } const accountData = userData.googleAccounts[email]; const eventCount = await calendarSync({ userId, email, token: accountData.accessToken, refreshToken: accountData.refreshToken, familyId: userData.familyId }); return { success: true, eventCount, message: "Google calendar sync completed successfully" }; } catch (error) { console.error('Google sync error:', error); throw new functions.https.HttpsError('internal', error.message); } }); exports.triggerMicrosoftSync = functions.https.onCall(async (data, context) => { if (!context.auth) { throw new functions.https.HttpsError( 'unauthenticated', 'Authentication required' ); } try { const {email} = data; if (!email) { throw new functions.https.HttpsError( 'invalid-argument', 'Email is required' ); } console.log('Starting Microsoft sync for:', {userId: context.auth.uid, email}); const userDoc = await db.collection("Profiles").doc(context.auth.uid).get(); if (!userDoc.exists) { throw new functions.https.HttpsError( 'not-found', 'User profile not found' ); } const userData = userDoc.data(); const accountData = userData.microsoftAccounts?.[email]; if (!accountData) { throw new functions.https.HttpsError( 'failed-precondition', 'Microsoft account not found' ); } let {accessToken, refreshToken} = accountData; // Try to refresh token if it exists if (refreshToken) { try { const refreshedTokens = await refreshMicrosoftToken(refreshToken); accessToken = refreshedTokens.accessToken; refreshToken = refreshedTokens.refreshToken || refreshToken; // Update the stored tokens await db.collection("Profiles").doc(context.auth.uid).update({ [`microsoftAccounts.${email}`]: { ...accountData, accessToken, refreshToken, lastRefresh: admin.firestore.FieldValue.serverTimestamp() } }); } catch (refreshError) { console.error('Token refresh failed:', refreshError); throw new functions.https.HttpsError( 'failed-precondition', 'Failed to refresh Microsoft token. Please reconnect your account.', {requiresReauth: true} ); } } else if (!accessToken) { throw new functions.https.HttpsError( 'failed-precondition', 'Microsoft account requires authentication. Please reconnect your account.', {requiresReauth: true} ); } try { console.log('Fetching Microsoft events with token'); const eventCount = await fetchAndSaveMicrosoftEvents({ token: accessToken, refreshToken, email, familyId: userData.familyId, creatorId: context.auth.uid }); console.log('Microsoft sync completed successfully:', {eventCount}); return { success: true, eventCount, message: "Microsoft calendar sync completed successfully" }; } catch (syncError) { // Check if the error is due to invalid token if (syncError.message?.includes('401') || syncError.message?.includes('unauthorized') || syncError.message?.includes('invalid_grant')) { throw new functions.https.HttpsError( 'unauthenticated', 'Microsoft authentication expired. Please reconnect your account.', {requiresReauth: true} ); } throw new functions.https.HttpsError( 'internal', syncError.message || 'Failed to sync Microsoft calendar', {originalError: syncError} ); } } catch (error) { console.error('Microsoft sync function error:', error); if (error instanceof functions.https.HttpsError) { throw error; } throw new functions.https.HttpsError( 'internal', error.message || 'Unknown error occurred', {originalError: error} ); } }); exports.updateHouseholdTimestampOnEventUpdate = functions.firestore .document('Events/{eventId}') .onUpdate(async (change, context) => { const eventData = change.after.data(); const familyId = eventData.familyId; const eventId = context.params.eventId; console.log(`[HOUSEHOLD_UPDATE] Event updated - Processing timestamp updates`, { eventId, familyId, eventTitle: eventData.title || 'Untitled' }); try { const householdsSnapshot = await db.collection('Households') .where('familyId', '==', familyId) .get(); console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`); const batch = db.batch(); householdsSnapshot.docs.forEach((doc) => { console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`); batch.update(doc.ref, { lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp() }); }); const batchStartTime = Date.now(); await batch.commit(); console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, { familyId, householdsUpdated: householdsSnapshot.size, eventId }); } catch (error) { console.error(`[HOUSEHOLD_UPDATE] Error updating households for event update`, { eventId, familyId, error: error.message, stack: error.stack }); throw error; } });