diff --git a/firebase/functions/index.js b/firebase/functions/index.js index 4defa60..2079284 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -1,1448 +1,291 @@ -const {onRequest} = require("firebase-functions/v2/https"); -const {getAuth} = require("firebase-admin/auth"); -const {getFirestore, Timestamp} = require("firebase-admin/firestore"); +const { onRequest } = require("firebase-functions/v2/https"); +const { getAuth } = require("firebase-admin/auth"); +const { getFirestore, Timestamp, FieldValue, FieldPath } = 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'); +const functions = require("firebase-functions"); +const admin = require("firebase-admin"); +const fetch = require("node-fetch"); +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; +let expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN }); const GOOGLE_CALENDAR_ID = "primary"; const CHANNEL_ID = "cally-family-calendar"; const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification"; +/* ───────────────────────────────── + PUSH NOTIFICATION HELPERS +───────────────────────────────── */ async function getPushTokensForFamily(familyId, excludeUserId = null) { - const usersRef = db.collection('Profiles'); - const snapshot = await usersRef.where('familyId', '==', familyId).get(); + const snapshot = await db.collection("Profiles") + .where("familyId", "==", familyId) + .get(); + let pushTokens = []; - - console.log('Getting push tokens:', { - familyId, - excludeUserId - }); - + logger.info("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 - }); - + logger.debug("Processing user", { docId: userId, hasToken: !!data.pushToken, excluded: userId === excludeUserId }); if (userId !== excludeUserId && data.pushToken) { - console.log('Including token for user:', { - userId, - excludeUserId - }); + logger.info("Including token for user", { userId, excludeUserId }); pushTokens.push(data.pushToken); } else { - console.log('Excluding token for user:', { - userId, - excludeUserId - }); + logger.debug("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, - eventId: events.length === 1 ? events[0].id : undefined - }); - } - - 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 snapshot = await db.collection("Profiles") + .where("pushToken", "==", pushToken) + .get(); const batch = db.batch(); - snapshot.forEach(doc => { - batch.update(doc.ref, { - pushToken: admin.firestore.FieldValue.delete() - }); - }); - + snapshot.forEach(doc => batch.update(doc.ref, { pushToken: FieldValue.delete() })); await batch.commit(); - console.log(`Removed invalid push token: ${pushToken}`); + logger.info(`Removed invalid push token: ${pushToken}`); } catch (error) { - console.error('Error removing invalid push token:', error); + logger.error("Error removing invalid push token", error); } } -const fetch = require("node-fetch"); +async function sendNotifications(pushTokens, notification) { + if (!pushTokens.length) return; + const messages = pushTokens + .filter(token => Expo.isExpoPushToken(token)) + .map(token => ({ + to: token, + sound: "default", + priority: "high", + ...notification, + })); + const chunks = expo.chunkPushNotifications(messages); + for (let chunk of chunks) { + try { + const tickets = await expo.sendPushNotificationsAsync(chunk); + tickets.forEach(ticket => { + if (ticket.status === "error") { + if (ticket.details?.error === "DeviceNotRegistered") { + removeInvalidPushToken(ticket.to); + } + logger.error("Push notification error", ticket.message); + } + }); + } catch (error) { + logger.error("Error sending notifications", error); + } + } +} +async function storeNotification(notificationData) { + try { + await db.collection("Notifications").add(notificationData); + } catch (error) { + logger.error("Error storing notification", error); + } +} +/* ───────────────────────────────── + TOKEN REFRESH HELPERS +───────────────────────────────── */ async function refreshGoogleToken(refreshToken) { try { - console.log("Refreshing Google token..."); + logger.info("Refreshing Google token..."); const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", - headers: { - "Content-Type": "application/json", - }, + 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); + logger.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"); - - + logger.info("Google token refreshed successfully"); return { refreshedGoogleToken: data.access_token, refreshedRefreshToken: data.refresh_token || refreshToken, }; } catch (error) { - console.error("Error refreshing Google token:", error.message); + logger.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; +async function refreshMicrosoftToken(refreshToken) { + try { + logger.info("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(); + logger.error("Error refreshing Microsoft token", errorData); + throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`); + } + const data = await response.json(); + logger.info("Microsoft token refreshed successfully"); + return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken }; + } catch (error) { + logger.error("Error refreshing Microsoft token", error.message); + throw error; + } } -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 - }); +/* ───────────────────────────────── + GOOGLE EVENT SYNC / STORAGE HELPERS +───────────────────────────────── */ +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(); +} -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}) { +async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }) { const baseDate = new Date(); const oneYearAgo = new Date(baseDate); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const oneYearAhead = new Date(baseDate); oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1); - - let totalEvents = 0; - let pageToken = null; - const batchSize = 250; - + let totalEvents = 0, pageToken = null, batchSize = 250; try { - console.log(`[FETCH] Starting event fetch for user: ${email}`); - + logger.info(`[FETCH] Starting event fetch for user: ${email}`); do { - let events = []; - const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`); + const url = new URL("https://www.googleapis.com/calendar/v3/calendars/primary/events"); url.searchParams.set("singleEvents", "true"); url.searchParams.set("timeMin", oneYearAgo.toISOString()); url.searchParams.set("timeMax", oneYearAhead.toISOString()); 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}`, - }, + 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; - - await db.collection("Profiles").doc(creatorId).update({ - [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken - }); - - response = await fetch(url.toString(), { - headers: { - Authorization: `Bearer ${refreshedGoogleToken}`, - }, - }); - } + logger.info(`[TOKEN] Token expired during fetch, refreshing for ${email}`); + const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken); + token = refreshedGoogleToken; + await db.collection("Profiles").doc(creatorId).update({ + [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken, + }); + 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", - private: item.visibility === "private" || item.visibility === "confidential", - }; - events.push(googleEvent); - }); - + const events = (data.items || []).map(item => ({ + 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", + private: item.visibility === "private" || item.visibility === "confidential", + })); if (events.length > 0) { - console.log(`[FETCH] Saving batch of ${events.length} events`); + logger.info(`[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`); + logger.info(`[FETCH] Completed with ${totalEvents} total events`); return totalEvents; } catch (error) { - console.error(`[ERROR] Failed fetching events for ${email}:`, error); + logger.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}`); +async function calendarSync({ userId, email, token, refreshToken, familyId }) { + logger.info(`[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); + logger.info(`[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 + [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken, }); } } - - const eventCount = await fetchAndSaveGoogleEvents({ - token, - refreshToken, - email, - familyId, - creatorId: userId, - }); - - console.log(`[SYNC] Calendar sync completed. Processed ${eventCount} events`); + const eventCount = await fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId: userId }); + logger.info(`[SYNC] Calendar sync completed. Processed ${eventCount} events`); return eventCount; } catch (error) { - console.error(`[ERROR] Calendar sync failed for user ${userId}:`, error); + logger.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}) { +/* ───────────────────────────────── + MICROSOFT EVENT SYNC HELPERS +───────────────────────────────── */ +async function fetchAndSaveMicrosoftEvents({ token, refreshToken, email, familyId, creatorId }) { const baseDate = new Date(); + // For Microsoft, we fetch events from one month ago for two months ahead 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}`); - + logger.info(`Fetching Microsoft 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,sensitivity', - $filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'` + $select: "subject,start,end,id,isAllDay,sensitivity", + $filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'`, }); - - const response = await fetch(`${url}?${queryParams}`, { + let response = await fetch(`${url}?${queryParams}`, { headers: { Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - } + "Content-Type": "application/json", + }, }); - - const data = await response.json(); - + let 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 - }); - } + logger.info(`Token expired for Microsoft for user: ${email}, refreshing...`); + const { accessToken: newToken } = await refreshMicrosoftToken(refreshToken); + token = newToken; + return await fetchAndSaveMicrosoftEvents({ token, 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)); + 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'); + startDate = new Date(item.start.dateTime + "Z"); + endDate = new Date(item.end.dateTime + "Z"); } - return { id: item.id, title: item.subject || "", @@ -1456,325 +299,556 @@ async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId private: item.sensitivity === "private" || item.sensitivity === "confidential", }; }); - - console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`); + logger.info(`Saving ${events.length} Microsoft events for user: ${email}`); await saveEventsToFirestore(events); - return events.length; - } catch (error) { - console.error(`Error fetching Microsoft Calendar events for ${email}:`, error); + logger.error(`Error fetching Microsoft events for ${email}`, error); throw error; } } +// Check Upcoming Events every 5 minutes – send reminders +exports.checkUpcomingEvents = functions.pubsub + .schedule("every 5 minutes") + .onRun(async (context) => { + const now = Timestamp.now(); + const eventsSnapshot = await db.collection("Events") + .where("startDate", ">=", now) + .get(); + await Promise.all(eventsSnapshot.docs.map(async (doc) => { + const event = doc.data(); + if (!event?.startDate) return; + const { familyId, title, allDay } = event; + try { + const familyDoc = await db.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) { + logger.error(`Error processing reminder for event ${doc.id}`, error); + } + })); + }); + +/* ───────────────────────────────── + MICROSOFT SUBSCRIPTION & WEBHOOK FUNCTIONS +───────────────────────────────── */ +// Renew Microsoft Subscriptions every 24 hours exports.renewMicrosoftSubscriptions = functions.pubsub - .schedule('every 24 hours') + .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)) + 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)) + 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}; + 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 + clientState: userId, }; - - const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { - method: 'POST', + const response = await fetch("https://graph.microsoft.com/v1.0/subscriptions", { + method: "POST", headers: { - 'Authorization': `Bearer ${userTokens.token}`, - 'Content-Type': 'application/json' + Authorization: `Bearer ${userTokens.token}`, + "Content-Type": "application/json", }, - body: JSON.stringify(subscription) + 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), { + batch.set(db.collection("MicrosoftSubscriptions").doc(userId), { subscriptionId: subscriptionData.id, expirationDateTime: subscriptionData.expirationDateTime, - updatedAt: admin.firestore.FieldValue.serverTimestamp() - }, {merge: true}); - + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); } catch (error) { - console.error(`Failed to renew Microsoft subscription for ${userId}:`, error); - batch.set(db.collection('MicrosoftSubscriptionErrors').doc(), { + logger.error(`Failed to renew Microsoft subscription for ${userId}`, error); + batch.set(db.collection("MicrosoftSubscriptionErrors").doc(), { userId, error: error.message, - timestamp: admin.firestore.FieldValue.serverTimestamp() + timestamp: FieldValue.serverTimestamp(), }); } } - await batch.commit(); }); -exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => { +// Microsoft Calendar Webhook +exports.microsoftCalendarWebhook = 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(); - } - + 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(); + await fetchAndSaveMicrosoftEvents({ token, email, familyId: userData.familyId, creatorId: userId }); + return res.status(200).send(); } catch (error) { - console.error(`Error processing Microsoft webhook for ${userId}:`, error); - res.status(500).send(); + logger.error(`Error processing Microsoft webhook for ${userId}`, error); + return res.status(500).send(); } }); -exports.triggerGoogleSync = functions.https.onCall(async (data, context) => { - if (!context.auth) { - throw new functions.https.HttpsError( - 'unauthenticated', - 'Authentication required' - ); - } +/* ───────────────────────────────── + CLEANUP TOKEN REFRESH FLAGS +───────────────────────────────── */ +const tokenRefreshInProgress = new Map(); +exports.cleanupTokenRefreshFlags = functions.pubsub + .schedule("every 5 minutes") + .onRun(() => { + tokenRefreshInProgress.clear(); + logger.info("[CLEANUP] Cleared all token refresh flags"); + return null; + }); - 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; +/* ───────────────────────────────── + FIRESTORE EVENT SYNC FUNCTIONS (Google) +───────────────────────────────── */ +// Create new event sync (skip if externalOrigin is google) +exports.syncNewEventToGoogle = functions.firestore + .document("Events/{eventId}") + .onCreate(async (snapshot, context) => { + const newEvent = snapshot.data(); const eventId = context.params.eventId; - - console.log(`[HOUSEHOLD_UPDATE] Event updated - Processing timestamp updates`, { - eventId, - familyId, - eventTitle: eventData.title || 'Untitled' - }); - + if (newEvent.externalOrigin === "google") { + logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId); + return; + } 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 - }); - + const creatorDoc = await db.collection("Profiles").doc(newEvent.creatorId).get(); + const creatorData = creatorDoc.data(); + if (!creatorData?.googleAccounts) { + logger.info("[GOOGLE_SYNC] Creator has no Google accounts", newEvent.creatorId); + return; + } + const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; + if (!accountData?.accessToken) { + logger.info("[GOOGLE_SYNC] No access token found for creator", newEvent.creatorId); + return; + } + await syncEventToGoogle( + { + ...newEvent, + email, + startDate: new Date(newEvent.startDate.seconds * 1000), + endDate: new Date(newEvent.endDate.seconds * 1000), + }, + accountData.accessToken, + accountData.refreshToken, + newEvent.creatorId + ); + logger.info("[GOOGLE_SYNC] Successfully synced new event to Google", eventId); } catch (error) { - console.error(`[HOUSEHOLD_UPDATE] Error updating households for event update`, { - eventId, - familyId, - error: error.message, - stack: error.stack - }); - throw error; + logger.error("[GOOGLE_SYNC] Error syncing new event to Google", error); } }); +// Update event sync to Google +exports.syncEventToGoogleOnUpdate = functions.firestore + .document("Events/{eventId}") + .onUpdate(async (change, context) => { + const eventBefore = change.before.data(); + const eventAfter = change.after.data(); + const eventId = context.params.eventId; + if (eventAfter.externalOrigin === "google") { + logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId); + return; + } + if (JSON.stringify(eventBefore) === JSON.stringify(eventAfter)) { + logger.info("[GOOGLE_SYNC] No changes detected for event", eventId); + return; + } + try { + const creatorDoc = await db.collection("Profiles").doc(eventAfter.creatorId).get(); + const creatorData = creatorDoc.data(); + if (!creatorData?.googleAccounts) { + logger.info("[GOOGLE_SYNC] Creator has no Google accounts", eventAfter.creatorId); + return; + } + const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; + if (!accountData?.accessToken) { + logger.info("[GOOGLE_SYNC] No access token found for creator", eventAfter.creatorId); + return; + } + const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`; + const googleEvent = { + summary: eventAfter.title, + start: { + dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.startDate.seconds * 1000).toISOString(), + date: eventAfter.allDay ? new Date(eventAfter.startDate.seconds * 1000).toISOString().split("T")[0] : undefined, + timeZone: "UTC", + }, + end: { + dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.endDate.seconds * 1000).toISOString(), + date: eventAfter.allDay ? new Date(new Date(eventAfter.endDate.seconds * 1000).getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0] : undefined, + timeZone: "UTC", + }, + visibility: eventAfter.private ? "private" : "default", + status: "confirmed", + reminders: { useDefault: true }, + }; + let response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${accountData.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(googleEvent), + }); + if (response.status === 401 && accountData.refreshToken) { + logger.info("[GOOGLE_SYNC] Token expired, refreshing..."); + const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken); + await db.collection("Profiles").doc(eventAfter.creatorId).update({ + [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken, + }); + response = await fetch(url, { + method: "PATCH", + headers: { + Authorization: `Bearer ${refreshedGoogleToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(googleEvent), + }); + } + if (response.status === 404) { + logger.info("[GOOGLE_SYNC] Event not found in Google Calendar, creating new event"); + const insertUrl = "https://www.googleapis.com/calendar/v3/calendars/primary/events"; + response = await fetch(insertUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${accountData.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...googleEvent, id: eventId }), + }); + } + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || response.statusText); + } + logger.info("[GOOGLE_SYNC] Successfully synced event update to Google", eventId); + } catch (error) { + logger.error("[GOOGLE_SYNC] Error syncing event update to Google", error); + } + }); + +// Delete event sync from Google +exports.syncEventToGoogleOnDelete = functions.firestore + .document("Events/{eventId}") + .onDelete(async (snapshot, context) => { + const deletedEvent = snapshot.data(); + const eventId = context.params.eventId; + if (deletedEvent.externalOrigin === "google") { + logger.info("[GOOGLE_SYNC] Skipping delete sync for Google-originated event", eventId); + return; + } + try { + const creatorDoc = await db.collection("Profiles").doc(deletedEvent.creatorId).get(); + const creatorData = creatorDoc.data(); + if (!creatorData?.googleAccounts) { + logger.info("[GOOGLE_SYNC] Creator has no Google accounts", deletedEvent.creatorId); + return; + } + const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; + if (!accountData?.accessToken) { + logger.info("[GOOGLE_SYNC] No access token found for creator", deletedEvent.creatorId); + return; + } + const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`; + let response = await fetch(url, { + method: "DELETE", + headers: { + Authorization: `Bearer ${accountData.accessToken}`, + }, + }); + if (response.status === 401 && accountData.refreshToken) { + logger.info("[GOOGLE_SYNC] Token expired, refreshing..."); + const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken); + await db.collection("Profiles").doc(deletedEvent.creatorId).update({ + [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken, + }); + response = await fetch(url, { + method: "DELETE", + headers: { Authorization: `Bearer ${refreshedGoogleToken}` }, + }); + } + if (!response.ok && response.status !== 404) { + const errorData = await response.json(); + throw new Error(errorData.error?.message || response.statusText); + } + logger.info("[GOOGLE_SYNC] Successfully deleted event from Google", eventId); + } catch (error) { + logger.error("[GOOGLE_SYNC] Error deleting event from Google", error); + } + }); + +/* ───────────────────────────────── + UTILS FOR FETCH EVENTS (CALLABLE) +───────────────────────────────── */ +const createEventHash = (event) => { + const str = `${event.startDate?.seconds || ""}-${event.endDate?.seconds || ""}-${ + event.title || "" + }-${event.location || ""}-${event.allDay ? "true" : "false"}`; + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash = hash & hash; + } + return hash.toString(36); +}; + +async function fetchEventsFromFirestore(userId, profileData, isFamilyView) { + const eventsQuery = db.collection("Events"); + let constraints; + const familyId = profileData?.familyId; + if (profileData?.userType === "FAMILY_DEVICE") { + constraints = [ eventsQuery.where("familyId", "==", familyId) ]; + } else { + if (isFamilyView) { + constraints = [ + eventsQuery.where("familyId", "==", familyId), + eventsQuery.where("creatorId", "==", userId), + eventsQuery.where("attendees", "array-contains", userId), + ]; + } else { + constraints = [ + eventsQuery.where("creatorId", "==", userId), + eventsQuery.where("attendees", "array-contains", userId), + ]; + } + } + try { + const snapshots = await Promise.all(constraints.map(query => query.get())); + const uniqueEvents = new Map(); + const processedHashes = new Set(); + const creatorIds = new Set(); + snapshots.forEach(snapshot => { + snapshot.docs.forEach(doc => { + const event = doc.data(); + const hash = createEventHash(event); + if (!processedHashes.has(hash)) { + processedHashes.add(hash); + creatorIds.add(event.creatorId); + uniqueEvents.set(doc.id, event); + } + }); + }); + const creatorIdsArray = Array.from(creatorIds); + const creatorProfiles = new Map(); + const BATCH_SIZE = 10; + for (let i = 0; i < creatorIdsArray.length; i += BATCH_SIZE) { + const chunk = creatorIdsArray.slice(i, i + BATCH_SIZE); + const profilesSnapshot = await db.collection("Profiles") + .where(FieldPath.documentId(), "in", chunk) + .get(); + profilesSnapshot.docs.forEach(doc => { + creatorProfiles.set(doc.id, doc.data()?.eventColor || "#ff69b4"); + }); + } + return Array.from(uniqueEvents.entries()).map(([id, event]) => ({ + ...event, + id, + start: event.allDay ? new Date(new Date(event.startDate.seconds * 1000).setHours(0, 0, 0, 0)) + : new Date(event.startDate.seconds * 1000), + end: event.allDay ? new Date(new Date(event.endDate.seconds * 1000).setHours(0, 0, 0, 0)) + : new Date(event.endDate.seconds * 1000), + hideHours: event.allDay, + eventColor: creatorProfiles.get(event.creatorId) || "#ff69b4", + notes: event.notes, + })); + } catch (error) { + logger.error("Error fetching events", error); + throw new functions.https.HttpsError("internal", "Error fetching events"); + } +} + +exports.fetchEvents = functions.https.onCall(async (data, context) => { + if (!context.auth) { + throw new functions.https.HttpsError("unauthenticated", "User must be authenticated"); + } + try { + const { isFamilyView } = data; + const userId = context.auth.uid; + const profileDoc = await db.collection("Profiles").doc(userId).get(); + if (!profileDoc.exists) { + throw new functions.https.HttpsError("not-found", "User profile not found"); + } + const profileData = profileDoc.data(); + const events = await fetchEventsFromFirestore(userId, profileData, isFamilyView); + return { events }; + } catch (error) { + logger.error("Error in fetchEvents", error); + throw new functions.https.HttpsError("internal", error.message || "An unknown error occurred"); + } +}); + +/* ───────────────────────────────── + HTTP FUNCTIONS (e.g., createSubUser, removeSubUser, generateCustomToken) +───────────────────────────────── */ +// Create Sub User +exports.createSubUser = onRequest(async (req, res) => { + const authHeader = req.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + logger.warn("Missing or incorrect Authorization header", { authHeader }); + return res.status(401).json({ error: "Unauthorized" }); + } + 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 }); + return res.status(401).json({ error: "Unauthorized: Invalid token" }); + } + const { userType, firstName, lastName, email, password, familyId } = req.body.data; + if (!email || !password || !firstName || !userType || !familyId) { + logger.warn("Missing required fields in request body", { requestBody: req.body.data }); + return res.status(400).json({ error: "Missing required fields" }); + } + 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 }); + return res.status(500).json({ error: "Failed to create user" }); + } + 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 }); + return res.status(500).json({ error: "Failed to save user profile" }); + } + return res.status(200).json({ data: { message: "User created successfully", userId: userRecord.uid } }); + } catch (error) { + logger.error("Error in createSubUser function", { error: error.message }); + return res.status(500).json({ data: { error: error.message } }); + } +}); + +// Remove Sub User +exports.removeSubUser = onRequest(async (req, res) => { + const authHeader = req.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + logger.warn("Missing or incorrect Authorization header", { authHeader }); + return res.status(401).json({ error: "Unauthorized" }); + } + 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 }); + return res.status(401).json({ error: "Unauthorized: Invalid token" }); + } + const { userId, familyId } = req.body.data; + if (!userId || !familyId) { + logger.warn("Missing required fields in request body", { requestBody: req.body.data }); + return res.status(400).json({ error: "Missing required fields" }); + } + const userProfileDoc = await getFirestore().collection("Profiles").doc(userId).get(); + if (!userProfileDoc.exists) { + logger.error("User profile not found", { userId }); + return res.status(404).json({ error: "User not found" }); + } + if (userProfileDoc.data().familyId !== familyId) { + logger.error("User does not belong to the specified family", { + userId, + requestedFamilyId: familyId, + actualFamilyId: userProfileDoc.data().familyId, + }); + return res.status(403).json({ error: "User does not belong to the specified family" }); + } + 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 }); + return res.status(200).json({ data: { message: "User removed successfully", success: true } }); + } catch (error) { + logger.error("Error in removeSubUser function", { error: error.message }); + return res.status(500).json({ data: { error: error.message } }); + } +}); + +// Generate Custom Token +exports.generateCustomToken = onRequest(async (req, res) => { + try { + const { userId } = req.body.data; + logger.info("Generating custom token for userId", { userId }); + if (!userId) return res.status(400).json({ error: "Missing userId" }); + const customToken = await getAuth().createCustomToken(userId); + return res.status(200).json({ data: { token: customToken } }); + } catch (error) { + logger.error("Error generating custom token", { error: error.message }); + return res.status(500).json({ error: "Failed to generate custom token" }); + } +}); + async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) { try { - console.log('[GOOGLE_SYNC] Starting to sync event to Google Calendar', { + logger.info('[GOOGLE_SYNC] Starting to sync event to Google Calendar', { eventId: event.id, creatorId }); @@ -1818,7 +892,7 @@ async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) { // Handle token refresh if needed if (response.status === 401 && refreshToken) { - console.log('[GOOGLE_SYNC] Token expired, refreshing...'); + logger.info('[GOOGLE_SYNC] Token expired, refreshing...'); const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken); token = refreshedGoogleToken; @@ -1850,419 +924,365 @@ async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) { googleEventId: responseData.id }); - console.log('[GOOGLE_SYNC] Successfully created event in Google Calendar', { + logger.info('[GOOGLE_SYNC] Successfully synced event to Google Calendar', { firestoreId: event.id, googleEventId: responseData.id }); return true; } catch (error) { - console.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error); + logger.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error); throw error; } } -async function deleteEventFromGoogle(eventId, accessToken, refreshToken, creatorId, email) { - try { - console.log('[GOOGLE_DELETE] Starting to delete event from Google Calendar', { - eventId, - creatorId + + +/* ───────────────────────────────── + PUSH NOTIFICATION HELPERS +───────────────────────────────── */ +async function getPushTokensForFamily(familyId, excludeUserId = null) { + const snapshot = await db.collection("Profiles") + .where("familyId", "==", familyId) + .get(); + + let pushTokens = []; + logger.info("Getting push tokens", {familyId, excludeUserId}); + snapshot.forEach(doc => { + const data = doc.data(); + const userId = doc.id; + logger.debug("Processing user", { + docId: userId, + hasToken: !!data.pushToken, + excluded: userId === excludeUserId }); - - let token = accessToken; - const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`; - - let response = await fetch(url, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - // Handle token refresh if needed - if (response.status === 401 && refreshToken) { - console.log('[GOOGLE_DELETE] Token expired, refreshing...'); - const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken); - token = refreshedGoogleToken; - - // Update the token in Firestore - await db.collection("Profiles").doc(creatorId).update({ - [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken - }); - - // Retry with new token - response = await fetch(url, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${refreshedGoogleToken}` - } - }); - } - - if (!response.ok && response.status !== 404) { - const errorData = await response.json(); - throw new Error(`Failed to delete event: ${errorData.error?.message || response.statusText}`); - } - - console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', { - eventId, - creatorId - }); - - return true; - } catch (error) { - console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error); - throw error; - } -} - -const createEventHash = (event) => { - const str = `${event.startDate?.seconds || ''}-${event.endDate?.seconds || ''}-${ - event.title || '' - }-${event.location || ''}-${event.allDay ? 'true' : 'false'}`; - - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return hash.toString(36); -}; - -async function fetchEventsFromFirestore(userId, profileData, isFamilyView) { - const db = admin.firestore(); - const eventsQuery = db.collection("Events"); - let constraints; - const familyId = profileData?.familyId; - - if (profileData?.userType === "FAMILY_DEVICE") { - constraints = [ - eventsQuery.where("familyId", "==", familyId) - ]; - } else { - if (isFamilyView) { - constraints = [ - eventsQuery.where("familyId", "==", familyId), - eventsQuery.where("creatorId", "==", userId), - eventsQuery.where("attendees", "array-contains", userId) - ]; + if (userId !== excludeUserId && data.pushToken) { + logger.info("Including token for user", {userId, excludeUserId}); + pushTokens.push(data.pushToken); } else { - constraints = [ - eventsQuery.where("creatorId", "==", userId), - eventsQuery.where("attendees", "array-contains", userId) - ]; + logger.debug("Excluding token for user", {userId, excludeUserId}); } - } + }); + // Remove duplicates before sending + return [...new Set(pushTokens)]; +} +async function removeInvalidPushToken(pushToken) { try { - const snapshots = await Promise.all(constraints.map(query => query.get())); - - const uniqueEvents = new Map(); - const processedHashes = new Set(); - const creatorIds = new Set(); - - snapshots.forEach(snapshot => { - snapshot.docs.forEach(doc => { - const event = doc.data(); - const hash = createEventHash(event); - - if (!processedHashes.has(hash)) { - processedHashes.add(hash); - creatorIds.add(event.creatorId); - uniqueEvents.set(doc.id, event); - } - }); - }); - - const creatorIdsArray = Array.from(creatorIds); - const creatorProfiles = new Map(); - const BATCH_SIZE = 10; - - for (let i = 0; i < creatorIdsArray.length; i += BATCH_SIZE) { - const chunk = creatorIdsArray.slice(i, i + BATCH_SIZE); - const profilesSnapshot = await db - .collection("Profiles") - .where(admin.firestore.FieldPath.documentId(), "in", chunk) - .get(); - - profilesSnapshot.docs.forEach(doc => { - creatorProfiles.set(doc.id, doc.data()?.eventColor || '#ff69b4'); - }); - } - - return Array.from(uniqueEvents.entries()).map(([id, event]) => ({ - ...event, - id, - start: event.allDay - ? new Date(new Date(event.startDate.seconds * 1000).setHours(0, 0, 0, 0)) - : new Date(event.startDate.seconds * 1000), - end: event.allDay - ? new Date(new Date(event.endDate.seconds * 1000).setHours(0, 0, 0, 0)) - : new Date(event.endDate.seconds * 1000), - hideHours: event.allDay, - eventColor: creatorProfiles.get(event.creatorId) || '#ff69b4', - notes: event.notes - })); - + const snapshot = await db.collection("Profiles") + .where("pushToken", "==", pushToken) + .get(); + const batch = db.batch(); + snapshot.forEach(doc => batch.update(doc.ref, {pushToken: FieldValue.delete()})); + await batch.commit(); + logger.info(`Removed invalid push token: ${pushToken}`); } catch (error) { - console.error('Error fetching events:', error); - throw new functions.https.HttpsError('internal', 'Error fetching events'); + logger.error("Error removing invalid push token", error); } } -exports.fetchEvents = functions.https.onCall(async (data, context) => { - if (!context.auth) { - throw new functions.https.HttpsError( - 'unauthenticated', - 'User must be authenticated' - ); +async function sendNotifications(pushTokens, notification) { + if (!pushTokens.length) return; + const messages = pushTokens + .filter(token => Expo.isExpoPushToken(token)) + .map(token => ({ + to: token, + sound: "default", + priority: "high", + ...notification, + })); + const chunks = expo.chunkPushNotifications(messages); + for (let chunk of chunks) { + try { + const tickets = await expo.sendPushNotificationsAsync(chunk); + tickets.forEach(ticket => { + if (ticket.status === "error") { + if (ticket.details?.error === "DeviceNotRegistered") { + removeInvalidPushToken(ticket.to); + } + logger.error("Push notification error", ticket.message); + } + }); + } catch (error) { + logger.error("Error sending notifications", error); + } } +} +async function storeNotification(notificationData) { try { - const { isFamilyView } = data; - const userId = context.auth.uid; + await db.collection("Notifications").add(notificationData); + } catch (error) { + logger.error("Error storing notification", error); + } +} - const profileDoc = await admin.firestore() - .collection('Profiles') - .doc(userId) +/* ───────────────────────────────── + TOKEN REFRESH HELPERS (unchanged) +───────────────────────────────── */ +// [refreshGoogleToken and refreshMicrosoftToken remain unchanged as in your code] + +/* ───────────────────────────────── + NEW NOTIFICATION TRIGGER – No Batching +───────────────────────────────── */ + +/* +We now remove the “batch processing” functions +and instead trigger notifications directly when an event is created. +We also use a field named "notifiedAt" to ensure that repeated triggers (or re‑processing on update) don’t result in duplicate notifications. +*/ + +// Firestore trigger: send notification immediately when a new event is created (if not already notified). +exports.notifyOnEventCreation = functions.firestore + .document("Events/{eventId}") + .onCreate(async (snapshot, context) => { + const event = snapshot.data(); + const eventId = context.params.eventId; + + // If the event was created by an automated sync (for example, externalOrigin === "google"), skip notification. + if (event.externalOrigin === "google") { + logger.info("[NOTIFY] Skipping notification for Google-originated event", eventId); + return; + } + + // Check if already notified (could be set by an update trigger) + if (event.notifiedAt) { + logger.info("[NOTIFY] Event already notified", eventId); + return; + } + + // Construct a notification message (customize as needed) + const notificationMessage = `New event "${event.title}" added to the calendar.`; + + try { + // Get push tokens excluding the creator + const pushTokens = await getPushTokensForFamily(event.familyId, event.creatorId); + + if (pushTokens.length) { + await sendNotifications(pushTokens, { + title: "New Family Calendar Event", + body: notificationMessage, + data: {type: "event_created", eventId}, + }); + await storeNotification({ + type: "event_created", + familyId: event.familyId, + content: notificationMessage, + timestamp: FieldValue.serverTimestamp(), + eventId, + }); + } + // Mark event as notified (to prevent duplicate notifications on re-trigger) + await snapshot.ref.update({notifiedAt: FieldValue.serverTimestamp()}); + logger.info("[NOTIFY] Notification sent for event", eventId); + } catch (error) { + logger.error("[NOTIFY] Error sending notification for event", eventId, error); + } + }); + +/* +You can create similar triggers for event updates if needed. +For example, if the event update includes changes that warrant a new notification (and if you clear or update the notifiedAt field accordingly). +This simplified approach reduces extra writes because you update the event document only once to mark it as notified. +*/ + +// A sample Firestore trigger for critical updates (if necessary): +exports.notifyOnEventUpdate = functions.firestore + .document("Events/{eventId}") + .onUpdate(async (change, context) => { + const before = change.before.data(); + const after = change.after.data(); + const eventId = context.params.eventId; + + // Only trigger if certain fields have changed (e.g., title change or a significant update) + if (before.title === after.title) { + logger.info("[NOTIFY] No relevant change detected; skipping update notification", eventId); + return; + } + + // Use a different notification type for updates + const notificationMessage = `Event "${after.title}" has been updated.`; + + try { + const pushTokens = await getPushTokensForFamily(after.familyId, after.creatorId); + if (pushTokens.length) { + await sendNotifications(pushTokens, { + title: "Family Calendar Event Updated", + body: notificationMessage, + data: {type: "event_updated", eventId}, + }); + await storeNotification({ + type: "event_updated", + familyId: after.familyId, + content: notificationMessage, + timestamp: FieldValue.serverTimestamp(), + eventId, + }); + } + // Optionally update a notification timestamp on the event document. + await change.after.ref.update({notifiedAt: FieldValue.serverTimestamp()}); + logger.info("[NOTIFY] Update notification sent for event", eventId); + } catch (error) { + logger.error("[NOTIFY] Error sending update notification for event", eventId, error); + } + }); + +/* ───────────────────────────────── + REMINDER FUNCTION (for upcoming events) +───────────────────────────────── */ +// We keep the reminder scheduler mostly as-is but ensure that once a notification is sent, the event is updated +exports.checkUpcomingEvents = functions.pubsub + .schedule("every 5 minutes") // Run more frequently to catch reminders + .onRun(async (context) => { + const now = Timestamp.now(); + const thirtyMinutesFromNow = new Date(now.toDate().getTime() + 30 * 60 * 1000); + + // Query only events starting in the next 30 minutes that haven't been reminded + const eventsSnapshot = await db.collection("Events") + .where("startDate", ">=", now) + .where("startDate", "<=", thirtyMinutesFromNow) + .where("reminderSent", "==", false) // Only get events that haven't been reminded .get(); - if (!profileDoc.exists) { - throw new functions.https.HttpsError( - 'not-found', - 'User profile not found' - ); + const batch = db.batch(); // Batch our updates + const notificationPromises = []; + + for (const doc of eventsSnapshot.docs) { + const event = doc.data(); + if (!event?.startDate) continue; + + const { familyId, title, allDay } = event; + try { + const familyDoc = await db.collection("Families").doc(familyId).get(); + if (!familyDoc.exists) continue; + + const familySettings = familyDoc.data()?.settings || {}; + const reminderTime = familySettings.defaultReminderTime || 10; // Default to 10 minutes + const eventTime = event.startDate.toDate(); + const reminderThreshold = new Date(now.toDate().getTime() + reminderTime * 60 * 1000); + + // Check if we're within the reminder window + const timeUntilEvent = eventTime.getTime() - now.toDate().getTime(); + const minutesUntilEvent = Math.floor(timeUntilEvent / (60 * 1000)); + + if (minutesUntilEvent <= reminderTime && minutesUntilEvent > 0) { + notificationPromises.push(async () => { + const pushTokens = await getPushTokensForFamily(familyId); + if (pushTokens.length) { + await sendNotifications(pushTokens, { + title: "Upcoming Event", + body: `In ${minutesUntilEvent} minutes: ${title}`, + data: { type: 'event_reminder', eventId: doc.id } + }); + batch.update(doc.ref, { reminderSent: true }); + } + }); + } + + // Handle all-day events separately + if (allDay && !event.eveningReminderSent) { + const eveningBefore = new Date(eventTime); + eveningBefore.setDate(eveningBefore.getDate() - 1); + eveningBefore.setHours(20, 0, 0, 0); + + if (now.toDate() >= eveningBefore) { + notificationPromises.push(async () => { + 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 } + }); + batch.update(doc.ref, { eveningReminderSent: true }); + } + }); + } + } + } catch (error) { + logger.error(`Error processing reminder for event ${doc.id}:`, error); + } } - const profileData = profileDoc.data(); - const events = await fetchEventsFromFirestore(userId, profileData, isFamilyView); + // Execute all notifications + await Promise.all(notificationPromises.map(fn => fn())); - return { events }; + // Commit all updates in one batch + if (batch._ops.length > 0) { + await batch.commit(); + } + }); + +/* ───────────────────────────────── + MIGRATION UTILITY +───────────────────────────────── */ +exports.migrateEventNotifications = functions.https.onRequest(async (req, res) => { + try { + const batch = db.batch(); + const existingEvents = await db.collection('Events') + .where('notifiedAt', '==', null) + .get(); + + logger.info("[MIGRATE] Starting event migration", {count: existingEvents.size}); + + if (existingEvents.empty) { + logger.info("[MIGRATE] No events require migration"); + res.status(200).send("No events needed migration"); + return; + } + + existingEvents.forEach(doc => { + batch.update(doc.ref, { + notifiedAt: FieldValue.serverTimestamp(), + eveningReminderSent: false, + reminderSent: false, + lastNotifiedAt: null + }); + }); + + await batch.commit(); + + // Store migration record + await db.collection('SystemLogs').add({ + type: 'migration', + operation: 'event_notifications', + count: existingEvents.size, + timestamp: FieldValue.serverTimestamp(), + status: 'completed' + }); + + logger.info("[MIGRATE] Completed event migration", { + count: existingEvents.size, + status: 'success' + }); + + res.status(200).send({ + status: 'success', + message: `Successfully migrated ${existingEvents.size} events`, + timestamp: new Date().toISOString() + }); } catch (error) { - console.error('Error in fetchEvents:', error); - throw new functions.https.HttpsError( - 'internal', - error.message || 'An unknown error occurred' - ); + logger.error("[MIGRATE] Error during migration", { + error: error.message, + stack: error.stack + }); + + // Store error record + await db.collection('SystemLogs').add({ + type: 'migration', + operation: 'event_notifications', + error: error.message, + errorStack: error.stack, + timestamp: FieldValue.serverTimestamp(), + status: 'failed' + }); + + res.status(500).send({ + status: 'error', + message: 'Migration failed', + error: error.message, + timestamp: new Date().toISOString() + }); } -}); - -exports.syncNewEventToGoogle = functions.firestore - .document('Events/{eventId}') - .onCreate(async (snapshot, context) => { - const newEvent = snapshot.data(); - const eventId = context.params.eventId; - - // Don't sync if this event came from Google - if (newEvent.externalOrigin === 'google') { - console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event:', eventId); - return; - } - - try { - // Get the creator's Google account credentials - const creatorDoc = await db.collection('Profiles').doc(newEvent.creatorId).get(); - const creatorData = creatorDoc.data(); - - if (!creatorData?.googleAccounts) { - console.log('[GOOGLE_SYNC] Creator has no Google accounts:', newEvent.creatorId); - return; - } - - // Get the first Google account (assuming one account per user) - const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; - - if (!accountData?.accessToken) { - console.log('[GOOGLE_SYNC] No access token found for creator:', newEvent.creatorId); - return; - } - - await syncEventToGoogle( - { - ...newEvent, - email, - startDate: new Date(newEvent.startDate.seconds * 1000), - endDate: new Date(newEvent.endDate.seconds * 1000) - }, - accountData.accessToken, - accountData.refreshToken, - newEvent.creatorId - ); - - console.log('[GOOGLE_SYNC] Successfully synced new event to Google:', eventId); - } catch (error) { - console.error('[GOOGLE_SYNC] Error syncing new event to Google:', error); - } - }); - -exports.syncEventToGoogleOnUpdate = functions.firestore - .document('Events/{eventId}') - .onUpdate(async (change, context) => { - const eventBefore = change.before.data(); - const eventAfter = change.after.data(); - const eventId = context.params.eventId; - - if (eventAfter.externalOrigin === 'google') { - console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event:', eventId); - return; - } - - if (JSON.stringify(eventBefore) === JSON.stringify(eventAfter)) { - console.log('[GOOGLE_SYNC] No changes detected for event:', eventId); - return; - } - - try { - const creatorDoc = await db.collection('Profiles').doc(eventAfter.creatorId).get(); - const creatorData = creatorDoc.data(); - - if (!creatorData?.googleAccounts) { - console.log('[GOOGLE_SYNC] Creator has no Google accounts:', eventAfter.creatorId); - return; - } - - const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; - - if (!accountData?.accessToken) { - console.log('[GOOGLE_SYNC] No access token found for creator:', eventAfter.creatorId); - return; - } - - const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`; - const googleEvent = { - summary: eventAfter.title, - start: { - dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.startDate.seconds * 1000).toISOString(), - date: eventAfter.allDay ? new Date(eventAfter.startDate.seconds * 1000).toISOString().split('T')[0] : undefined, - timeZone: 'UTC' - }, - end: { - dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.endDate.seconds * 1000).toISOString(), - date: eventAfter.allDay ? new Date(new Date(eventAfter.endDate.seconds * 1000).getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined, - timeZone: 'UTC' - }, - visibility: eventAfter.private ? 'private' : 'default', - status: 'confirmed', - reminders: { - useDefault: true - } - }; - - let response = await fetch(url, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${accountData.accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(googleEvent) - }); - - if (response.status === 401 && accountData.refreshToken) { - console.log('[GOOGLE_SYNC] Token expired, refreshing...'); - const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken); - await db.collection("Profiles").doc(eventAfter.creatorId).update({ - [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken - }); - - response = await fetch(url, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${refreshedGoogleToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(googleEvent) - }); - } - - // If event doesn't exist in Google Calendar, create it using insert - if (response.status === 404) { - console.log('[GOOGLE_SYNC] Event not found in Google Calendar, creating new event'); - const insertUrl = 'https://www.googleapis.com/calendar/v3/calendars/primary/events'; - response = await fetch(insertUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accountData.accessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - ...googleEvent, - id: eventId - }) - }); - } - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.message || response.statusText); - } - - console.log('[GOOGLE_SYNC] Successfully synced event update to Google:', eventId); - } catch (error) { - console.error('[GOOGLE_SYNC] Error syncing event update to Google:', error); - } - }); - -exports.syncEventToGoogleOnDelete = functions.firestore - .document('Events/{eventId}') - .onDelete(async (snapshot, context) => { - const deletedEvent = snapshot.data(); - const eventId = context.params.eventId; - - if (deletedEvent.externalOrigin === 'google') { - console.log('[GOOGLE_SYNC] Skipping delete sync for Google-originated event:', eventId); - return; - } - - try { - const creatorDoc = await db.collection('Profiles').doc(deletedEvent.creatorId).get(); - const creatorData = creatorDoc.data(); - - if (!creatorData?.googleAccounts) { - console.log('[GOOGLE_SYNC] Creator has no Google accounts:', deletedEvent.creatorId); - return; - } - - const [email, accountData] = Object.entries(creatorData.googleAccounts)[0]; - - if (!accountData?.accessToken) { - console.log('[GOOGLE_SYNC] No access token found for creator:', deletedEvent.creatorId); - return; - } - - const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`; - let response = await fetch(url, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${accountData.accessToken}` - } - }); - - if (response.status === 401 && accountData.refreshToken) { - console.log('[GOOGLE_SYNC] Token expired, refreshing...'); - const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken); - await db.collection("Profiles").doc(deletedEvent.creatorId).update({ - [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken - }); - - response = await fetch(url, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${refreshedGoogleToken}` - } - }); - } - - if (!response.ok && response.status !== 404) { - const errorData = await response.json(); - throw new Error(errorData.error?.message || response.statusText); - } - - console.log('[GOOGLE_SYNC] Successfully deleted event from Google:', eventId); - } catch (error) { - console.error('[GOOGLE_SYNC] Error deleting event from Google:', error); - } - }); - -exports.sendOverviews = functions.pubsub \ No newline at end of file +}); \ No newline at end of file