mirror of
https://github.com/urosran/cally.git
synced 2025-07-10 07:07:16 +00:00
2182 lines
80 KiB
JavaScript
2182 lines
80 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 = [];
|
|
|
|
console.log('Getting push tokens:', {
|
|
familyId,
|
|
excludeUserId
|
|
});
|
|
|
|
snapshot.forEach(doc => {
|
|
const data = doc.data();
|
|
const userId = doc.id;
|
|
|
|
console.log('Processing user:', {
|
|
docId: userId,
|
|
hasToken: !!data.pushToken,
|
|
excluded: userId === excludeUserId
|
|
});
|
|
|
|
if (userId !== excludeUserId && data.pushToken) {
|
|
console.log('Including token for user:', {
|
|
userId,
|
|
excludeUserId
|
|
});
|
|
pushTokens.push(data.pushToken);
|
|
} else {
|
|
console.log('Excluding token for user:', {
|
|
userId,
|
|
excludeUserId
|
|
});
|
|
}
|
|
});
|
|
|
|
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;
|
|
const eventId = context.params.eventId;
|
|
|
|
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: eventId,
|
|
title: title || '',
|
|
timestamp: new Date().toISOString() // Using ISO string instead of serverTimestamp
|
|
}],
|
|
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: eventId,
|
|
title: title || '',
|
|
timestamp: new Date().toISOString() // Using ISO string instead of serverTimestamp
|
|
}]
|
|
});
|
|
}
|
|
});
|
|
|
|
console.log(`[HOUSEHOLD_UPDATE] Event created - Processing timestamp updates`, {
|
|
eventId,
|
|
familyId,
|
|
eventTitle: title || 'Untitled'
|
|
});
|
|
|
|
const householdsSnapshot = await admin.firestore().collection('Households')
|
|
.where('familyId', '==', familyId)
|
|
.get();
|
|
|
|
console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`);
|
|
|
|
const householdBatch = admin.firestore().batch();
|
|
householdsSnapshot.docs.forEach((doc) => {
|
|
console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`);
|
|
householdBatch.update(doc.ref, {
|
|
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp()
|
|
});
|
|
});
|
|
|
|
const batchStartTime = Date.now();
|
|
await householdBatch.commit();
|
|
console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, {
|
|
familyId,
|
|
householdsUpdated: householdsSnapshot.size,
|
|
eventId
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Error in event creation handler:', 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;
|
|
|
|
console.log('Processing batch:', {
|
|
batchId: doc.id,
|
|
creatorId,
|
|
familyId
|
|
});
|
|
|
|
try {
|
|
const pushTokens = await getPushTokensForFamily(
|
|
familyId,
|
|
creatorId
|
|
);
|
|
|
|
// Add logging to see what tokens are returned
|
|
console.log('Push tokens retrieved:', {
|
|
batchId: doc.id,
|
|
tokenCount: pushTokens.length,
|
|
tokens: pushTokens
|
|
});
|
|
|
|
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,
|
|
eventId: events.length === 1 ? events[0].id : undefined
|
|
});
|
|
}
|
|
|
|
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 oneYearAgo = new Date(baseDate);
|
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
const oneYearAhead = new Date(baseDate);
|
|
oneYearAhead.setFullYear(oneYearAhead.getFullYear() + 1);
|
|
|
|
let totalEvents = 0;
|
|
let pageToken = null;
|
|
const batchSize = 250;
|
|
|
|
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", oneYearAgo.toISOString());
|
|
url.searchParams.set("timeMax", oneYearAhead.toISOString());
|
|
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;
|
|
|
|
await db.collection("Profiles").doc(creatorId).update({
|
|
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
|
|
});
|
|
|
|
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",
|
|
private: item.visibility === "private" || item.visibility === "confidential",
|
|
};
|
|
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,sensitivity',
|
|
$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",
|
|
private: item.sensitivity === "private" || item.sensitivity === "confidential",
|
|
};
|
|
});
|
|
|
|
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.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, {
|
|
lastSyncTimestamp: 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;
|
|
}
|
|
});
|
|
|
|
async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) {
|
|
try {
|
|
console.log('[GOOGLE_SYNC] Starting to sync event to Google Calendar', {
|
|
eventId: event.id,
|
|
creatorId
|
|
});
|
|
|
|
let token = accessToken;
|
|
|
|
// Construct the Google Calendar event
|
|
const googleEvent = {
|
|
summary: event.title,
|
|
start: {
|
|
dateTime: event.allDay ? undefined : event.startDate.toISOString(),
|
|
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined
|
|
},
|
|
end: {
|
|
dateTime: event.allDay ? undefined : event.endDate.toISOString(),
|
|
date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined
|
|
},
|
|
visibility: event.private ? 'private' : 'default',
|
|
id: event.id
|
|
};
|
|
|
|
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`;
|
|
|
|
let response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
});
|
|
|
|
// Handle token refresh if needed
|
|
if (response.status === 401 && refreshToken) {
|
|
console.log('[GOOGLE_SYNC] Token expired, refreshing...');
|
|
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
|
token = refreshedGoogleToken;
|
|
|
|
// Update the token in Firestore
|
|
await db.collection("Profiles").doc(creatorId).update({
|
|
[`googleAccounts.${event.email}.accessToken`]: refreshedGoogleToken
|
|
});
|
|
|
|
// Retry with new token
|
|
response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${refreshedGoogleToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Failed to sync event: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
console.log('[GOOGLE_SYNC] Successfully synced event to Google Calendar', {
|
|
eventId: event.id,
|
|
creatorId
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function deleteEventFromGoogle(eventId, accessToken, refreshToken, creatorId, email) {
|
|
try {
|
|
console.log('[GOOGLE_DELETE] Starting to delete event from Google Calendar', {
|
|
eventId,
|
|
creatorId
|
|
});
|
|
|
|
let token = accessToken;
|
|
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
|
|
|
|
let response = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
// Handle token refresh if needed
|
|
if (response.status === 401 && refreshToken) {
|
|
console.log('[GOOGLE_DELETE] Token expired, refreshing...');
|
|
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
|
token = refreshedGoogleToken;
|
|
|
|
// Update the token in Firestore
|
|
await db.collection("Profiles").doc(creatorId).update({
|
|
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
|
|
});
|
|
|
|
// Retry with new token
|
|
response = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${refreshedGoogleToken}`
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Failed to delete event: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', {
|
|
eventId,
|
|
creatorId
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Cloud Function to handle event updates
|
|
exports.syncEventToGoogleCalendar = functions.firestore
|
|
.document('Events/{eventId}')
|
|
.onWrite(async (change, context) => {
|
|
const eventId = context.params.eventId;
|
|
const afterData = change.after.exists ? change.after.data() : null;
|
|
const beforeData = change.before.exists ? change.before.data() : null;
|
|
|
|
// Skip if this is a Google-originated event
|
|
if (afterData?.externalOrigin === 'google' || beforeData?.externalOrigin === 'google') {
|
|
console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event', { eventId });
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Handle deletion
|
|
if (!afterData && beforeData) {
|
|
console.log('[GOOGLE_SYNC] Processing event deletion', { eventId });
|
|
|
|
// Only proceed if this was previously synced with Google
|
|
if (!beforeData.email) {
|
|
return null;
|
|
}
|
|
|
|
const creatorDoc = await db.collection('Profiles').doc(beforeData.creatorId).get();
|
|
const creatorData = creatorDoc.data();
|
|
|
|
if (!creatorData?.googleAccounts?.[beforeData.email]) {
|
|
return null;
|
|
}
|
|
|
|
const accountData = creatorData.googleAccounts[beforeData.email];
|
|
|
|
await deleteEventFromGoogle(
|
|
eventId,
|
|
accountData.accessToken,
|
|
accountData.refreshToken,
|
|
beforeData.creatorId,
|
|
beforeData.email
|
|
);
|
|
|
|
return null;
|
|
}
|
|
|
|
// Handle creation or update
|
|
if (afterData) {
|
|
// Skip if no creator or email is set
|
|
if (!afterData.creatorId || !afterData.email) {
|
|
return null;
|
|
}
|
|
|
|
const creatorDoc = await db.collection('Profiles').doc(afterData.creatorId).get();
|
|
const creatorData = creatorDoc.data();
|
|
|
|
if (!creatorData?.googleAccounts?.[afterData.email]) {
|
|
return null;
|
|
}
|
|
|
|
const accountData = creatorData.googleAccounts[afterData.email];
|
|
|
|
await syncEventToGoogle(
|
|
afterData,
|
|
accountData.accessToken,
|
|
accountData.refreshToken,
|
|
afterData.creatorId
|
|
);
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error('[GOOGLE_SYNC] Error in sync function:', error);
|
|
|
|
// Store the error for later retry or monitoring
|
|
await db.collection('SyncErrors').add({
|
|
eventId,
|
|
error: error.message,
|
|
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
|
type: 'google'
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
}););
|
|
|
|
let token = accessToken;
|
|
|
|
// Construct the Google Calendar event
|
|
const googleEvent = {
|
|
summary: event.title,
|
|
start: {
|
|
dateTime: event.allDay ? undefined : event.startDate.toISOString(),
|
|
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined
|
|
},
|
|
end: {
|
|
dateTime: event.allDay ? undefined : event.endDate.toISOString(),
|
|
date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined
|
|
},
|
|
visibility: event.private ? 'private' : 'default',
|
|
id: event.id
|
|
};
|
|
|
|
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`;
|
|
|
|
let response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
});
|
|
|
|
// Handle token refresh if needed
|
|
if (response.status === 401 && refreshToken) {
|
|
console.log('[GOOGLE_SYNC] Token expired, refreshing...');
|
|
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
|
token = refreshedGoogleToken;
|
|
|
|
// Update the token in Firestore
|
|
await db.collection("Profiles").doc(creatorId).update({
|
|
[`googleAccounts.${event.email}.accessToken`]: refreshedGoogleToken
|
|
});
|
|
|
|
// Retry with new token
|
|
response = await fetch(url, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${refreshedGoogleToken}`,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(googleEvent)
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Failed to sync event: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
console.log('[GOOGLE_SYNC] Successfully synced event to Google Calendar', {
|
|
eventId: event.id,
|
|
creatorId
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function deleteEventFromGoogle(eventId, accessToken, refreshToken, creatorId, email) {
|
|
try {
|
|
console.log('[GOOGLE_DELETE] Starting to delete event from Google Calendar', {
|
|
eventId,
|
|
creatorId
|
|
});
|
|
|
|
let token = accessToken;
|
|
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
|
|
|
|
let response = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
// Handle token refresh if needed
|
|
if (response.status === 401 && refreshToken) {
|
|
console.log('[GOOGLE_DELETE] Token expired, refreshing...');
|
|
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
|
token = refreshedGoogleToken;
|
|
|
|
// Update the token in Firestore
|
|
await db.collection("Profiles").doc(creatorId).update({
|
|
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
|
|
});
|
|
|
|
// Retry with new token
|
|
response = await fetch(url, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Authorization': `Bearer ${refreshedGoogleToken}`
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
const errorData = await response.json();
|
|
throw new Error(`Failed to delete event: ${errorData.error?.message || response.statusText}`);
|
|
}
|
|
|
|
console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', {
|
|
eventId,
|
|
creatorId
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
exports.handleEventDelete = functions.firestore
|
|
.document('Events/{eventId}')
|
|
.onDelete(async (snapshot, context) => {
|
|
const deletedEvent = snapshot.data();
|
|
const eventId = context.params.eventId;
|
|
|
|
// Skip if this was a Google-originated event to prevent sync loops
|
|
if (deletedEvent?.externalOrigin === 'google') {
|
|
console.log('[GOOGLE_DELETE] Skipping delete sync for Google-originated event', { eventId });
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Only proceed if this was synced with Google (has an email)
|
|
if (!deletedEvent?.email) {
|
|
console.log('[GOOGLE_DELETE] Event not synced with Google, skipping', { eventId });
|
|
return null;
|
|
}
|
|
|
|
const creatorDoc = await admin.firestore()
|
|
.collection('Profiles')
|
|
.doc(deletedEvent.creatorId)
|
|
.get();
|
|
|
|
if (!creatorDoc.exists) {
|
|
console.log('[GOOGLE_DELETE] Creator profile not found', {
|
|
eventId,
|
|
creatorId: deletedEvent.creatorId
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const creatorData = creatorDoc.data();
|
|
const googleAccount = creatorData?.googleAccounts?.[deletedEvent.email];
|
|
|
|
if (!googleAccount) {
|
|
console.log('[GOOGLE_DELETE] No Google account found for email', {
|
|
eventId,
|
|
email: deletedEvent.email
|
|
});
|
|
return null;
|
|
}
|
|
|
|
await deleteEventFromGoogle(
|
|
eventId,
|
|
googleAccount.accessToken,
|
|
googleAccount.refreshToken,
|
|
deletedEvent.creatorId,
|
|
deletedEvent.email
|
|
);
|
|
|
|
console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', {
|
|
eventId,
|
|
email: deletedEvent.email
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error);
|
|
|
|
// Store the error for monitoring
|
|
await admin.firestore()
|
|
.collection('SyncErrors')
|
|
.add({
|
|
eventId,
|
|
error: error.message,
|
|
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
|
type: 'google_delete'
|
|
});
|
|
|
|
throw error;
|
|
}
|
|
});
|