Files
cally/firebase/functions/index.js
Milan Paunovic f2af60111b Syncing rework
2024-11-26 21:13:54 +01:00

1750 lines
64 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 = eventData.familyId;
const creatorId = eventData.creatorId;
const title = eventData.title || '';
const externalOrigin = eventData.externalOrigin || false;
if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data');
return;
}
const timeWindow = Math.floor(Date.now() / 5000);
const batchId = `${timeWindow}_${familyId}_${creatorId}_${externalOrigin ? 'sync' : 'manual'}`;
const batchRef = admin.firestore().collection('EventBatches').doc(batchId);
try {
await admin.firestore().runTransaction(async (transaction) => {
const batchDoc = await transaction.get(batchRef);
if (!batchDoc.exists) {
transaction.set(batchRef, {
familyId,
creatorId,
externalOrigin: externalOrigin || false,
events: [{
id: context.params.eventId,
title: title || '',
timestamp: new Date().toISOString()
}],
createdAt: admin.firestore.FieldValue.serverTimestamp(),
processed: false,
expiresAt: new Date(Date.now() + 10000)
});
} else {
const existingEvents = batchDoc.data().events || [];
transaction.update(batchRef, {
events: [...existingEvents, {
id: context.params.eventId,
title: title || '',
timestamp: new Date().toISOString()
}]
});
}
});
} catch (error) {
console.error('Error adding to event batch:', error);
throw error;
}
});
exports.processEventBatches = functions.pubsub
.schedule('every 1 minutes')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('EventBatches');
const now = admin.firestore.Timestamp.fromDate(new Date());
const snapshot = await batchesRef
.where('processed', '==', false)
.where('expiresAt', '<=', now)
.limit(100)
.get();
if (snapshot.empty) return null;
const processPromises = snapshot.docs.map(async (doc) => {
const batchData = doc.data();
const {familyId, creatorId, externalOrigin, events} = batchData;
try {
const pushTokens = await getPushTokensForFamily(
familyId,
creatorId
);
if (pushTokens.length) {
let notificationMessage;
if (externalOrigin) {
notificationMessage = `Calendar sync completed: ${events.length} ${events.length === 1 ? 'event has' : 'events have'} been added.`;
} else {
notificationMessage = events.length === 1
? `New event "${events[0].title}" has been added to the family calendar.`
: `${events.length} new events have been added to the family calendar.`;
}
await sendNotifications(pushTokens, {
title: 'New Family Calendar Events',
body: notificationMessage,
data: {
type: externalOrigin ? 'sync' : 'manual',
count: events.length
}
});
await storeNotification({
type: externalOrigin ? 'sync' : 'manual',
familyId,
content: notificationMessage,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
creatorId,
eventCount: events.length
});
}
await doc.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error processing event batch ${doc.id}:`, error);
await doc.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp(),
error: error.message
});
}
});
await Promise.all(processPromises);
});
async function addToUpdateBatch(eventData, eventId) {
const timeWindow = Math.floor(Date.now() / 2000);
const batchId = `${timeWindow}_${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) {
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,
expiresAt: new Date(Date.now() + 3000)
});
} else {
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.cleanupEventBatches = functions.pubsub
.schedule('every 24 hours')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('EventBatches');
const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
while (true) {
const oldBatches = await batchesRef
.where('processedAt', '<=', dayAgo)
.limit(500)
.get();
if (oldBatches.empty) break;
const batch = admin.firestore().batch();
oldBatches.docs.forEach(doc => batch.delete(doc.ref));
await batch.commit();
}
});
exports.processUpdateBatches = functions.pubsub
.schedule('every 1 minutes')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('UpdateBatches');
const now = admin.firestore.Timestamp.fromDate(new Date());
const snapshot = await batchesRef
.where('processed', '==', false)
.where('expiresAt', '<=', now)
.limit(100)
.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 admin.firestore().runTransaction(async (transaction) => {
await sendNotifications(pushTokens, {
title: batchData.externalOrigin ? "Calendar Sync Complete" : "Events Updated",
body: message,
data: {
type: 'event_update',
count: batchData.events.length
}
});
const notificationRef = admin.firestore().collection('Notifications').doc();
transaction.set(notificationRef, {
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
});
transaction.update(doc.ref, {
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp()
});
});
} else {
await doc.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp()
});
}
} catch (error) {
console.error(`Error processing batch ${doc.id}:`, error);
await doc.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp(),
error: error.message
});
}
});
await Promise.all(processPromises);
});
exports.cleanupUpdateBatches = functions.pubsub
.schedule('every 24 hours')
.onRun(async (context) => {
const batchesRef = admin.firestore().collection('UpdateBatches');
const dayAgo = admin.firestore.Timestamp.fromDate(new Date(Date.now() - 24 * 60 * 60 * 1000));
while (true) {
const oldBatches = await batchesRef
.where('processedAt', '<=', dayAgo)
.limit(500)
.get();
if (oldBatches.empty) break;
const batch = admin.firestore().batch();
oldBatches.docs.forEach(doc => batch.delete(doc.ref));
await batch.commit();
}
});
exports.checkUpcomingEvents = functions.pubsub
.schedule('every 5 minutes')
.onRun(async (context) => {
const now = admin.firestore.Timestamp.now();
const eventsSnapshot = await admin.firestore()
.collection('Events')
.where('startDate', '>=', now)
.get();
const processPromises = eventsSnapshot.docs.map(async (doc) => {
const event = doc.data();
if (!event?.startDate) return;
const {familyId, title, allDay} = event;
try {
const familyDoc = await admin.firestore().collection('Families').doc(familyId).get();
if (!familyDoc.exists) return;
const familySettings = familyDoc.data()?.settings || {};
const reminderTime = familySettings.defaultReminderTime || 15;
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) {
console.error(`Error processing reminder for event ${doc.id}:`, error);
}
});
await Promise.all(processPromises);
});
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",
scope: "openid profile email offline_access Calendars.ReadWrite User.Read",
});
return response.data.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();
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");
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 {
refreshedGoogleToken: data.access_token,
refreshedRefreshToken: data.refresh_token || refreshToken,
};
} catch (error) {
console.error("Error refreshing Google token:", error.message);
throw error;
}
}
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)) {
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;
}
exports.renewGoogleCalendarWatch = functions.pubsub
.schedule("every 10 minutes")
.onRun(async (context) => {
console.log("Starting calendar watch renewal check");
const profilesWithGoogle = await db.collection('Profiles')
.where('googleAccounts', '!=', null)
.get();
const existingWatches = await db.collection('CalendarWatches').get();
const now = Date.now();
const watchMap = new Map();
existingWatches.forEach(doc => {
watchMap.set(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);
if (!userData.googleAccounts || Object.keys(userData.googleAccounts).length === 0) {
continue;
}
const firstAccount = Object.values(userData.googleAccounts)[0];
const token = firstAccount?.accessToken;
if (!token) {
console.log(`No token found for user ${userId}`);
continue;
}
const needsRenewal = !existingWatch ||
!existingWatch.expiration ||
existingWatch.expiration < (now + 30 * 60 * 1000);
if (!needsRenewal) {
continue;
}
try {
console.log(`${existingWatch ? 'Renewing' : 'Creating new'} watch for user ${userId}`);
const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`;
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: "604800"},
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
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++;
}
console.log(`Successfully ${existingWatch ? 'renewed' : 'created'} watch for user ${userId}`);
} catch (error) {
console.error(`Failed to ${existingWatch ? 'renew' : 'create'} watch for user ${userId}:`, error);
batch.set(db.collection('CalendarWatchErrors').doc(), {
userId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
}
}
if (processedCount > 0) {
await batch.commit();
}
console.log(`Completed calendar watch processing:`, {
totalProcessed: processedCount,
newWatches: newWatchCount,
renewals: renewalCount
});
});
const tokenRefreshInProgress = new Map();
exports.cleanupTokenRefreshFlags = functions.pubsub
.schedule('every 5 minutes')
.onRun(() => {
tokenRefreshInProgress.clear();
console.log('[CLEANUP] Cleared all token refresh flags');
return null;
});
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 totalEvents = 0;
let pageToken = null;
const batchSize = 50;
try {
console.log(`[FETCH] Starting event fetch for user: ${email}`);
do {
let events = [];
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);
url.searchParams.set("maxResults", batchSize.toString());
if (pageToken) url.searchParams.set("pageToken", pageToken);
console.log(`[FETCH] Making request with token: ${token.substring(0, 10)}...`);
let response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 401 && refreshToken) {
console.log(`[TOKEN] Token expired during fetch, refreshing for ${email}`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
if (refreshedGoogleToken) {
console.log(`[TOKEN] Token refreshed successfully during fetch`);
token = refreshedGoogleToken;
// Update token in Firestore
await db.collection("Profiles").doc(creatorId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
// Retry the request with new token
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}`);
}
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);
});
if (events.length > 0) {
console.log(`[FETCH] Saving batch of ${events.length} events`);
await saveEventsToFirestore(events);
totalEvents += events.length;
}
pageToken = data.nextPageToken;
} while (pageToken);
console.log(`[FETCH] Completed with ${totalEvents} total events`);
return totalEvents;
} catch (error) {
console.error(`[ERROR] Failed fetching events for ${email}:`, error);
throw 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(`[SYNC] Starting calendar sync for user ${userId} with email ${email}`);
try {
if (refreshToken) {
console.log(`[TOKEN] Initial token refresh for ${email}`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
if (refreshedGoogleToken) {
console.log(`[TOKEN] Token refreshed successfully for ${email}`);
token = refreshedGoogleToken;
await db.collection("Profiles").doc(userId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
}
}
const eventCount = await fetchAndSaveGoogleEvents({
token,
refreshToken,
email,
familyId,
creatorId: userId,
});
console.log(`[SYNC] Calendar sync completed. Processed ${eventCount} events`);
return eventCount;
} catch (error) {
console.error(`[ERROR] Calendar sync failed for user ${userId}:`, error);
throw error;
}
}
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
const calendarId = req.body.resourceId;
console.log(`[SYNC START] Received notification for user ${userId} with calendar ID ${calendarId}`);
console.log('Request headers:', req.headers);
console.log('Request body:', req.body);
try {
console.log(`[PROFILE] Fetching user profile for ${userId}`);
const userDoc = await db.collection("Profiles").doc(userId).get();
if (!userDoc.exists) {
console.error(`[ERROR] No profile found for user ${userId}`);
return res.status(404).send("User profile not found");
}
const userData = userDoc.data();
console.log(`[PROFILE] Found profile data for user ${userId}:`, {
hasGoogleAccounts: !!userData.googleAccounts,
familyId: userData.familyId
});
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0];
if (!email) {
console.error(`[ERROR] No Google account found for user ${userId}`);
return res.status(400).send("No Google account found");
}
console.log(`[GOOGLE] Using Google account: ${email}`);
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
if (!familyId) {
console.error(`[ERROR] No family ID found for user ${userId}`);
return res.status(400).send("No family ID found");
}
console.log(`[SYNC] Starting calendar sync for user ${userId} (family: ${familyId})`);
const syncStartTime = Date.now();
await calendarSync({userId, email, token, refreshToken, familyId});
console.log(`[SYNC] Calendar sync completed in ${Date.now() - syncStartTime}ms`);
console.log(`[HOUSEHOLDS] Fetching households for family ${familyId}`);
const querySnapshot = await db.collection('Households')
.where("familyId", "==", familyId)
.get();
console.log(`[HOUSEHOLDS] Found ${querySnapshot.size} households to update`);
const batch = db.batch();
querySnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLDS] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
console.log(`[HOUSEHOLDS] Committing batch update for ${querySnapshot.size} households`);
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLDS] Batch update completed in ${Date.now() - batchStartTime}ms`);
console.log(`[SYNC COMPLETE] Successfully processed sync for user ${userId}`);
res.status(200).send("Sync completed successfully.");
} catch (error) {
console.error(`[ERROR] Error in sendSyncNotification for user ${userId}:`, {
errorMessage: error.message,
errorStack: error.stack,
errorCode: error.code
});
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,isAllDay',
$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 => {
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"
};
});
console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
return events.length;
} catch (error) {
console.error(`Error fetching Microsoft Calendar events for ${email}:`, error);
throw error;
}
}
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: admin.firestore.FieldValue.serverTimestamp()
}, {merge: true});
} catch (error) {
console.error(`Failed to renew Microsoft subscription for ${userId}:`, error);
batch.set(db.collection('MicrosoftSubscriptionErrors').doc(), {
userId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
}
}
await batch.commit();
});
exports.microsoftCalendarWebhook = functions.https.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
});
res.status(200).send();
} catch (error) {
console.error(`Error processing Microsoft webhook for ${userId}:`, error);
res.status(500).send();
}
});
exports.triggerGoogleSync = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'Authentication required'
);
}
try {
const {email} = data;
const userId = context.auth.uid;
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
if (!userData?.googleAccounts?.[email]) {
throw new functions.https.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
});
return {
success: true,
eventCount,
message: "Google calendar sync completed successfully"
};
} catch (error) {
console.error('Google sync error:', error);
throw new functions.https.HttpsError('internal', error.message);
}
});
exports.triggerMicrosoftSync = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'Authentication required'
);
}
try {
const {email} = data;
if (!email) {
throw new functions.https.HttpsError(
'invalid-argument',
'Email is required'
);
}
console.log('Starting Microsoft sync for:', {userId: context.auth.uid, email});
const userDoc = await db.collection("Profiles").doc(context.auth.uid).get();
if (!userDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'User profile not found'
);
}
const userData = userDoc.data();
const accountData = userData.microsoftAccounts?.[email];
if (!accountData) {
throw new functions.https.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(context.auth.uid).update({
[`microsoftAccounts.${email}`]: {
...accountData,
accessToken,
refreshToken,
lastRefresh: admin.firestore.FieldValue.serverTimestamp()
}
});
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
throw new functions.https.HttpsError(
'failed-precondition',
'Failed to refresh Microsoft token. Please reconnect your account.',
{requiresReauth: true}
);
}
} else if (!accessToken) {
throw new functions.https.HttpsError(
'failed-precondition',
'Microsoft account requires authentication. Please reconnect your account.',
{requiresReauth: true}
);
}
try {
console.log('Fetching Microsoft events with token');
const eventCount = await fetchAndSaveMicrosoftEvents({
token: accessToken,
refreshToken,
email,
familyId: userData.familyId,
creatorId: context.auth.uid
});
console.log('Microsoft sync completed successfully:', {eventCount});
return {
success: true,
eventCount,
message: "Microsoft calendar sync completed successfully"
};
} catch (syncError) {
// Check if the error is due to invalid token
if (syncError.message?.includes('401') ||
syncError.message?.includes('unauthorized') ||
syncError.message?.includes('invalid_grant')) {
throw new functions.https.HttpsError(
'unauthenticated',
'Microsoft authentication expired. Please reconnect your account.',
{requiresReauth: true}
);
}
throw new functions.https.HttpsError(
'internal',
syncError.message || 'Failed to sync Microsoft calendar',
{originalError: syncError}
);
}
} catch (error) {
console.error('Microsoft sync function error:', error);
if (error instanceof functions.https.HttpsError) {
throw error;
}
throw new functions.https.HttpsError(
'internal',
error.message || 'Unknown error occurred',
{originalError: error}
);
}
});
exports.updateHouseholdTimestampOnEventCreate = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const familyId = eventData.familyId;
const eventId = context.params.eventId;
console.log(`[HOUSEHOLD_UPDATE] Event created - Processing timestamp updates`, {
eventId,
familyId,
eventTitle: eventData.title || 'Untitled'
});
try {
const householdsSnapshot = await db.collection('Households')
.where('familyId', '==', familyId)
.get();
console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`);
const batch = db.batch();
householdsSnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastUpdateTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, {
familyId,
householdsUpdated: householdsSnapshot.size,
eventId
});
} catch (error) {
console.error(`[HOUSEHOLD_UPDATE] Error updating households for event creation`, {
eventId,
familyId,
error: error.message,
stack: error.stack
});
throw error;
}
});
exports.updateHouseholdTimestampOnEventUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const eventData = change.after.data();
const familyId = eventData.familyId;
const eventId = context.params.eventId;
console.log(`[HOUSEHOLD_UPDATE] Event updated - Processing timestamp updates`, {
eventId,
familyId,
eventTitle: eventData.title || 'Untitled'
});
try {
const householdsSnapshot = await db.collection('Households')
.where('familyId', '==', familyId)
.get();
console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`);
const batch = db.batch();
householdsSnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastUpdateTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, {
familyId,
householdsUpdated: householdsSnapshot.size,
eventId
});
} catch (error) {
console.error(`[HOUSEHOLD_UPDATE] Error updating households for event update`, {
eventId,
familyId,
error: error.message,
stack: error.stack
});
throw error;
}
});