Calendar controls fix

This commit is contained in:
Milan Paunovic
2025-02-14 15:05:42 +01:00
parent f9a5e76162
commit e04441bd81
4 changed files with 354 additions and 25 deletions

View File

@ -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;
});

View File

@ -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,
},
},

View File

@ -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');
});

View File

@ -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),
});