const { onRequest, onCall, HttpsError } = 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 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 }); const GOOGLE_CALENDAR_ID = "primary"; const CHANNEL_ID = "cally-family-calendar"; const WEBHOOK_URL = "https://sendsyncnotification-ne4kpcdqwa-uc.a.run.app"; /* ───────────────────────────────── 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 }); if (userId !== excludeUserId && data.pushToken) { logger.info("Including token for user", { userId, excludeUserId }); pushTokens.push(data.pushToken); } else { logger.debug("Excluding token for user", { userId, excludeUserId }); } }); return pushTokens; } async function removeInvalidPushToken(pushToken) { try { 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) { logger.error("Error removing invalid push token", error); } } 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 { logger.info("Refreshing Google token..."); const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com", }), }); if (!response.ok) { const errorData = await response.json(); logger.error("Error refreshing Google token", errorData); throw new Error(`Failed to refresh Google token: ${errorData.error || response.statusText}`); } const data = await response.json(); logger.info("Google token refreshed successfully"); return { refreshedGoogleToken: data.access_token, refreshedRefreshToken: data.refresh_token || refreshToken, }; } catch (error) { logger.error("Error refreshing Google token", error.message); throw error; } } 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; } } /* ───────────────────────────────── 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(); } 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, pageToken = null, batchSize = 250; try { logger.info(`[FETCH] Starting event fetch for user: ${email}`); do { 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); let response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}` }, }); if (response.status === 401 && refreshToken) { 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}`); } 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) { logger.info(`[FETCH] Saving batch of ${events.length} events`); await saveEventsToFirestore(events); totalEvents += events.length; } pageToken = data.nextPageToken; } while (pageToken); logger.info(`[FETCH] Completed with ${totalEvents} total events`); return totalEvents; } catch (error) { logger.error(`[ERROR] Failed fetching events for ${email}`, error); throw error; } } async function calendarSync({ userId, email, token, refreshToken, familyId }) { logger.info(`[SYNC] Starting calendar sync for user ${userId} with email ${email}`); try { if (refreshToken) { logger.info(`[TOKEN] Initial token refresh for ${email}`); const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken); if (refreshedGoogleToken) { token = refreshedGoogleToken; await db.collection("Profiles").doc(userId).update({ [`googleAccounts.${email}.accessToken`]: refreshedGoogleToken, }); } } const eventCount = await fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId: userId }); logger.info(`[SYNC] Calendar sync completed. Processed ${eventCount} events`); return eventCount; } catch (error) { logger.error(`[ERROR] Calendar sync failed for user ${userId}`, error); throw error; } } /* ───────────────────────────────── 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 { 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}'`, }); let response = await fetch(`${url}?${queryParams}`, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, }); let data = await response.json(); if (response.status === 401 && refreshToken) { 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)); } else { startDate = new Date(item.start.dateTime + "Z"); endDate = new Date(item.end.dateTime + "Z"); } return { id: item.id, title: item.subject || "", startDate, endDate, allDay: item.isAllDay, familyId, email, creatorId, externalOrigin: "microsoft", private: item.sensitivity === "private" || item.sensitivity === "confidential", }; }); logger.info(`Saving ${events.length} Microsoft events for user: ${email}`); await saveEventsToFirestore(events); return events.length; } catch (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 tenMinutesFromNow = new Date(now.toDate().getTime() + 10 * 60 * 1000); const eventsSnapshot = await db.collection("Events") .where("startDate", ">=", now) .where("startDate", "<=", Timestamp.fromDate(tenMinutesFromNow)) .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("Households").doc(familyId).get(); if (!familyDoc.exists) return; const familySettings = familyDoc.data()?.settings || {}; const reminderTime = familySettings.defaultReminderTime || 5; 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") .onRun(async (context) => { const now = Date.now(); const subs = await db.collection("MicrosoftSubscriptions") .where("expirationDateTime", "<=", new Date(now + 24 * 60 * 60 * 1000)) .get(); if (subs.empty) return null; const profilesSnapshot = await db.collection("Profiles") .where("uid", "in", subs.docs.map(doc => doc.id)) .get(); const batch = db.batch(); const userTokenMap = {}; profilesSnapshot.forEach(doc => { const data = doc.data(); if (data.microsoftAccounts) { const [email, token] = Object.entries(data.microsoftAccounts)[0] || []; if (token) userTokenMap[doc.id] = { token, email }; } }); for (const subDoc of subs.docs) { const userId = subDoc.id; const userTokens = userTokenMap[userId]; if (!userTokens) continue; try { const subscription = { changeType: "created,updated,deleted", notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`, resource: "/me/calendar/events", expirationDateTime: new Date(now + 3 * 24 * 60 * 60 * 1000).toISOString(), clientState: userId, }; const response = await fetch("https://graph.microsoft.com/v1.0/subscriptions", { method: "POST", headers: { Authorization: `Bearer ${userTokens.token}`, "Content-Type": "application/json", }, body: JSON.stringify(subscription), }); if (!response.ok) throw new Error(await response.text()); const subscriptionData = await response.json(); batch.set(db.collection("MicrosoftSubscriptions").doc(userId), { subscriptionId: subscriptionData.id, expirationDateTime: subscriptionData.expirationDateTime, updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); } catch (error) { logger.error(`Failed to renew Microsoft subscription for ${userId}`, error); batch.set(db.collection("MicrosoftSubscriptionErrors").doc(), { userId, error: error.message, timestamp: FieldValue.serverTimestamp(), }); } } await batch.commit(); }); // 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(); const [email, token] = Object.entries(userData.microsoftAccounts)[0] || []; if (!token) return res.status(200).send(); await fetchAndSaveMicrosoftEvents({ token, email, familyId: userData.familyId, creatorId: userId }); return res.status(200).send(); } catch (error) { logger.error(`Error processing Microsoft webhook for ${userId}`, error); return res.status(500).send(); } }); /* ───────────────────────────────── 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; }); /* ───────────────────────────────── 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; await snapshot.ref.update({ reminderSent: false, eveningReminderSent: false, notifiedAt: null }); if (newEvent.externalOrigin === "google") { logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId); return; } try { 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) { 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 { logger.info('[GOOGLE_SYNC] Starting to sync event to Google Calendar', { eventId: event.id, creatorId }); let token = accessToken; const googleEvent = { summary: event.title, start: { dateTime: event.allDay ? undefined : event.startDate.toISOString(), date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined, timeZone: 'UTC' }, end: { dateTime: event.allDay ? undefined : event.endDate.toISOString(), date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined, timeZone: 'UTC' }, visibility: event.private ? 'private' : 'default', status: 'confirmed', reminders: { useDefault: true }, // Add extendedProperties to store our Firestore ID extendedProperties: { private: { firestoreId: event.id } } }; // For new events, use POST to create const url = 'https://www.googleapis.com/calendar/v3/calendars/primary/events'; let response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(googleEvent) }); // Handle token refresh if needed if (response.status === 401 && refreshToken) { logger.info('[GOOGLE_SYNC] Token expired, refreshing...'); const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken); token = refreshedGoogleToken; // Update the token in Firestore await db.collection("Profiles").doc(creatorId).update({ [`googleAccounts.${event.email}.accessToken`]: refreshedGoogleToken }); // Retry with new token response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Bearer ${refreshedGoogleToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(googleEvent) }); } if (!response.ok) { const errorData = await response.json(); throw new Error(`Failed to sync event: ${errorData.error?.message || response.statusText}`); } const responseData = await response.json(); // Store the Google Calendar event ID in Firestore await db.collection('Events').doc(event.id).update({ googleEventId: responseData.id }); logger.info('[GOOGLE_SYNC] Successfully synced event to Google Calendar', { firestoreId: event.id, googleEventId: responseData.id }); return true; } catch (error) { logger.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error); throw error; } } /* ───────────────────────────────── 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 }); if (userId !== excludeUserId && data.pushToken) { logger.info("Including token for user", {userId, excludeUserId}); pushTokens.push(data.pushToken); } else { logger.debug("Excluding token for user", {userId, excludeUserId}); } }); // Remove duplicates before sending return [...new Set(pushTokens)]; } async function removeInvalidPushToken(pushToken) { try { 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) { logger.error("Error removing invalid push token", error); } } 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 (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") .onRun(async (context) => { const now = Timestamp.now(); const thirtyMinutesFromNow = new Date(now.toDate().getTime() + 30 * 60 * 1000); logger.info(`Running check at ${now.toDate().toISOString()}`); const eventsSnapshot = await db.collection("Events") .where("startDate", ">=", now) .where("startDate", "<=", Timestamp.fromDate(thirtyMinutesFromNow)) .get(); const batch = db.batch(); const notificationPromises = []; for (const doc of eventsSnapshot.docs) { const event = doc.data(); if (!event?.startDate) continue; const { familyId, title, allDay } = event; // Skip if reminder already sent if (event.reminderSent) { logger.info(`Reminder already sent for event: ${title}`); continue; } try { const familyDoc = await db.collection("Households").doc(familyId).get(); if (!familyDoc.exists) continue; const familySettings = familyDoc.data()?.settings || {}; const reminderTime = familySettings.defaultReminderTime || 10; const eventTime = event.startDate.toDate(); const timeUntilEvent = eventTime.getTime() - now.toDate().getTime(); const minutesUntilEvent = Math.floor(timeUntilEvent / (60 * 1000)); logger.info(`Checking event: "${title}"`, { minutesUntilEvent, reminderTime, eventTime: eventTime.toISOString() }); // Modified timing logic: Send reminder when we're close to the reminder time // This ensures we don't miss the window between function executions if (minutesUntilEvent <= reminderTime && minutesUntilEvent > 0) { logger.info(`Preparing to send reminder for: ${title}`); 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, lastReminderSent: Timestamp.now() }); logger.info(`Reminder sent for: ${title}`); } } else { logger.info(`Not yet time for reminder: ${title}`, { minutesUntilEvent, reminderTime }); } } catch (error) { logger.error(`Error processing reminder for event ${doc.id}:`, error); } } // Commit batch if there are any operations if (batch._ops.length > 0) { await batch.commit(); logger.info(`Committed ${batch._ops.length} updates`); } }); /* ───────────────────────────────── 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) { 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.sendSyncNotification = onRequest(async (req, res) => { const userId = req.query.userId; if (!userId) { logger.error('[SYNC] Missing userId in request'); return res.status(400).send('Missing userId'); } try { const userDoc = await db.collection("Profiles").doc(userId).get(); if (!userDoc.exists) { logger.error(`[SYNC] No profile found for user ${userId}`); return res.status(404).send("User profile not found"); } const userData = userDoc.data(); const familyId = userData.familyId; const googleAccounts = userData.googleAccounts || {}; // Get first Google account const [email] = Object.keys(googleAccounts); if (!email) { logger.error(`[SYNC] No Google account found for user ${userId}`); return res.status(400).send("No Google account found"); } const accountData = googleAccounts[email]; const { accessToken, refreshToken } = accountData; if (!accessToken) { logger.error(`[SYNC] No access token for user ${userId}`); return res.status(400).send("No access token found"); } if (!familyId) { logger.error(`[SYNC] No family ID for user ${userId}`); return res.status(400).send("No family ID found"); } // Check if household exists and create if it doesn't const householdsSnapshot = await db.collection('Households') .where("familyId", "==", familyId) .get(); if (householdsSnapshot.empty) { logger.info(`[SYNC] No household found for family ${familyId}, creating one`); // Create a new household document await db.collection('Households').add({ familyId, createdAt: admin.firestore.FieldValue.serverTimestamp(), createdBy: userId, lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp(), settings: { defaultReminderTime: 15, // Default 15 minutes reminder } }); logger.info(`[SYNC] Created new household for family ${familyId}`); } logger.info(`[SYNC] Starting calendar sync for user ${userId}`); const syncStartTime = Date.now(); // Trigger immediate sync and get event count const totalEvents = await fetchAndSaveGoogleEvents({ token: accessToken, refreshToken, email, familyId, creatorId: userId }); // Update household timestamps (will now include newly created household if any) const updatedHouseholdsSnapshot = await db.collection('Households') .where("familyId", "==", familyId) .get(); const batch = db.batch(); updatedHouseholdsSnapshot.docs.forEach((doc) => { batch.update(doc.ref, { lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp() }); }); await batch.commit(); logger.info(`[SYNC] Completed sync for user ${userId} in ${Date.now() - syncStartTime}ms`); res.status(200).send("Sync completed successfully"); } catch (error) { logger.error(`[SYNC] Error in sync notification:`, error); res.status(500).send("Sync failed"); } }); exports.renewGoogleCalendarWatch = functions.pubsub .schedule("every 10 minutes") .onRun(async (context) => { logger.info("[WATCH] Starting calendar watch renewal check"); try { const profilesWithGoogle = await db.collection('Profiles') .where('googleAccounts', '!=', null) .get(); logger.info(`[WATCH] Found ${profilesWithGoogle.size} profiles with Google accounts`); const existingWatches = await db.collection('CalendarWatches').get(); logger.info(`[WATCH] Found ${existingWatches.size} existing watches`); const now = Date.now(); const watchMap = new Map(); existingWatches.forEach(doc => { watchMap.set(doc.id, doc.data()); logger.debug(`[WATCH] Existing watch for ${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); logger.info(`[WATCH] Processing user ${userId}`, { hasGoogleAccounts: !!userData.googleAccounts, accountCount: Object.keys(userData.googleAccounts || {}).length, hasExistingWatch: !!existingWatch, watchExpiration: existingWatch?.expiration }); if (!userData.googleAccounts || Object.keys(userData.googleAccounts).length === 0) { logger.info(`[WATCH] Skipping user ${userId} - no Google accounts`); continue; } const firstAccount = Object.values(userData.googleAccounts)[0]; const token = firstAccount?.accessToken; if (!token) { logger.info(`[WATCH] No token found for user ${userId}`); continue; } const needsRenewal = !existingWatch || !existingWatch.expiration || existingWatch.expiration < (now + 30 * 60 * 1000); if (!needsRenewal) { logger.info(`[WATCH] Watch still valid for user ${userId}, skipping renewal`); continue; } try { logger.info(`[WATCH] ${existingWatch ? 'Renewing' : 'Creating new'} watch for user ${userId}`); const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`; const watchBody = { id: `${CHANNEL_ID}-${userId}`, type: "web_hook", address: `${WEBHOOK_URL}?userId=${userId}`, params: {ttl: "604800"} }; logger.info(`[WATCH] Making request for user ${userId}`, { url, watchBody, tokenPrefix: token.substring(0, 10) }); const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify(watchBody), }); const responseText = await response.text(); logger.info(`[WATCH] Received response for ${userId}`, { status: response.status, response: responseText }); if (!response.ok) { throw new Error(`Failed to set up watch: ${responseText}`); } const result = JSON.parse(responseText); 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++; } logger.info(`[WATCH] Successfully ${existingWatch ? 'renewed' : 'created'} watch for user ${userId}`, { watchId: result.id, expiration: result.expiration }); } catch (error) { logger.error(`[WATCH] Failed to ${existingWatch ? 'renew' : 'create'} watch for user ${userId}:`, { error: error.message, stack: error.stack }); batch.set(db.collection('CalendarWatchErrors').doc(), { userId, error: error.message, timestamp: admin.firestore.FieldValue.serverTimestamp(), }); } } if (processedCount > 0) { logger.info(`[WATCH] Committing batch with ${processedCount} operations`); await batch.commit(); } logger.info(`[WATCH] Completed calendar watch processing`, { totalProcessed: processedCount, newWatches: newWatchCount, renewals: renewalCount }); return null; } catch (error) { logger.error('[WATCH] Critical error in watch renewal:', { error: error.message, stack: error.stack }); throw error; } }); exports.triggerGoogleSync = onCall({ memory: '256MiB', enforceAppCheck: false, maxInstances: 10, region: 'us-central1', invoker: 'public', }, async (request) => { if (!request.auth) { throw new HttpsError('unauthenticated', 'Authentication required'); } try { const userId = request.auth.uid; const {email} = request.data; logger.info("Starting manual Google sync", {userId, email}); const userDoc = await db.collection("Profiles").doc(userId).get(); const userData = userDoc.data(); if (!userData?.googleAccounts?.[email]) { logger.error("No valid Google account found", {userId, email}); throw new 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 }); logger.info("Manual sync completed successfully", {userId, eventCount}); return { success: true, eventCount, message: "Google calendar sync completed successfully" }; } catch (error) { logger.error("Manual sync failed", error); throw new HttpsError('internal', error.message); } }); exports.triggerMicrosoftSync = onCall({ memory: '256MiB', enforceAppCheck: false, maxInstances: 10, region: 'us-central1', invoker: 'public', }, async (request) => { if (!request.auth) { throw new HttpsError('unauthenticated', 'Authentication required'); } try { const userId = request.auth.uid; const {email} = request.data; if (!email) { throw new HttpsError('invalid-argument', 'Email is required'); } logger.info('Starting Microsoft sync', {userId, email}); const userDoc = await db.collection("Profiles").doc(userId).get(); if (!userDoc.exists) { throw new HttpsError('not-found', 'User profile not found'); } const userData = userDoc.data(); const accountData = userData.microsoftAccounts?.[email]; if (!accountData) { throw new 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(userId).update({ [`microsoftAccounts.${email}`]: { ...accountData, accessToken, refreshToken, lastRefresh: FieldValue.serverTimestamp() } }); } catch (refreshError) { logger.error('Token refresh failed:', refreshError); throw new HttpsError( 'failed-precondition', 'Failed to refresh Microsoft token. Please reconnect your account.', {requiresReauth: true} ); } } try { const eventCount = await fetchAndSaveMicrosoftEvents({ token: accessToken, refreshToken, email, familyId: userData.familyId, creatorId: userId }); logger.info('Microsoft sync completed successfully', {eventCount}); return { success: true, eventCount, message: "Microsoft calendar sync completed successfully" }; } catch (syncError) { if (syncError.message?.includes('401') || syncError.message?.includes('unauthorized') || syncError.message?.includes('invalid_grant')) { throw new HttpsError( 'unauthenticated', 'Microsoft authentication expired. Please reconnect your account.', {requiresReauth: true} ); } throw new HttpsError('internal', syncError.message); } } catch (error) { logger.error('Microsoft sync function error:', error); if (error instanceof HttpsError) { throw error; } throw new HttpsError('internal', error.message || 'Unknown error occurred'); } }); exports.forceWatchRenewal = onRequest(async (req, res) => { const batch = db.batch(); const watches = await db.collection('CalendarWatches').get(); watches.forEach(doc => { batch.update(doc.ref, { expiration: 0 }); }); await batch.commit(); res.status(200).send('Forced renewal of all watches'); });