Files
cally/firebase/functions/index.js
Milan Paunovic d091aaa0ee Batch updates
2024-11-22 09:21:12 +01:00

1451 lines
55 KiB
JavaScript

const {onRequest} = require("firebase-functions/v2/https");
const {getAuth} = require("firebase-admin/auth");
const {getFirestore, Timestamp} = require("firebase-admin/firestore");
const logger = require("firebase-functions/logger");
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const {Expo} = require('expo-server-sdk');
admin.initializeApp();
const db = admin.firestore();
let expo = new Expo({accessToken: process.env.EXPO_ACCESS_TOKEN});
let notificationTimeout = null;
let eventCount = 0;
let notificationInProgress = false;
const GOOGLE_CALENDAR_ID = "primary";
const CHANNEL_ID = "cally-family-calendar";
const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification";
async function getPushTokensForFamily(familyId, excludeUserId = null) {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.where('familyId', '==', familyId).get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
if ((!excludeUserId || data.uid !== excludeUserId) && data.pushToken) {
pushTokens.push(data.pushToken);
}
});
return pushTokens;
}
exports.sendOverviews = functions.pubsub
.schedule('0 20 * * *')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.overviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const tomorrowEnd = new Date(tomorrow);
tomorrowEnd.setHours(23, 59, 59, 999);
const weekEnd = new Date(tomorrow);
weekEnd.setDate(weekEnd.getDate() + 7);
weekEnd.setHours(23, 59, 59, 999);
const [tomorrowEvents, weekEvents] = await Promise.all([
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', tomorrow)
.where('startDate', '<=', tomorrowEnd)
.orderBy('startDate')
.limit(3)
.get(),
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>', tomorrowEnd)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get()
]);
if (tomorrowEvents.empty && weekEvents.empty) {
continue;
}
let notificationBody = '';
if (!tomorrowEvents.empty) {
notificationBody += 'Tomorrow: ';
const tomorrowTitles = tomorrowEvents.docs.map(doc => doc.data().title);
notificationBody += tomorrowTitles.join(', ');
if (tomorrowEvents.size === 3) notificationBody += ' and more...';
}
if (!weekEvents.empty) {
if (notificationBody) notificationBody += '\n\n';
notificationBody += 'This week: ';
const weekTitles = weekEvents.docs.map(doc => doc.data().title);
notificationBody += weekTitles.join(', ');
if (weekEvents.size === 3) notificationBody += ' and more...';
}
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Family Calendar Overview",
body: notificationBody,
data: {
type: 'calendar_overview',
date: tomorrow.toISOString()
}
});
await storeNotification({
type: 'calendar_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
console.error(`Error sending overview for family ${familyId}:`, error);
}
}
});
exports.sendWeeklyOverview = functions.pubsub
.schedule('0 20 * * 0')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.weeklyOverviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() + 1);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
const weekEvents = await admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', weekStart)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get();
if (weekEvents.empty) continue;
const eventTitles = weekEvents.docs.map(doc => doc.data().title);
const notificationBody = `Next week: ${eventTitles.join(', ')}${weekEvents.size === 3 ? ' and more...' : ''}`;
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Weekly Calendar Overview",
body: notificationBody,
data: {
type: 'weekly_overview',
weekStart: weekStart.toISOString()
}
});
await storeNotification({
type: 'weekly_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error sending weekly overview for family ${familyId}:`, error);
}
}
});
exports.sendNotificationOnEventCreation = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const {familyId, creatorId, email, title, externalOrigin} = eventData;
if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data');
return;
}
// Get push tokens - exclude creator for manual events, include everyone for synced events
let pushTokens = await getPushTokensForFamily(
familyId,
externalOrigin ? null : creatorId // Only exclude creator for manual events
);
if (!pushTokens.length) {
console.log('No push tokens available for the event.');
return;
}
eventCount++;
// Only set up the notification timeout if it's not already in progress
if (!notificationInProgress) {
notificationInProgress = true;
notificationTimeout = setTimeout(async () => {
let eventMessage;
if (externalOrigin) {
eventMessage = eventCount === 1
? `Calendar sync completed: "${title}" has been added.`
: `Calendar sync completed: ${eventCount} new events have been added.`;
} else {
eventMessage = eventCount === 1
? `New event "${title}" has been added to the family calendar.`
: `${eventCount} new events have been added to the family calendar.`;
}
let messages = pushTokens.map(pushToken => {
if (!Expo.isExpoPushToken(pushToken)) {
console.error(`Push token ${pushToken} is not a valid Expo push token`);
return null;
}
return {
to: pushToken,
sound: 'default',
title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events',
body: eventMessage,
data: {
eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual'
},
};
}).filter(Boolean);
let chunks = expo.chunkPushNotifications(messages);
let tickets = [];
for (let chunk of chunks) {
try {
let ticketChunk = await expo.sendPushNotificationsAsync(chunk);
tickets.push(...ticketChunk);
for (let ticket of ticketChunk) {
if (ticket.status === 'ok') {
console.log('Notification successfully sent:', ticket.id);
} else if (ticket.status === 'error') {
console.error(`Notification error: ${ticket.message}`);
if (ticket.details?.error === 'DeviceNotRegistered') {
await removeInvalidPushToken(ticket.to);
}
}
}
} catch (error) {
console.error('Error sending notification:', error);
}
}
// Save the notification in Firestore
const notificationData = {
creatorId,
familyId,
content: eventMessage,
eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual',
timestamp: Timestamp.now(),
date: eventData.startDate
};
try {
await db.collection("Notifications").add(notificationData);
console.log("Notification stored in Firestore:", notificationData);
} catch (error) {
console.error("Error saving notification to Firestore:", error);
}
// Reset state variables
eventCount = 0;
pushTokens = [];
notificationInProgress = false;
}, 5000);
}
});
// Store batches in Firestore instead of memory
async function addToUpdateBatch(eventData, eventId) {
const batchId = `${eventData.familyId}_${eventData.lastModifiedBy}`;
const batchRef = admin.firestore().collection('UpdateBatches').doc(batchId);
try {
await admin.firestore().runTransaction(async (transaction) => {
const batchDoc = await transaction.get(batchRef);
if (!batchDoc.exists) {
// Create new batch
transaction.set(batchRef, {
familyId: eventData.familyId,
lastModifiedBy: eventData.lastModifiedBy,
externalOrigin: eventData.externalOrigin,
events: [{
id: eventId,
title: eventData.title,
startDate: eventData.startDate
}],
createdAt: admin.firestore.FieldValue.serverTimestamp(),
processed: false
});
} else {
// Update existing batch
const existingEvents = batchDoc.data().events || [];
transaction.update(batchRef, {
events: [...existingEvents, {
id: eventId,
title: eventData.title,
startDate: eventData.startDate
}]
});
}
});
} catch (error) {
console.error('Error adding to update batch:', error);
throw error;
}
}
exports.onEventUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const beforeData = change.before.data();
const afterData = change.after.data();
const {familyId, title, lastModifiedBy, externalOrigin, startDate} = afterData;
if (JSON.stringify(beforeData) === JSON.stringify(afterData)) {
return null;
}
try {
await addToUpdateBatch({
familyId,
title,
lastModifiedBy,
externalOrigin,
startDate
}, context.params.eventId);
} catch (error) {
console.error('Error in onEventUpdate:', error);
}
});
// Separate function to process batches
exports.processUpdateBatches = functions.pubsub
.schedule('every 1 minutes')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('UpdateBatches');
// Find unprocessed batches older than 5 seconds
const cutoff = new Date(Date.now() - 5000);
const snapshot = await batchesRef
.where('processed', '==', false)
.where('createdAt', '<=', cutoff)
.get();
const processPromises = snapshot.docs.map(async (doc) => {
const batchData = doc.data();
try {
const pushTokens = await getPushTokensForFamily(
batchData.familyId,
batchData.lastModifiedBy
);
if (pushTokens.length) {
let message;
if (batchData.externalOrigin) {
message = `Calendar sync completed: ${batchData.events.length} events have been updated`;
} else {
message = batchData.events.length === 1
? `Event "${batchData.events[0].title}" has been updated`
: `${batchData.events.length} events have been updated`;
}
await sendNotifications(pushTokens, {
title: batchData.externalOrigin ? "Calendar Sync Complete" : "Events Updated",
body: message,
data: {
type: 'event_update',
count: batchData.events.length
}
});
await storeNotification({
type: 'event_update',
familyId: batchData.familyId,
content: message,
excludedUser: batchData.lastModifiedBy,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
count: batchData.events.length,
date: batchData.events[0].startDate
});
}
// Mark batch as processed
await doc.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error processing batch ${doc.id}:`, error);
}
});
await Promise.all(processPromises);
});
// Cleanup old batches
exports.cleanupUpdateBatches = functions.pubsub
.schedule('every 24 hours')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('UpdateBatches');
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oldBatches = await batchesRef
.where('processedAt', '<=', dayAgo)
.get();
const deletePromises = oldBatches.docs.map(doc => doc.ref.delete());
await Promise.all(deletePromises);
});
// Upcoming Event Reminders
exports.checkUpcomingEvents = functions.pubsub
.schedule('every 5 minutes')
.onRun(async (context) => {
const now = admin.firestore.Timestamp.now();
const eventsSnapshot = await admin.firestore().collection('Events').get();
for (const doc of eventsSnapshot.docs) {
const event = doc.data();
const {startDate, familyId, title, allDay, creatorId} = event;
if (startDate.toDate() < now.toDate()) continue;
try {
const familyDoc = await admin.firestore().collection('Families').doc(familyId).get();
const familySettings = familyDoc.data()?.settings || {};
const reminderTime = familySettings.defaultReminderTime || 15; // minutes
const eventTime = startDate.toDate();
const reminderThreshold = new Date(now.toDate().getTime() + (reminderTime * 60 * 1000));
// For all-day events, send reminder the evening before
if (allDay) {
const eveningBefore = new Date(eventTime);
eveningBefore.setDate(eveningBefore.getDate() - 1);
eveningBefore.setHours(20, 0, 0, 0);
if (now.toDate() >= eveningBefore && !event.eveningReminderSent) {
// Get all family members' tokens (including creator for reminders)
const pushTokens = await getPushTokensForFamily(familyId);
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});
}
}
// For regular events, check if within reminder threshold
else if (eventTime <= reminderThreshold && !event.reminderSent) {
// Include creator for reminders
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Upcoming Event",
body: `In ${reminderTime} minutes: ${title}`,
data: {
type: 'event_reminder',
eventId: doc.id
}
});
await doc.ref.update({reminderSent: true});
}
} catch (error) {
console.error(`Error processing reminder for event ${doc.id}:`, error);
}
}
});
async function storeNotification(notificationData) {
try {
await admin.firestore()
.collection('Notifications')
.add(notificationData);
} catch (error) {
console.error('Error storing notification:', error);
}
}
async function sendNotifications(pushTokens, notification) {
if (!pushTokens.length) return;
const messages = pushTokens
.filter(token => Expo.isExpoPushToken(token))
.map(pushToken => ({
to: pushToken,
sound: 'default',
priority: 'high',
...notification
}));
const chunks = expo.chunkPushNotifications(messages);
for (let chunk of chunks) {
try {
const tickets = await expo.sendPushNotificationsAsync(chunk);
for (let ticket of tickets) {
if (ticket.status === "error") {
if (ticket.details?.error === "DeviceNotRegistered") {
await removeInvalidPushToken(ticket.to);
}
console.error('Push notification error:', ticket.message);
}
}
} catch (error) {
console.error('Error sending notifications:', error);
}
}
}
exports.createSubUser = onRequest(async (request, response) => {
const authHeader = request.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn("Missing or incorrect Authorization header", {authHeader});
response.status(401).json({error: 'Unauthorized'});
return;
}
try {
const token = authHeader.split('Bearer ')[1];
logger.info("Verifying ID token", {token});
let decodedToken;
try {
decodedToken = await getAuth().verifyIdToken(token);
logger.info("ID token verified successfully", {uid: decodedToken.uid});
} catch (verifyError) {
logger.error("ID token verification failed", {error: verifyError.message});
response.status(401).json({error: 'Unauthorized: Invalid token'});
return;
}
logger.info("Processing user creation", {requestBody: request.body.data});
const {userType, firstName, lastName, email, password, familyId} = request.body.data;
if (!email || !password || !firstName || !userType || !familyId) {
logger.warn("Missing required fields in request body", {requestBody: request.body.data});
response.status(400).json({error: "Missing required fields"});
return;
}
let userRecord;
try {
userRecord = await getAuth().createUser({
email, password, displayName: `${firstName} ${lastName}`,
});
logger.info("User record created", {userId: userRecord.uid});
} catch (createUserError) {
logger.error("User creation failed", {error: createUserError.message});
response.status(500).json({error: "Failed to create user"});
return;
}
const userProfile = {
userType, firstName, lastName, familyId, email, uid: userRecord.uid
};
try {
await getFirestore().collection("Profiles").doc(userRecord.uid).set(userProfile);
logger.info("User profile saved to Firestore", {userId: userRecord.uid});
} catch (firestoreError) {
logger.error("Failed to save user profile to Firestore", {error: firestoreError.message});
response.status(500).json({error: "Failed to save user profile"});
return;
}
response.status(200).json({
data: {
message: "User created successfully", userId: userRecord.uid,
}
});
} catch (error) {
logger.error("Error in createSubUser function", {error: error.message});
response.status(500).json({data: {error: error.message}});
}
});
exports.removeSubUser = onRequest(async (request, response) => {
const authHeader = request.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
logger.warn("Missing or incorrect Authorization header", {authHeader});
response.status(401).json({error: 'Unauthorized'});
return;
}
try {
const token = authHeader.split('Bearer ')[1];
logger.info("Verifying ID token", {token});
let decodedToken;
try {
decodedToken = await getAuth().verifyIdToken(token);
logger.info("ID token verified successfully", {uid: decodedToken.uid});
} catch (verifyError) {
logger.error("ID token verification failed", {error: verifyError.message});
response.status(401).json({error: 'Unauthorized: Invalid token'});
return;
}
logger.info("Processing user removal", {requestBody: request.body.data});
const {userId, familyId} = request.body.data;
if (!userId || !familyId) {
logger.warn("Missing required fields in request body", {requestBody: request.body.data});
response.status(400).json({error: "Missing required fields"});
return;
}
try {
const userProfile = await getFirestore()
.collection("Profiles")
.doc(userId)
.get();
if (!userProfile.exists) {
logger.error("User profile not found", {userId});
response.status(404).json({error: "User not found"});
return;
}
if (userProfile.data().familyId !== familyId) {
logger.error("User does not belong to the specified family", {
userId,
requestedFamilyId: familyId,
actualFamilyId: userProfile.data().familyId
});
response.status(403).json({error: "User does not belong to the specified family"});
return;
}
await getFirestore()
.collection("Profiles")
.doc(userId)
.delete();
logger.info("User profile deleted from Firestore", {userId});
await getAuth().deleteUser(userId);
logger.info("User authentication deleted", {userId});
response.status(200).json({
data: {
message: "User removed successfully",
success: true
}
});
} catch (error) {
logger.error("Failed to remove user", {error: error.message});
response.status(500).json({error: "Failed to remove user"});
return;
}
} catch (error) {
logger.error("Error in removeSubUser function", {error: error.message});
response.status(500).json({data: {error: error.message}});
}
});
exports.generateCustomToken = onRequest(async (request, response) => {
try {
const {userId} = request.body.data;
console.log("Generating custom token for userId", {userId});
if (!userId) {
response.status(400).json({error: 'Missing userId'});
return;
}
const customToken = await getAuth().createCustomToken(userId);
response.status(200).json({data: {token: customToken}});
} catch (error) {
console.error("Error generating custom token", {error: error.message});
response.status(500).json({error: "Failed to generate custom token"});
}
});
exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(async (context) => {
console.log('Running token refresh job...');
const profilesSnapshot = await db.collection('Profiles').get();
profilesSnapshot.forEach(async (profileDoc) => {
const profileData = profileDoc.data();
if (profileData.googleAccounts) {
try {
for (const googleEmail of Object.keys(profileData?.googleAccounts)) {
const googleToken = profileData?.googleAccounts?.[googleEmail]?.refreshToken;
if (googleToken) {
const {refreshedGoogleToken, refreshedRefreshToken} = await refreshGoogleToken(googleToken);
const updatedGoogleAccounts = {
...profileData.googleAccounts,
[googleEmail]: {accessToken: refreshedGoogleToken, refreshToken: refreshedRefreshToken}
};
await profileDoc.ref.update({googleAccounts: updatedGoogleAccounts});
console.log(`Google token updated for user ${profileDoc.id}`);
}
}
} catch (error) {
console.error(`Error refreshing Google token for user ${profileDoc.id}:`, error.message);
}
}
if (profileData.microsoftAccounts) {
try {
for (const microsoftEmail of Object.keys(profileData?.microsoftAccounts)) {
const microsoftToken = profileData?.microsoftAccounts?.[microsoftEmail];
if (microsoftToken) {
const refreshedMicrosoftToken = await refreshMicrosoftToken(microsoftToken);
const updatedMicrosoftAccounts = {
...profileData.microsoftAccounts,
[microsoftEmail]: refreshedMicrosoftToken
};
await profileDoc.ref.update({microsoftAccounts: updatedMicrosoftAccounts});
console.log(`Microsoft token updated for user ${profileDoc.id}`);
}
}
} catch (error) {
console.error(`Error refreshing Microsoft token for user ${profileDoc.id}:`, error.message);
}
}
if (profileData.appleAccounts) {
try {
for (const appleEmail of Object.keys(profileData?.appleAccounts)) {
const appleToken = profileData?.appleAccounts?.[appleEmail];
const refreshedAppleToken = await refreshAppleToken(appleToken);
const updatedAppleAccounts = {...profileData.appleAccounts, [appleEmail]: refreshedAppleToken};
await profileDoc.ref.update({appleAccunts: updatedAppleAccounts});
console.log(`Apple token updated for user ${profileDoc.id}`);
}
} catch (error) {
console.error(`Error refreshing Apple token for user ${profileDoc.id}:`, error.message);
}
}
});
return null;
});
async function refreshMicrosoftToken(refreshToken) {
try {
const response = await axios.post('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af", // Client ID from microsoftConfig
scope: "openid profile email offline_access Calendars.ReadWrite User.Read", // Scope from microsoftConfig
});
return response.data.access_token; // Return the new access token
} catch (error) {
console.error("Error refreshing Microsoft token:", error);
throw error;
}
}
async function getPushTokensForEvent() {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
if (data.pushToken) {
pushTokens.push(data.pushToken);
}
});
console.log('Push Tokens:', pushTokens);
return pushTokens;
}
async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.where('familyId', '==', familyId).get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
// Exclude the creator
if (data.uid !== creatorId && data.pushToken) {
pushTokens.push(data.pushToken);
}
});
return pushTokens;
}
async function removeInvalidPushToken(pushToken) {
try {
const profilesRef = db.collection('Profiles');
const snapshot = await profilesRef.where('pushToken', '==', pushToken).get();
const batch = db.batch();
snapshot.forEach(doc => {
batch.update(doc.ref, {
pushToken: admin.firestore.FieldValue.delete()
});
});
await batch.commit();
console.log(`Removed invalid push token: ${pushToken}`);
} catch (error) {
console.error('Error removing invalid push token:', error);
}
}
const fetch = require("node-fetch");
// Function to refresh Google Token with additional logging
async function refreshGoogleToken(refreshToken) {
try {
console.log("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();
console.error("Error refreshing Google token:", errorData);
throw new Error(`Failed to refresh Google token: ${errorData.error || response.statusText}`);
}
const data = await response.json();
console.log("Google token refreshed successfully");
// Return both the access token and refresh token (if a new one is provided)
return {
refreshedGoogleToken: data.access_token,
refreshedRefreshToken: data.refresh_token || refreshToken, // Return the existing refresh token if a new one is not provided
};
} catch (error) {
console.error("Error refreshing Google token:", error.message);
throw error;
}
}
// Helper function to get Google access tokens for all users and refresh them if needed with logging
async function getGoogleAccessTokens() {
console.log("Fetching Google access tokens for all users...");
const tokens = {};
const profilesSnapshot = await db.collection("Profiles").get();
await Promise.all(
profilesSnapshot.docs.map(async (doc) => {
const profileData = doc.data();
const googleAccounts = profileData?.googleAccounts || {};
for (const googleEmail of Object.keys(googleAccounts)) {
// Check if the googleAccount entry exists and has a refreshToken
const accountInfo = googleAccounts[googleEmail];
const refreshToken = accountInfo?.refreshToken;
if (refreshToken) {
try {
console.log(`Refreshing token for user ${doc.id} (email: ${googleEmail})`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
tokens[doc.id] = accessToken;
console.log(`Token refreshed successfully for user ${doc.id}`);
} catch (error) {
tokens[doc.id] = accountInfo?.accessToken;
console.error(`Failed to refresh token for user ${doc.id}:`, error.message);
}
} else {
console.log(`No refresh token available for user ${doc.id} (email: ${googleEmail})`);
}
}
})
);
console.log("Access tokens fetched and refreshed as needed");
return tokens;
}
// Function to watch Google Calendar events with additional logging
const watchCalendarEvents = async (userId, token) => {
const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`;
// Verify the token is valid
console.log(`Attempting to watch calendar for user ${userId}`);
console.log(`Token being used: ${token ? 'present' : 'missing'}`);
console.log(`Calendar ID: ${GOOGLE_CALENDAR_ID}`);
console.log(`Webhook URL: ${WEBHOOK_URL}?userId=${userId}`);
try {
// Test the token first
const testResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events?maxResults=1`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!testResponse.ok) {
console.error(`Token validation failed for user ${userId}:`, await testResponse.text());
throw new Error('Token validation failed');
}
console.log(`Token validated successfully for user ${userId}`);
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: `${CHANNEL_ID}-${userId}`,
type: "web_hook",
address: `${WEBHOOK_URL}?userId=${userId}`,
params: {
ttl: "80000",
},
}),
});
const responseText = await response.text();
console.log(`Watch response for user ${userId}:`, responseText);
if (!response.ok) {
console.error(`Failed to watch calendar for user ${userId}:`, responseText);
throw new Error(`Failed to watch calendar: ${responseText}`);
}
const result = JSON.parse(responseText);
console.log(`Successfully set up Google Calendar watch for user ${userId}`, result);
// Store the watch details in Firestore for monitoring
await db.collection('CalendarWatches').doc(userId).set({
watchId: result.id,
resourceId: result.resourceId,
expiration: result.expiration,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
return result;
} catch (error) {
console.error(`Error in watchCalendarEvents for user ${userId}:`, error);
// Store the error in Firestore for monitoring
await db.collection('CalendarWatchErrors').add({
userId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
throw error;
}
};
// Add this to test webhook connectivity
exports.testWebhook = functions.https.onRequest(async (req, res) => {
console.log('Test webhook received');
console.log('Headers:', req.headers);
console.log('Body:', req.body);
console.log('Query:', req.query);
res.status(200).send('Test webhook received successfully');
});
// Schedule function to renew Google Calendar watch every 20 hours for each user with logging
exports.renewGoogleCalendarWatch = functions.pubsub.schedule("every 10 minutes").onRun(async (context) => {
console.log("Starting Google Calendar watch renewal process...");
try {
const tokens = await getGoogleAccessTokens();
console.log("Tokens: ", tokens);
for (const [userId, token] of Object.entries(tokens)) {
try {
await watchCalendarEvents(userId, token);
} catch (error) {
console.error(`Error renewing Google Calendar watch for user ${userId}:`, error.message);
}
}
console.log("Google Calendar watch renewal process completed");
} catch (error) {
console.error("Error in renewGoogleCalendarWatch function:", error.message);
}
});
// Function to handle notifications from Google Calendar with additional logging
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId; // Extract userId from query params
const calendarId = req.body.resourceId;
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
try {
// Fetch user profile data for the specific user
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
// Ensure pushTokens is an array
let pushTokens = [];
if (userData && userData.pushToken) {
pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken];
}
if (pushTokens.length === 0) {
console.log(`No push tokens found for user ${userId}`);
res.status(200).send("No push tokens found for user.");
return;
}
// Call calendarSync with necessary parameters
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0]; // Assuming the first account is the primary
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
// Prepare and send push notifications after sync
// const syncMessage = "New events have been synced.";
//
// let messages = pushTokens.map(pushToken => {
// if (!Expo.isExpoPushToken(pushToken)) {
// console.error(`Push token ${pushToken} is not a valid Expo push token`);
// return null;
// }
//
// return {
// to: pushToken,
// sound: "default",
// title: "Event Sync",
// body: syncMessage,
// data: { userId, calendarId },
// };
// }).filter(Boolean);
//
// let chunks = expo.chunkPushNotifications(messages);
// let tickets = [];
//
// for (let chunk of chunks) {
// try {
// let ticketChunk = await expo.sendPushNotificationsAsync(chunk);
// tickets.push(...ticketChunk);
//
// for (let ticket of ticketChunk) {
// if (ticket.status === "ok") {
// console.log("Notification successfully sent:", ticket.id);
// } else if (ticket.status === "error") {
// console.error(`Notification error: ${ticket.message}`);
// if (ticket.details?.error === "DeviceNotRegistered") {
// await removeInvalidPushToken(ticket.to);
// }
// }
// }
// } catch (error) {
// console.error("Error sending notification:", error.message);
// }
// }
//
// console.log(`Sync notification sent for user ${userId}`);
res.status(200).send("Sync notification sent.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
res.status(500).send("Failed to send sync notification.");
}
});
async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
let events = [];
let pageToken = null;
try {
console.log(`Fetching events for user: ${email}`);
// Fetch all events from Google Calendar within the specified time range
do {
const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`);
url.searchParams.set("singleEvents", "true");
url.searchParams.set("timeMin", timeMin);
url.searchParams.set("timeMax", timeMax);
if (pageToken) url.searchParams.set("pageToken", pageToken);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const refreshedToken = await refreshGoogleToken(refreshToken);
token = refreshedToken;
if (token) {
return fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId});
} else {
console.error(`Failed to refresh token for user: ${email}`);
await clearToken(email);
return;
}
}
if (!response.ok) {
throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`);
}
console.log(`Processing events for user: ${email}`);
data.items?.forEach((item) => {
const googleEvent = {
id: item.id,
title: item.summary || "",
startDate: item.start?.dateTime
? new Date(item.start.dateTime)
: new Date(item.start.date + 'T00:00:00'),
endDate: item.end?.dateTime
? new Date(item.end.dateTime)
: new Date(new Date(item.end.date + 'T00:00:00').setDate(new Date(item.end.date).getDate() - 1)),
allDay: !item.start?.dateTime,
familyId,
email,
creatorId,
externalOrigin: "google",
};
events.push(googleEvent);
console.log(`Processed event: ${JSON.stringify(googleEvent)}`);
});
pageToken = data.nextPageToken;
} while (pageToken);
console.log(`Saving events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
} catch (error) {
console.error(`Error fetching Google Calendar events for ${email}:`, error);
}
}
async function saveEventsToFirestore(events) {
const batch = db.batch();
events.forEach((event) => {
const eventRef = db.collection("Events").doc(event.id);
batch.set(eventRef, event, {merge: true});
});
await batch.commit();
}
async function calendarSync({userId, email, token, refreshToken, familyId}) {
console.log(`Starting calendar sync for user ${userId} with email ${email}`);
try {
await fetchAndSaveGoogleEvents({
token,
refreshToken,
email,
familyId,
creatorId: userId,
});
console.log("Calendar events synced successfully.");
} catch (error) {
console.error(`Error syncing calendar for user ${userId}:`, error);
throw error;
}
console.log(`Finished calendar sync for user ${userId}`);
}
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
const calendarId = req.body.resourceId;
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
try {
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
let pushTokens = [];
if (userData && userData.pushToken) {
pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken];
}
if (pushTokens.length === 0) {
console.log(`No push tokens found for user ${userId}`);
res.status(200).send("No push tokens found for user.");
return;
}
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0];
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
res.status(200).send("Sync notification sent.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
res.status(500).send("Failed to send sync notification.");
}
});
async function refreshMicrosoftToken(refreshToken) {
try {
console.log("Refreshing Microsoft token...");
const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af",
scope: "openid profile email offline_access Calendars.ReadWrite User.Read",
refresh_token: refreshToken,
grant_type: 'refresh_token'
})
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error refreshing Microsoft token:", errorData);
throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`);
}
const data = await response.json();
console.log("Microsoft token refreshed successfully");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken
};
} catch (error) {
console.error("Error refreshing Microsoft token:", error.message);
throw error;
}
}
async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
try {
console.log(`Fetching Microsoft calendar events for user: ${email}`);
const url = `https://graph.microsoft.com/v1.0/me/calendar/events`;
const queryParams = new URLSearchParams({
$select: 'subject,start,end,id',
$filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'`
});
const response = await fetch(`${url}?${queryParams}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const {accessToken: newToken} = await refreshMicrosoftToken(refreshToken);
if (newToken) {
return fetchAndSaveMicrosoftEvents({
token: newToken,
refreshToken,
email,
familyId,
creatorId
});
}
}
if (!response.ok) {
throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`);
}
const events = data.value.map(item => ({
id: item.id,
title: item.subject || "",
startDate: new Date(item.start.dateTime + 'Z'),
endDate: new Date(item.end.dateTime + 'Z'),
allDay: false, // Microsoft Graph API handles all-day events differently
familyId,
email,
creatorId,
externalOrigin: "microsoft"
}));
console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
} catch (error) {
console.error(`Error fetching Microsoft Calendar events for ${email}:`, error);
throw error;
}
}
async function subscribeMicrosoftCalendar(accessToken, userId) {
try {
console.log(`Setting up Microsoft calendar subscription for user ${userId}`);
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(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
clientState: userId
};
const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
if (!response.ok) {
throw new Error(`Failed to create subscription: ${response.statusText}`);
}
const subscriptionData = await response.json();
// Store subscription details in Firestore
await admin.firestore().collection('MicrosoftSubscriptions').doc(userId).set({
subscriptionId: subscriptionData.id,
expirationDateTime: subscriptionData.expirationDateTime,
createdAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log(`Microsoft calendar subscription created for user ${userId}`);
return subscriptionData;
} catch (error) {
console.error(`Error creating Microsoft calendar subscription for user ${userId}:`, error);
throw error;
}
}
exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
console.log(`Received Microsoft calendar webhook for user ${userId}`, req.body);
try {
const userDoc = await admin.firestore().collection("Profiles").doc(userId).get();
const userData = userDoc.data();
if (!userData?.microsoftAccounts) {
console.log(`No Microsoft account found for user ${userId}`);
return res.status(200).send();
}
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
await fetchAndSaveMicrosoftEvents({
token,
email,
familyId: userData.familyId,
creatorId: userId
});
res.status(200).send();
} catch (error) {
console.error(`Error processing Microsoft calendar webhook for user ${userId}:`, error);
res.status(500).send();
}
});
exports.renewMicrosoftSubscriptions = functions.pubsub.schedule('every 24 hours').onRun(async (context) => {
console.log('Starting Microsoft subscription renewal process');
try {
const subscriptionsSnapshot = await admin.firestore()
.collection('MicrosoftSubscriptions')
.get();
for (const doc of subscriptionsSnapshot.docs) {
const userId = doc.id;
const userDoc = await admin.firestore().collection('Profiles').doc(userId).get();
const userData = userDoc.data();
if (userData?.microsoftAccounts) {
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
try {
await subscribeMicrosoftCalendar(token, userId);
console.log(`Renewed Microsoft subscription for user ${userId}`);
} catch (error) {
console.error(`Failed to renew Microsoft subscription for user ${userId}:`, error);
}
}
}
} catch (error) {
console.error('Error in Microsoft subscription renewal process:', error);
}
});