diff --git a/components/pages/calendar/ManuallyAddEventModal.tsx b/components/pages/calendar/ManuallyAddEventModal.tsx index 03bf12e..cb04d95 100644 --- a/components/pages/calendar/ManuallyAddEventModal.tsx +++ b/components/pages/calendar/ManuallyAddEventModal.tsx @@ -148,7 +148,7 @@ export const ManuallyAddEventModal = () => { setIsPrivate(editEvent?.private || false); setStartTime(() => { - const date = initialDate ?? new Date(); + const date = initialDate ? new Date(initialDate) : new Date(); date.setSeconds(0, 0); return date; }); diff --git a/contexts/PersistQueryClientProvider.tsx b/contexts/PersistQueryClientProvider.tsx index 9672a3d..489511c 100644 --- a/contexts/PersistQueryClientProvider.tsx +++ b/contexts/PersistQueryClientProvider.tsx @@ -8,8 +8,8 @@ import type { AsyncPersistRetryer } from '@tanstack/query-async-storage-persiste const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { - gcTime: 1000 * 60 * 60 * 24, // 24 hours - staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 60 * 24, + staleTime: 1000 * 60 * 5, retry: 2, }, }, diff --git a/firebase/functions/index.js b/firebase/functions/index.js index d952124..bec2b4b 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -1,4 +1,4 @@ -const { onRequest } = require("firebase-functions/v2/https"); +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"); @@ -14,7 +14,7 @@ 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"; +const WEBHOOK_URL = "https://sendsyncnotification-ne4kpcdqwa-uc.a.run.app"; /* ───────────────────────────────── PUSH NOTIFICATION HELPERS @@ -1171,7 +1171,7 @@ exports.checkUpcomingEvents = functions.pubsub } try { - const familyDoc = await db.collection("Families").doc(familyId).get(); + const familyDoc = await db.collection("Households").doc(familyId).get(); if (!familyDoc.exists) continue; const familySettings = familyDoc.data()?.settings || {}; @@ -1299,28 +1299,29 @@ exports.migrateEventNotifications = functions.https.onRequest(async (req, res) = } }); -exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { +exports.sendSyncNotification = onRequest(async (req, res) => { const userId = req.query.userId; if (!userId) { - console.error('[SYNC] Missing userId in request'); + 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) { - console.error(`[SYNC] No profile found for user ${userId}`); + 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) { - console.error(`[SYNC] No Google account found for user ${userId}`); + logger.error(`[SYNC] No Google account found for user ${userId}`); return res.status(400).send("No Google account found"); } @@ -1328,21 +1329,40 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { const { accessToken, refreshToken } = accountData; if (!accessToken) { - console.error(`[SYNC] No access token for user ${userId}`); + logger.error(`[SYNC] No access token for user ${userId}`); return res.status(400).send("No access token found"); } - const familyId = userData.familyId; if (!familyId) { - console.error(`[SYNC] No family ID for user ${userId}`); + logger.error(`[SYNC] No family ID for user ${userId}`); return res.status(400).send("No family ID found"); } - console.log(`[SYNC] Starting calendar sync for user ${userId}`); + // 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 - await fetchAndSaveGoogleEvents({ + // Trigger immediate sync and get event count + const totalEvents = await fetchAndSaveGoogleEvents({ token: accessToken, refreshToken, email, @@ -1350,13 +1370,13 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { creatorId: userId }); - // Update household timestamps - const householdsSnapshot = await db.collection('Households') + // 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(); - householdsSnapshot.docs.forEach((doc) => { + updatedHouseholdsSnapshot.docs.forEach((doc) => { batch.update(doc.ref, { lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp() }); @@ -1364,10 +1384,319 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => { await batch.commit(); - console.log(`[SYNC] Completed sync for user ${userId} in ${Date.now() - syncStartTime}ms`); + logger.info(`[SYNC] Completed sync for user ${userId} in ${Date.now() - syncStartTime}ms`); res.status(200).send("Sync completed successfully"); } catch (error) { - console.error(`[SYNC] Error in sync notification:`, 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'); }); \ No newline at end of file diff --git a/hooks/firebase/useGetEvents.ts b/hooks/firebase/useGetEvents.ts index e5147fd..24c00f0 100644 --- a/hooks/firebase/useGetEvents.ts +++ b/hooks/firebase/useGetEvents.ts @@ -106,13 +106,13 @@ export const useGetEvents = () => { const prefetchEvents = async () => { await queryClient.prefetchQuery({ - queryKey: ["events", user.uid, false], // Personal events + queryKey: ["events", user.uid, false], queryFn: () => fetchEvents(user.uid, profileData, false), staleTime: 5 * 60 * 1000, }); await queryClient.prefetchQuery({ - queryKey: ["events", user.uid, true], // Family events + queryKey: ["events", user.uid, true], queryFn: () => fetchEvents(user.uid, profileData, true), staleTime: 5 * 60 * 1000, }); @@ -150,8 +150,8 @@ export const useGetEvents = () => { return useQuery({ queryKey: ["events", user?.uid, isFamilyView], queryFn: () => fetchEvents(user?.uid!, profileData, isFamilyView), - staleTime: Infinity, - gcTime: Infinity, + staleTime: 10 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, placeholderData: (previousData) => previousData, enabled: Boolean(user?.uid), });