mirror of
https://github.com/urosran/cally.git
synced 2025-07-10 07:07:16 +00:00
Calendar controls fix
This commit is contained in:
@ -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;
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
@ -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');
|
||||
});
|
@ -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),
|
||||
});
|
||||
|
Reference in New Issue
Block a user