Notification changes

This commit is contained in:
Milan Paunovic
2024-11-22 03:25:16 +01:00
parent f74a6390a2
commit 06a3a2dc8f
33 changed files with 1961 additions and 1447 deletions

View File

@ -18,23 +18,190 @@ const GOOGLE_CALENDAR_ID = "primary";
const CHANNEL_ID = "cally-family-calendar";
const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification";
async function getPushTokensForFamily(familyId, excludeUserId = null) {
const usersRef = db.collection('Profiles');
const snapshot = await usersRef.where('familyId', '==', familyId).get();
let pushTokens = [];
snapshot.forEach(doc => {
const data = doc.data();
if ((!excludeUserId || data.uid !== excludeUserId) && data.pushToken) {
pushTokens.push(data.pushToken);
}
});
return pushTokens;
}
exports.sendOverviews = functions.pubsub
.schedule('0 20 * * *')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.overviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const tomorrowEnd = new Date(tomorrow);
tomorrowEnd.setHours(23, 59, 59, 999);
const weekEnd = new Date(tomorrow);
weekEnd.setDate(weekEnd.getDate() + 7);
weekEnd.setHours(23, 59, 59, 999);
const [tomorrowEvents, weekEvents] = await Promise.all([
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', tomorrow)
.where('startDate', '<=', tomorrowEnd)
.orderBy('startDate')
.limit(3)
.get(),
admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>', tomorrowEnd)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get()
]);
if (tomorrowEvents.empty && weekEvents.empty) {
continue;
}
let notificationBody = '';
if (!tomorrowEvents.empty) {
notificationBody += 'Tomorrow: ';
const tomorrowTitles = tomorrowEvents.docs.map(doc => doc.data().title);
notificationBody += tomorrowTitles.join(', ');
if (tomorrowEvents.size === 3) notificationBody += ' and more...';
}
if (!weekEvents.empty) {
if (notificationBody) notificationBody += '\n\n';
notificationBody += 'This week: ';
const weekTitles = weekEvents.docs.map(doc => doc.data().title);
notificationBody += weekTitles.join(', ');
if (weekEvents.size === 3) notificationBody += ' and more...';
}
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Family Calendar Overview",
body: notificationBody,
data: {
type: 'calendar_overview',
date: tomorrow.toISOString()
}
});
await storeNotification({
type: 'calendar_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
});
} catch (error) {
console.error(`Error sending overview for family ${familyId}:`, error);
}
}
});
exports.sendWeeklyOverview = functions.pubsub
.schedule('0 20 * * 0')
.onRun(async (context) => {
const familiesSnapshot = await admin.firestore().collection('Families').get();
for (const familyDoc of familiesSnapshot.docs) {
const familyId = familyDoc.id;
const familySettings = familyDoc.data()?.settings || {};
const overviewTime = familySettings.weeklyOverviewTime || '20:00';
const [hours, minutes] = overviewTime.split(':');
const now = new Date();
if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) {
continue;
}
try {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() + 1);
weekStart.setHours(0, 0, 0, 0);
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekEnd.getDate() + 7);
const weekEvents = await admin.firestore()
.collection('Events')
.where('familyId', '==', familyId)
.where('startDate', '>=', weekStart)
.where('startDate', '<=', weekEnd)
.orderBy('startDate')
.limit(3)
.get();
if (weekEvents.empty) continue;
const eventTitles = weekEvents.docs.map(doc => doc.data().title);
const notificationBody = `Next week: ${eventTitles.join(', ')}${weekEvents.size === 3 ? ' and more...' : ''}`;
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Weekly Calendar Overview",
body: notificationBody,
data: {
type: 'weekly_overview',
weekStart: weekStart.toISOString()
}
});
await storeNotification({
type: 'weekly_overview',
familyId,
content: notificationBody,
timestamp: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error sending weekly overview for family ${familyId}:`, error);
}
}
});
exports.sendNotificationOnEventCreation = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const { familyId, creatorId, email, title } = eventData;
if (!!eventData?.externalOrigin) {
console.log('Externally synced event, ignoring.')
return;
}
const {familyId, creatorId, email, title, externalOrigin} = eventData;
if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data');
return;
}
let pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId);
// Get push tokens - exclude creator for manual events, include everyone for synced events
let pushTokens = await getPushTokensForFamily(
familyId,
externalOrigin ? null : creatorId // Only exclude creator for manual events
);
if (!pushTokens.length) {
console.log('No push tokens available for the event.');
@ -48,9 +215,16 @@ exports.sendNotificationOnEventCreation = functions.firestore
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 eventMessage;
if (externalOrigin) {
eventMessage = eventCount === 1
? `Calendar sync completed: "${title}" has been added.`
: `Calendar sync completed: ${eventCount} new events have been added.`;
} else {
eventMessage = eventCount === 1
? `New event "${title}" has been added to the family calendar.`
: `${eventCount} new events have been added to the family calendar.`;
}
let messages = pushTokens.map(pushToken => {
if (!Expo.isExpoPushToken(pushToken)) {
@ -61,9 +235,12 @@ exports.sendNotificationOnEventCreation = functions.firestore
return {
to: pushToken,
sound: 'default',
title: 'New Events Added!',
title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events',
body: eventMessage,
data: { eventId: context.params.eventId },
data: {
eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual'
},
};
}).filter(Boolean);
@ -90,13 +267,15 @@ exports.sendNotificationOnEventCreation = functions.firestore
}
}
// Save the notification in Firestore for record-keeping
// Save the notification in Firestore
const notificationData = {
creatorId,
familyId,
content: eventMessage,
eventId: context.params.eventId,
type: externalOrigin ? 'sync' : 'manual',
timestamp: Timestamp.now(),
date: eventData.startDate
};
try {
@ -106,15 +285,159 @@ exports.sendNotificationOnEventCreation = functions.firestore
console.error("Error saving notification to Firestore:", error);
}
// Reset state variables after notifications are sent
// Reset state variables
eventCount = 0;
pushTokens = [];
notificationInProgress = false;
}, 5000);
}
});
exports.onEventUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const beforeData = change.before.data();
const afterData = change.after.data();
const {familyId, title, lastModifiedBy} = afterData;
// Skip if no meaningful changes
if (JSON.stringify(beforeData) === JSON.stringify(afterData)) {
return null;
}
try {
// Get push tokens excluding the user who made the change
const pushTokens = await getPushTokensForFamily(familyId, lastModifiedBy);
const message = `Event "${title}" has been updated`;
await sendNotifications(pushTokens, {
title: "Event Updated",
body: message,
data: {
type: 'event_update',
eventId: context.params.eventId
}
});
// Store notification in Firestore
await storeNotification({
type: 'event_update',
familyId,
content: message,
eventId: context.params.eventId,
excludedUser: lastModifiedBy,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
date: eventData.startDate
});
} catch (error) {
console.error('Error sending event update notification:', error);
}
});
// Upcoming Event Reminders
exports.checkUpcomingEvents = functions.pubsub
.schedule('every 5 minutes')
.onRun(async (context) => {
const now = admin.firestore.Timestamp.now();
const eventsSnapshot = await admin.firestore().collection('Events').get();
for (const doc of eventsSnapshot.docs) {
const event = doc.data();
const {startDate, familyId, title, allDay, creatorId} = event;
if (startDate.toDate() < now.toDate()) continue;
try {
const familyDoc = await admin.firestore().collection('Families').doc(familyId).get();
const familySettings = familyDoc.data()?.settings || {};
const reminderTime = familySettings.defaultReminderTime || 15; // minutes
const eventTime = startDate.toDate();
const reminderThreshold = new Date(now.toDate().getTime() + (reminderTime * 60 * 1000));
// For all-day events, send reminder the evening before
if (allDay) {
const eveningBefore = new Date(eventTime);
eveningBefore.setDate(eveningBefore.getDate() - 1);
eveningBefore.setHours(20, 0, 0, 0);
if (now.toDate() >= eveningBefore && !event.eveningReminderSent) {
// Get all family members' tokens (including creator for reminders)
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Tomorrow's All-Day Event",
body: `Tomorrow: ${title}`,
data: {
type: 'event_reminder',
eventId: doc.id
}
});
await doc.ref.update({eveningReminderSent: true});
}
}
// For regular events, check if within reminder threshold
else if (eventTime <= reminderThreshold && !event.reminderSent) {
// Include creator for reminders
const pushTokens = await getPushTokensForFamily(familyId);
await sendNotifications(pushTokens, {
title: "Upcoming Event",
body: `In ${reminderTime} minutes: ${title}`,
data: {
type: 'event_reminder',
eventId: doc.id
}
});
await doc.ref.update({reminderSent: true});
}
} catch (error) {
console.error(`Error processing reminder for event ${doc.id}:`, error);
}
}
});
async function storeNotification(notificationData) {
try {
await admin.firestore()
.collection('Notifications')
.add(notificationData);
} catch (error) {
console.error('Error storing notification:', error);
}
}
async function sendNotifications(pushTokens, notification) {
if (!pushTokens.length) return;
const messages = pushTokens
.filter(token => Expo.isExpoPushToken(token))
.map(pushToken => ({
to: pushToken,
sound: 'default',
priority: 'high',
...notification
}));
const chunks = expo.chunkPushNotifications(messages);
for (let chunk of chunks) {
try {
const tickets = await expo.sendPushNotificationsAsync(chunk);
for (let ticket of tickets) {
if (ticket.status === "error") {
if (ticket.details?.error === "DeviceNotRegistered") {
await removeInvalidPushToken(ticket.to);
}
console.error('Push notification error:', ticket.message);
}
}
} catch (error) {
console.error('Error sending notifications:', error);
}
}
}
exports.createSubUser = onRequest(async (request, response) => {
const authHeader = request.get('Authorization');
@ -209,7 +532,7 @@ exports.removeSubUser = onRequest(async (request, response) => {
logger.info("Processing user removal", {requestBody: request.body.data});
const { userId, familyId } = request.body.data;
const {userId, familyId} = request.body.data;
if (!userId || !familyId) {
logger.warn("Missing required fields in request body", {requestBody: request.body.data});
@ -397,7 +720,22 @@ async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) {
}
async function removeInvalidPushToken(pushToken) {
// TODO
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");
@ -667,7 +1005,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
}
});
async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId }) {
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();
@ -700,7 +1038,7 @@ async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId,
token = refreshedToken;
if (token) {
return fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId });
return fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId});
} else {
console.error(`Failed to refresh token for user: ${email}`);
await clearToken(email);
@ -717,8 +1055,12 @@ async function fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId,
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),
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,
@ -743,12 +1085,12 @@ 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 });
batch.set(eventRef, event, {merge: true});
});
await batch.commit();
}
async function calendarSync({ userId, email, token, refreshToken, familyId }) {
async function calendarSync({userId, email, token, refreshToken, familyId}) {
console.log(`Starting calendar sync for user ${userId} with email ${email}`);
try {
await fetchAndSaveGoogleEvents({
@ -787,7 +1129,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
return;
}
const { googleAccounts } = userData;
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0];
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
@ -795,7 +1137,7 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({ userId, email, token, refreshToken, familyId });
await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
res.status(200).send("Sync notification sent.");
@ -803,4 +1145,202 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
res.status(500).send("Failed to send sync notification.");
}
});
async function refreshMicrosoftToken(refreshToken) {
try {
console.log("Refreshing Microsoft token...");
const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: "13c79071-1066-40a9-9f71-b8c4b138b4af",
scope: "openid profile email offline_access Calendars.ReadWrite User.Read",
refresh_token: refreshToken,
grant_type: 'refresh_token'
})
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error refreshing Microsoft token:", errorData);
throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`);
}
const data = await response.json();
console.log("Microsoft token refreshed successfully");
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken
};
} catch (error) {
console.error("Error refreshing Microsoft token:", error.message);
throw error;
}
}
async function fetchAndSaveMicrosoftEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
try {
console.log(`Fetching Microsoft calendar events for user: ${email}`);
const url = `https://graph.microsoft.com/v1.0/me/calendar/events`;
const queryParams = new URLSearchParams({
$select: 'subject,start,end,id',
$filter: `start/dateTime ge '${timeMin}' and end/dateTime le '${timeMax}'`
});
const response = await fetch(`${url}?${queryParams}`, {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const {accessToken: newToken} = await refreshMicrosoftToken(refreshToken);
if (newToken) {
return fetchAndSaveMicrosoftEvents({
token: newToken,
refreshToken,
email,
familyId,
creatorId
});
}
}
if (!response.ok) {
throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`);
}
const events = data.value.map(item => ({
id: item.id,
title: item.subject || "",
startDate: new Date(item.start.dateTime + 'Z'),
endDate: new Date(item.end.dateTime + 'Z'),
allDay: false, // Microsoft Graph API handles all-day events differently
familyId,
email,
creatorId,
externalOrigin: "microsoft"
}));
console.log(`Saving Microsoft calendar events to Firestore for user: ${email}`);
await saveEventsToFirestore(events);
} catch (error) {
console.error(`Error fetching Microsoft Calendar events for ${email}:`, error);
throw error;
}
}
async function subscribeMicrosoftCalendar(accessToken, userId) {
try {
console.log(`Setting up Microsoft calendar subscription for user ${userId}`);
const subscription = {
changeType: "created,updated,deleted",
notificationUrl: `https://us-central1-cally-family-calendar.cloudfunctions.net/microsoftCalendarWebhook?userId=${userId}`,
resource: "/me/calendar/events",
expirationDateTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days
clientState: userId
};
const response = await fetch('https://graph.microsoft.com/v1.0/subscriptions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
});
if (!response.ok) {
throw new Error(`Failed to create subscription: ${response.statusText}`);
}
const subscriptionData = await response.json();
// Store subscription details in Firestore
await admin.firestore().collection('MicrosoftSubscriptions').doc(userId).set({
subscriptionId: subscriptionData.id,
expirationDateTime: subscriptionData.expirationDateTime,
createdAt: admin.firestore.FieldValue.serverTimestamp()
});
console.log(`Microsoft calendar subscription created for user ${userId}`);
return subscriptionData;
} catch (error) {
console.error(`Error creating Microsoft calendar subscription for user ${userId}:`, error);
throw error;
}
}
exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
console.log(`Received Microsoft calendar webhook for user ${userId}`, req.body);
try {
const userDoc = await admin.firestore().collection("Profiles").doc(userId).get();
const userData = userDoc.data();
if (!userData?.microsoftAccounts) {
console.log(`No Microsoft account found for user ${userId}`);
return res.status(200).send();
}
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
await fetchAndSaveMicrosoftEvents({
token,
email,
familyId: userData.familyId,
creatorId: userId
});
res.status(200).send();
} catch (error) {
console.error(`Error processing Microsoft calendar webhook for user ${userId}:`, error);
res.status(500).send();
}
});
exports.renewMicrosoftSubscriptions = functions.pubsub.schedule('every 24 hours').onRun(async (context) => {
console.log('Starting Microsoft subscription renewal process');
try {
const subscriptionsSnapshot = await admin.firestore()
.collection('MicrosoftSubscriptions')
.get();
for (const doc of subscriptionsSnapshot.docs) {
const userId = doc.id;
const userDoc = await admin.firestore().collection('Profiles').doc(userId).get();
const userData = userDoc.data();
if (userData?.microsoftAccounts) {
const email = Object.keys(userData.microsoftAccounts)[0];
const token = userData.microsoftAccounts[email];
try {
await subscribeMicrosoftCalendar(token, userId);
console.log(`Renewed Microsoft subscription for user ${userId}`);
} catch (error) {
console.error(`Failed to renew Microsoft subscription for user ${userId}:`, error);
}
}
}
} catch (error) {
console.error('Error in Microsoft subscription renewal process:', error);
}
});