Files
cally/firebase/functions/index.js
2024-11-04 01:01:59 +01:00

719 lines
28 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";
exports.sendNotificationOnEventCreation = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const { familyId, creatorId, email, title } = eventData;
if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data');
return;
}
let pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId);
if (!pushTokens.length) {
console.log('No push tokens available for the event.');
return;
}
eventCount++;
// Only set up the notification timeout if it's not already in progress
if (!notificationInProgress) {
notificationInProgress = true;
notificationTimeout = setTimeout(async () => {
const eventMessage = eventCount === 1
? `An event "${title}" has been added. Check it out!`
: `${eventCount} new events have been added.`;
let messages = pushTokens.map(pushToken => {
if (!Expo.isExpoPushToken(pushToken)) {
console.error(`Push token ${pushToken} is not a valid Expo push token`);
return null;
}
return {
to: pushToken,
sound: 'default',
title: 'New Events Added!',
body: eventMessage,
data: { eventId: context.params.eventId },
};
}).filter(Boolean);
let chunks = expo.chunkPushNotifications(messages);
let tickets = [];
for (let chunk of chunks) {
try {
let ticketChunk = await expo.sendPushNotificationsAsync(chunk);
tickets.push(...ticketChunk);
for (let ticket of ticketChunk) {
if (ticket.status === 'ok') {
console.log('Notification successfully sent:', ticket.id);
} else if (ticket.status === 'error') {
console.error(`Notification error: ${ticket.message}`);
if (ticket.details?.error === 'DeviceNotRegistered') {
await removeInvalidPushToken(ticket.to);
}
}
}
} catch (error) {
console.error('Error sending notification:', error);
}
}
// Save the notification in Firestore for record-keeping
const notificationData = {
creatorId,
familyId,
content: eventMessage,
eventId: context.params.eventId,
timestamp: Timestamp.now(),
};
try {
await db.collection("Notifications").add(notificationData);
console.log("Notification stored in Firestore:", notificationData);
} catch (error) {
console.error("Error saving notification to Firestore:", error);
}
// Reset state variables after notifications are sent
eventCount = 0;
pushTokens = [];
notificationInProgress = false;
}, 5000);
}
});
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.generateCustomToken = onRequest(async (request, response) => {
try {
const {userId} = request.body.data;
console.log("Generating custom token for userId", {userId});
if (!userId) {
response.status(400).json({error: 'Missing userId'});
return;
}
const customToken = await getAuth().createCustomToken(userId);
response.status(200).json({data: {token: customToken}});
} catch (error) {
console.error("Error generating custom token", {error: error.message});
response.status(500).json({error: "Failed to generate custom token"});
}
});
exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(async (context) => {
console.log('Running token refresh job...');
const profilesSnapshot = await db.collection('Profiles').get();
profilesSnapshot.forEach(async (profileDoc) => {
const profileData = profileDoc.data();
if (profileData.googleAccounts) {
try {
for (const googleEmail of Object.keys(profileData?.googleAccounts)) {
const googleToken = profileData?.googleAccounts?.[googleEmail]?.refreshToken;
if (googleToken) {
const {refreshedGoogleToken, refreshedRefreshToken} = await refreshGoogleToken(googleToken);
const updatedGoogleAccounts = {
...profileData.googleAccounts,
[googleEmail]: {accessToken: refreshedGoogleToken, refreshToken: refreshedRefreshToken}
};
await profileDoc.ref.update({googleAccounts: updatedGoogleAccounts});
console.log(`Google token updated for user ${profileDoc.id}`);
}
}
} catch (error) {
console.error(`Error refreshing Google token for user ${profileDoc.id}:`, error.message);
}
}
if (profileData.microsoftAccounts) {
try {
for (const microsoftEmail of Object.keys(profileData?.microsoftAccounts)) {
const microsoftToken = profileData?.microsoftAccounts?.[microsoftEmail];
if (microsoftToken) {
const refreshedMicrosoftToken = await refreshMicrosoftToken(microsoftToken);
const updatedMicrosoftAccounts = {
...profileData.microsoftAccounts,
[microsoftEmail]: refreshedMicrosoftToken
};
await profileDoc.ref.update({microsoftAccounts: updatedMicrosoftAccounts});
console.log(`Microsoft token updated for user ${profileDoc.id}`);
}
}
} catch (error) {
console.error(`Error refreshing Microsoft token for user ${profileDoc.id}:`, error.message);
}
}
if (profileData.appleAccounts) {
try {
for (const appleEmail of Object.keys(profileData?.appleAccounts)) {
const appleToken = profileData?.appleAccounts?.[appleEmail];
const refreshedAppleToken = await refreshAppleToken(appleToken);
const updatedAppleAccounts = {...profileData.appleAccounts, [appleEmail]: refreshedAppleToken};
await profileDoc.ref.update({appleAccunts: updatedAppleAccounts});
console.log(`Apple token updated for user ${profileDoc.id}`);
}
} catch (error) {
console.error(`Error refreshing Apple token for user ${profileDoc.id}:`, error.message);
}
}
});
return null;
});
async function refreshMicrosoftToken(refreshToken) {
try {
const response = await axios.post('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af", // Client ID from microsoftConfig
scope: "openid profile email offline_access Calendars.ReadWrite User.Read", // Scope from microsoftConfig
});
return response.data.access_token; // Return the new access token
} catch (error) {
console.error("Error refreshing Microsoft token:", error);
throw error;
}
}
async function getPushTokensForEvent() {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
if (data.pushToken) {
pushTokens.push(data.pushToken);
}
});
console.log('Push Tokens:', pushTokens);
return pushTokens;
}
async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.where('familyId', '==', familyId).get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
// Exclude the creator
if (data.uid !== creatorId && data.pushToken) {
pushTokens.push(data.pushToken);
}
});
return pushTokens;
}
async function removeInvalidPushToken(pushToken) {
// TODO
}
const fetch = require("node-fetch");
// Function to refresh Google Token with additional logging
async function refreshGoogleToken(refreshToken) {
try {
console.log("Refreshing Google token...");
const response = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error refreshing Google token:", errorData);
throw new Error(`Failed to refresh Google token: ${errorData.error || response.statusText}`);
}
const data = await response.json();
console.log("Google token refreshed successfully");
// Return both the access token and refresh token (if a new one is provided)
return {
refreshedGoogleToken: data.access_token,
refreshedRefreshToken: data.refresh_token || refreshToken, // Return the existing refresh token if a new one is not provided
};
} catch (error) {
console.error("Error refreshing Google token:", error.message);
throw error;
}
}
// Helper function to get Google access tokens for all users and refresh them if needed with logging
async function getGoogleAccessTokens() {
console.log("Fetching Google access tokens for all users...");
const tokens = {};
const profilesSnapshot = await db.collection("Profiles").get();
await Promise.all(
profilesSnapshot.docs.map(async (doc) => {
const profileData = doc.data();
const googleAccounts = profileData?.googleAccounts || {};
for (const googleEmail of Object.keys(googleAccounts)) {
// Check if the googleAccount entry exists and has a refreshToken
const accountInfo = googleAccounts[googleEmail];
const refreshToken = accountInfo?.refreshToken;
if (refreshToken) {
try {
console.log(`Refreshing token for user ${doc.id} (email: ${googleEmail})`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
tokens[doc.id] = accessToken;
console.log(`Token refreshed successfully for user ${doc.id}`);
} catch (error) {
tokens[doc.id] = accountInfo?.accessToken;
console.error(`Failed to refresh token for user ${doc.id}:`, error.message);
}
} else {
console.log(`No refresh token available for user ${doc.id} (email: ${googleEmail})`);
}
}
})
);
console.log("Access tokens fetched and refreshed as needed");
return tokens;
}
// Function to watch Google Calendar events with additional logging
const watchCalendarEvents = async (userId, token) => {
const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`;
// Verify the token is valid
console.log(`Attempting to watch calendar for user ${userId}`);
console.log(`Token being used: ${token ? 'present' : 'missing'}`);
console.log(`Calendar ID: ${GOOGLE_CALENDAR_ID}`);
console.log(`Webhook URL: ${WEBHOOK_URL}?userId=${userId}`);
try {
// Test the token first
const testResponse = await fetch(
`https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events?maxResults=1`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!testResponse.ok) {
console.error(`Token validation failed for user ${userId}:`, await testResponse.text());
throw new Error('Token validation failed');
}
console.log(`Token validated successfully for user ${userId}`);
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
id: `${CHANNEL_ID}-${userId}`,
type: "web_hook",
address: `${WEBHOOK_URL}?userId=${userId}`,
params: {
ttl: "80000",
},
}),
});
const responseText = await response.text();
console.log(`Watch response for user ${userId}:`, responseText);
if (!response.ok) {
console.error(`Failed to watch calendar for user ${userId}:`, responseText);
throw new Error(`Failed to watch calendar: ${responseText}`);
}
const result = JSON.parse(responseText);
console.log(`Successfully set up Google Calendar watch for user ${userId}`, result);
// Store the watch details in Firestore for monitoring
await db.collection('CalendarWatches').doc(userId).set({
watchId: result.id,
resourceId: result.resourceId,
expiration: result.expiration,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
});
return result;
} catch (error) {
console.error(`Error in watchCalendarEvents for user ${userId}:`, error);
// Store the error in Firestore for monitoring
await db.collection('CalendarWatchErrors').add({
userId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
throw error;
}
};
// Add this to test webhook connectivity
exports.testWebhook = functions.https.onRequest(async (req, res) => {
console.log('Test webhook received');
console.log('Headers:', req.headers);
console.log('Body:', req.body);
console.log('Query:', req.query);
res.status(200).send('Test webhook received successfully');
});
// Schedule function to renew Google Calendar watch every 20 hours for each user with logging
exports.renewGoogleCalendarWatch = functions.pubsub.schedule("every 10 minutes").onRun(async (context) => {
console.log("Starting Google Calendar watch renewal process...");
try {
const tokens = await getGoogleAccessTokens();
console.log("Tokens: ", tokens);
for (const [userId, token] of Object.entries(tokens)) {
try {
await watchCalendarEvents(userId, token);
} catch (error) {
console.error(`Error renewing Google Calendar watch for user ${userId}:`, error.message);
}
}
console.log("Google Calendar watch renewal process completed");
} catch (error) {
console.error("Error in renewGoogleCalendarWatch function:", error.message);
}
});
// Function to handle notifications from Google Calendar with additional logging
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId; // Extract userId from query params
const calendarId = req.body.resourceId;
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
try {
// Fetch user profile data for the specific user
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
// Ensure pushTokens is an array
let pushTokens = [];
if (userData && userData.pushToken) {
pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken];
}
if (pushTokens.length === 0) {
console.log(`No push tokens found for user ${userId}`);
res.status(200).send("No push tokens found for user.");
return;
}
// Call calendarSync with necessary parameters
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0]; // Assuming the first account is the primary
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
// Prepare and send push notifications after sync
// const syncMessage = "New events have been synced.";
//
// let messages = pushTokens.map(pushToken => {
// if (!Expo.isExpoPushToken(pushToken)) {
// console.error(`Push token ${pushToken} is not a valid Expo push token`);
// return null;
// }
//
// return {
// to: pushToken,
// sound: "default",
// title: "Event Sync",
// body: syncMessage,
// data: { userId, calendarId },
// };
// }).filter(Boolean);
//
// let chunks = expo.chunkPushNotifications(messages);
// let tickets = [];
//
// for (let chunk of chunks) {
// try {
// let ticketChunk = await expo.sendPushNotificationsAsync(chunk);
// tickets.push(...ticketChunk);
//
// for (let ticket of ticketChunk) {
// if (ticket.status === "ok") {
// console.log("Notification successfully sent:", ticket.id);
// } else if (ticket.status === "error") {
// console.error(`Notification error: ${ticket.message}`);
// if (ticket.details?.error === "DeviceNotRegistered") {
// await removeInvalidPushToken(ticket.to);
// }
// }
// }
// } catch (error) {
// console.error("Error sending notification:", error.message);
// }
// }
//
// console.log(`Sync notification sent for user ${userId}`);
res.status(200).send("Sync notification sent.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
res.status(500).send("Failed to send sync notification.");
}
});
async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }) {
const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
let events = [];
let pageToken = null;
try {
console.log(`Fetching events for user: ${email}`);
// Fetch all events from Google Calendar within the specified time range
do {
const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`);
url.searchParams.set("singleEvents", "true");
url.searchParams.set("timeMin", timeMin);
url.searchParams.set("timeMax", timeMax);
if (pageToken) url.searchParams.set("pageToken", pageToken);
const response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await response.json();
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const refreshedToken = await refreshGoogleToken(refreshToken);
token = refreshedToken;
if (token) {
return fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId });
} else {
console.error(`Failed to refresh token for user: ${email}`);
await clearToken(email);
return;
}
}
if (!response.ok) {
throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`);
}
console.log(`Processing events for user: ${email}`);
data.items?.forEach((item) => {
const googleEvent = {
id: item.id,
title: item.summary || "",
startDate: item.start?.dateTime ? new Date(item.start.dateTime) : new Date(item.start.date),
endDate: item.end?.dateTime ? new Date(item.end.dateTime) : new Date(item.end.date),
allDay: !item.start?.dateTime,
familyId,
email,
creatorId, // Add creatorId to each event
};
events.push(googleEvent);
console.log(`Processed event: ${JSON.stringify(googleEvent)}`);
});
pageToken = data.nextPageToken;
} while (pageToken);
console.log(`Saving events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
} catch (error) {
console.error(`Error fetching Google Calendar events for ${email}:`, error);
}
}
async function saveEventsToFirestore(events) {
const batch = db.batch();
events.forEach((event) => {
const eventRef = db.collection("Events").doc(event.id);
batch.set(eventRef, event, { merge: true });
});
await batch.commit();
}
async function calendarSync({ userId, email, token, refreshToken, familyId }) {
console.log(`Starting calendar sync for user ${userId} with email ${email}`);
try {
await fetchAndSaveGoogleEvents({
token,
refreshToken,
email,
familyId,
creatorId: userId,
});
console.log("Calendar events synced successfully.");
} catch (error) {
console.error(`Error syncing calendar for user ${userId}:`, error);
throw error;
}
console.log(`Finished calendar sync for user ${userId}`);
}
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
const calendarId = req.body.resourceId;
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
try {
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
let pushTokens = [];
if (userData && userData.pushToken) {
pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken];
}
if (pushTokens.length === 0) {
console.log(`No push tokens found for user ${userId}`);
res.status(200).send("No push tokens found for user.");
return;
}
const { googleAccounts } = userData;
const email = Object.keys(googleAccounts || {})[0];
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({ userId, email, token, refreshToken, familyId });
console.log("Calendar sync completed.");
res.status(200).send("Sync notification sent.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
res.status(500).send("Failed to send sync notification.");
}
});