mirror of
https://github.com/urosran/cally.git
synced 2025-07-10 07:07:16 +00:00
1288 lines
56 KiB
JavaScript
1288 lines
56 KiB
JavaScript
const { onRequest } = require("firebase-functions/v2/https");
|
||
const { getAuth } = require("firebase-admin/auth");
|
||
const { getFirestore, Timestamp, FieldValue, FieldPath } = require("firebase-admin/firestore");
|
||
const logger = require("firebase-functions/logger");
|
||
const functions = require("firebase-functions");
|
||
const admin = require("firebase-admin");
|
||
const fetch = require("node-fetch");
|
||
const { Expo } = require("expo-server-sdk");
|
||
|
||
admin.initializeApp();
|
||
const db = admin.firestore();
|
||
|
||
let expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
|
||
|
||
const GOOGLE_CALENDAR_ID = "primary";
|
||
const CHANNEL_ID = "cally-family-calendar";
|
||
const WEBHOOK_URL = "https://us-central1-cally-family-calendar.cloudfunctions.net/sendSyncNotification";
|
||
|
||
/* ─────────────────────────────────
|
||
PUSH NOTIFICATION HELPERS
|
||
───────────────────────────────── */
|
||
async function getPushTokensForFamily(familyId, excludeUserId = null) {
|
||
const snapshot = await db.collection("Profiles")
|
||
.where("familyId", "==", familyId)
|
||
.get();
|
||
|
||
let pushTokens = [];
|
||
logger.info("Getting push tokens", { familyId, excludeUserId });
|
||
snapshot.forEach(doc => {
|
||
const data = doc.data();
|
||
const userId = doc.id;
|
||
logger.debug("Processing user", { docId: userId, hasToken: !!data.pushToken, excluded: userId === excludeUserId });
|
||
if (userId !== excludeUserId && data.pushToken) {
|
||
logger.info("Including token for user", { userId, excludeUserId });
|
||
pushTokens.push(data.pushToken);
|
||
} else {
|
||
logger.debug("Excluding token for user", { userId, excludeUserId });
|
||
}
|
||
});
|
||
return pushTokens;
|
||
}
|
||
|
||
async function removeInvalidPushToken(pushToken) {
|
||
try {
|
||
const snapshot = await db.collection("Profiles")
|
||
.where("pushToken", "==", pushToken)
|
||
.get();
|
||
const batch = db.batch();
|
||
snapshot.forEach(doc => batch.update(doc.ref, { pushToken: FieldValue.delete() }));
|
||
await batch.commit();
|
||
logger.info(`Removed invalid push token: ${pushToken}`);
|
||
} catch (error) {
|
||
logger.error("Error removing invalid push token", error);
|
||
}
|
||
}
|
||
|
||
async function sendNotifications(pushTokens, notification) {
|
||
if (!pushTokens.length) return;
|
||
const messages = pushTokens
|
||
.filter(token => Expo.isExpoPushToken(token))
|
||
.map(token => ({
|
||
to: token,
|
||
sound: "default",
|
||
priority: "high",
|
||
...notification,
|
||
}));
|
||
const chunks = expo.chunkPushNotifications(messages);
|
||
for (let chunk of chunks) {
|
||
try {
|
||
const tickets = await expo.sendPushNotificationsAsync(chunk);
|
||
tickets.forEach(ticket => {
|
||
if (ticket.status === "error") {
|
||
if (ticket.details?.error === "DeviceNotRegistered") {
|
||
removeInvalidPushToken(ticket.to);
|
||
}
|
||
logger.error("Push notification error", ticket.message);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
logger.error("Error sending notifications", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function storeNotification(notificationData) {
|
||
try {
|
||
await db.collection("Notifications").add(notificationData);
|
||
} catch (error) {
|
||
logger.error("Error storing notification", error);
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────
|
||
TOKEN REFRESH HELPERS
|
||
───────────────────────────────── */
|
||
async function refreshGoogleToken(refreshToken) {
|
||
try {
|
||
logger.info("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();
|
||
logger.error("Error refreshing Google token", errorData);
|
||
throw new Error(`Failed to refresh Google token: ${errorData.error || response.statusText}`);
|
||
}
|
||
const data = await response.json();
|
||
logger.info("Google token refreshed successfully");
|
||
return {
|
||
refreshedGoogleToken: data.access_token,
|
||
refreshedRefreshToken: data.refresh_token || refreshToken,
|
||
};
|
||
} catch (error) {
|
||
logger.error("Error refreshing Google token", error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function refreshMicrosoftToken(refreshToken) {
|
||
try {
|
||
logger.info("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();
|
||
logger.error("Error refreshing Microsoft token", errorData);
|
||
throw new Error(`Failed to refresh Microsoft token: ${errorData.error || response.statusText}`);
|
||
}
|
||
const data = await response.json();
|
||
logger.info("Microsoft token refreshed successfully");
|
||
return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken };
|
||
} catch (error) {
|
||
logger.error("Error refreshing Microsoft token", error.message);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────
|
||
GOOGLE EVENT SYNC / STORAGE HELPERS
|
||
───────────────────────────────── */
|
||
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 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, pageToken = null, batchSize = 250;
|
||
try {
|
||
logger.info(`[FETCH] Starting event fetch for user: ${email}`);
|
||
do {
|
||
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);
|
||
let response = await fetch(url.toString(), {
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
});
|
||
if (response.status === 401 && refreshToken) {
|
||
logger.info(`[TOKEN] Token expired during fetch, refreshing for ${email}`);
|
||
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
||
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}`);
|
||
}
|
||
const events = (data.items || []).map(item => ({
|
||
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",
|
||
}));
|
||
if (events.length > 0) {
|
||
logger.info(`[FETCH] Saving batch of ${events.length} events`);
|
||
await saveEventsToFirestore(events);
|
||
totalEvents += events.length;
|
||
}
|
||
pageToken = data.nextPageToken;
|
||
} while (pageToken);
|
||
logger.info(`[FETCH] Completed with ${totalEvents} total events`);
|
||
return totalEvents;
|
||
} catch (error) {
|
||
logger.error(`[ERROR] Failed fetching events for ${email}`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function calendarSync({ userId, email, token, refreshToken, familyId }) {
|
||
logger.info(`[SYNC] Starting calendar sync for user ${userId} with email ${email}`);
|
||
try {
|
||
if (refreshToken) {
|
||
logger.info(`[TOKEN] Initial token refresh for ${email}`);
|
||
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
|
||
if (refreshedGoogleToken) {
|
||
token = refreshedGoogleToken;
|
||
await db.collection("Profiles").doc(userId).update({
|
||
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken,
|
||
});
|
||
}
|
||
}
|
||
const eventCount = await fetchAndSaveGoogleEvents({ token, refreshToken, email, familyId, creatorId: userId });
|
||
logger.info(`[SYNC] Calendar sync completed. Processed ${eventCount} events`);
|
||
return eventCount;
|
||
} catch (error) {
|
||
logger.error(`[ERROR] Calendar sync failed for user ${userId}`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────
|
||
MICROSOFT EVENT SYNC HELPERS
|
||
───────────────────────────────── */
|
||
async function fetchAndSaveMicrosoftEvents({ token, refreshToken, email, familyId, creatorId }) {
|
||
const baseDate = new Date();
|
||
// For Microsoft, we fetch events from one month ago for two months ahead
|
||
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString();
|
||
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString();
|
||
try {
|
||
logger.info(`Fetching Microsoft 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}'`,
|
||
});
|
||
let response = await fetch(`${url}?${queryParams}`, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
let data = await response.json();
|
||
if (response.status === 401 && refreshToken) {
|
||
logger.info(`Token expired for Microsoft for user: ${email}, refreshing...`);
|
||
const { accessToken: newToken } = await refreshMicrosoftToken(refreshToken);
|
||
token = newToken;
|
||
return await fetchAndSaveMicrosoftEvents({ token, 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",
|
||
};
|
||
});
|
||
logger.info(`Saving ${events.length} Microsoft events for user: ${email}`);
|
||
await saveEventsToFirestore(events);
|
||
return events.length;
|
||
} catch (error) {
|
||
logger.error(`Error fetching Microsoft events for ${email}`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Check Upcoming Events every 5 minutes – send reminders
|
||
exports.checkUpcomingEvents = functions.pubsub
|
||
.schedule("every 5 minutes")
|
||
.onRun(async (context) => {
|
||
const now = Timestamp.now();
|
||
const eventsSnapshot = await db.collection("Events")
|
||
.where("startDate", ">=", now)
|
||
.get();
|
||
await Promise.all(eventsSnapshot.docs.map(async (doc) => {
|
||
const event = doc.data();
|
||
if (!event?.startDate) return;
|
||
const { familyId, title, allDay } = event;
|
||
try {
|
||
const familyDoc = await db.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) {
|
||
logger.error(`Error processing reminder for event ${doc.id}`, error);
|
||
}
|
||
}));
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
MICROSOFT SUBSCRIPTION & WEBHOOK FUNCTIONS
|
||
───────────────────────────────── */
|
||
// Renew Microsoft Subscriptions every 24 hours
|
||
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: FieldValue.serverTimestamp(),
|
||
}, { merge: true });
|
||
} catch (error) {
|
||
logger.error(`Failed to renew Microsoft subscription for ${userId}`, error);
|
||
batch.set(db.collection("MicrosoftSubscriptionErrors").doc(), {
|
||
userId,
|
||
error: error.message,
|
||
timestamp: FieldValue.serverTimestamp(),
|
||
});
|
||
}
|
||
}
|
||
await batch.commit();
|
||
});
|
||
|
||
// Microsoft Calendar Webhook
|
||
exports.microsoftCalendarWebhook = 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 });
|
||
return res.status(200).send();
|
||
} catch (error) {
|
||
logger.error(`Error processing Microsoft webhook for ${userId}`, error);
|
||
return res.status(500).send();
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
CLEANUP TOKEN REFRESH FLAGS
|
||
───────────────────────────────── */
|
||
const tokenRefreshInProgress = new Map();
|
||
exports.cleanupTokenRefreshFlags = functions.pubsub
|
||
.schedule("every 5 minutes")
|
||
.onRun(() => {
|
||
tokenRefreshInProgress.clear();
|
||
logger.info("[CLEANUP] Cleared all token refresh flags");
|
||
return null;
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
FIRESTORE EVENT SYNC FUNCTIONS (Google)
|
||
───────────────────────────────── */
|
||
// Create new event sync (skip if externalOrigin is google)
|
||
exports.syncNewEventToGoogle = functions.firestore
|
||
.document("Events/{eventId}")
|
||
.onCreate(async (snapshot, context) => {
|
||
const newEvent = snapshot.data();
|
||
const eventId = context.params.eventId;
|
||
if (newEvent.externalOrigin === "google") {
|
||
logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId);
|
||
return;
|
||
}
|
||
try {
|
||
const creatorDoc = await db.collection("Profiles").doc(newEvent.creatorId).get();
|
||
const creatorData = creatorDoc.data();
|
||
if (!creatorData?.googleAccounts) {
|
||
logger.info("[GOOGLE_SYNC] Creator has no Google accounts", newEvent.creatorId);
|
||
return;
|
||
}
|
||
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
|
||
if (!accountData?.accessToken) {
|
||
logger.info("[GOOGLE_SYNC] No access token found for creator", newEvent.creatorId);
|
||
return;
|
||
}
|
||
await syncEventToGoogle(
|
||
{
|
||
...newEvent,
|
||
email,
|
||
startDate: new Date(newEvent.startDate.seconds * 1000),
|
||
endDate: new Date(newEvent.endDate.seconds * 1000),
|
||
},
|
||
accountData.accessToken,
|
||
accountData.refreshToken,
|
||
newEvent.creatorId
|
||
);
|
||
logger.info("[GOOGLE_SYNC] Successfully synced new event to Google", eventId);
|
||
} catch (error) {
|
||
logger.error("[GOOGLE_SYNC] Error syncing new event to Google", error);
|
||
}
|
||
});
|
||
|
||
// Update event sync to Google
|
||
exports.syncEventToGoogleOnUpdate = functions.firestore
|
||
.document("Events/{eventId}")
|
||
.onUpdate(async (change, context) => {
|
||
const eventBefore = change.before.data();
|
||
const eventAfter = change.after.data();
|
||
const eventId = context.params.eventId;
|
||
if (eventAfter.externalOrigin === "google") {
|
||
logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId);
|
||
return;
|
||
}
|
||
if (JSON.stringify(eventBefore) === JSON.stringify(eventAfter)) {
|
||
logger.info("[GOOGLE_SYNC] No changes detected for event", eventId);
|
||
return;
|
||
}
|
||
try {
|
||
const creatorDoc = await db.collection("Profiles").doc(eventAfter.creatorId).get();
|
||
const creatorData = creatorDoc.data();
|
||
if (!creatorData?.googleAccounts) {
|
||
logger.info("[GOOGLE_SYNC] Creator has no Google accounts", eventAfter.creatorId);
|
||
return;
|
||
}
|
||
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
|
||
if (!accountData?.accessToken) {
|
||
logger.info("[GOOGLE_SYNC] No access token found for creator", eventAfter.creatorId);
|
||
return;
|
||
}
|
||
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
|
||
const googleEvent = {
|
||
summary: eventAfter.title,
|
||
start: {
|
||
dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.startDate.seconds * 1000).toISOString(),
|
||
date: eventAfter.allDay ? new Date(eventAfter.startDate.seconds * 1000).toISOString().split("T")[0] : undefined,
|
||
timeZone: "UTC",
|
||
},
|
||
end: {
|
||
dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.endDate.seconds * 1000).toISOString(),
|
||
date: eventAfter.allDay ? new Date(new Date(eventAfter.endDate.seconds * 1000).getTime() + 24 * 60 * 60 * 1000).toISOString().split("T")[0] : undefined,
|
||
timeZone: "UTC",
|
||
},
|
||
visibility: eventAfter.private ? "private" : "default",
|
||
status: "confirmed",
|
||
reminders: { useDefault: true },
|
||
};
|
||
let response = await fetch(url, {
|
||
method: "PATCH",
|
||
headers: {
|
||
Authorization: `Bearer ${accountData.accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(googleEvent),
|
||
});
|
||
if (response.status === 401 && accountData.refreshToken) {
|
||
logger.info("[GOOGLE_SYNC] Token expired, refreshing...");
|
||
const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken);
|
||
await db.collection("Profiles").doc(eventAfter.creatorId).update({
|
||
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken,
|
||
});
|
||
response = await fetch(url, {
|
||
method: "PATCH",
|
||
headers: {
|
||
Authorization: `Bearer ${refreshedGoogleToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(googleEvent),
|
||
});
|
||
}
|
||
if (response.status === 404) {
|
||
logger.info("[GOOGLE_SYNC] Event not found in Google Calendar, creating new event");
|
||
const insertUrl = "https://www.googleapis.com/calendar/v3/calendars/primary/events";
|
||
response = await fetch(insertUrl, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${accountData.accessToken}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ ...googleEvent, id: eventId }),
|
||
});
|
||
}
|
||
if (!response.ok) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error?.message || response.statusText);
|
||
}
|
||
logger.info("[GOOGLE_SYNC] Successfully synced event update to Google", eventId);
|
||
} catch (error) {
|
||
logger.error("[GOOGLE_SYNC] Error syncing event update to Google", error);
|
||
}
|
||
});
|
||
|
||
// Delete event sync from Google
|
||
exports.syncEventToGoogleOnDelete = functions.firestore
|
||
.document("Events/{eventId}")
|
||
.onDelete(async (snapshot, context) => {
|
||
const deletedEvent = snapshot.data();
|
||
const eventId = context.params.eventId;
|
||
if (deletedEvent.externalOrigin === "google") {
|
||
logger.info("[GOOGLE_SYNC] Skipping delete sync for Google-originated event", eventId);
|
||
return;
|
||
}
|
||
try {
|
||
const creatorDoc = await db.collection("Profiles").doc(deletedEvent.creatorId).get();
|
||
const creatorData = creatorDoc.data();
|
||
if (!creatorData?.googleAccounts) {
|
||
logger.info("[GOOGLE_SYNC] Creator has no Google accounts", deletedEvent.creatorId);
|
||
return;
|
||
}
|
||
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
|
||
if (!accountData?.accessToken) {
|
||
logger.info("[GOOGLE_SYNC] No access token found for creator", deletedEvent.creatorId);
|
||
return;
|
||
}
|
||
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
|
||
let response = await fetch(url, {
|
||
method: "DELETE",
|
||
headers: {
|
||
Authorization: `Bearer ${accountData.accessToken}`,
|
||
},
|
||
});
|
||
if (response.status === 401 && accountData.refreshToken) {
|
||
logger.info("[GOOGLE_SYNC] Token expired, refreshing...");
|
||
const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken);
|
||
await db.collection("Profiles").doc(deletedEvent.creatorId).update({
|
||
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken,
|
||
});
|
||
response = await fetch(url, {
|
||
method: "DELETE",
|
||
headers: { Authorization: `Bearer ${refreshedGoogleToken}` },
|
||
});
|
||
}
|
||
if (!response.ok && response.status !== 404) {
|
||
const errorData = await response.json();
|
||
throw new Error(errorData.error?.message || response.statusText);
|
||
}
|
||
logger.info("[GOOGLE_SYNC] Successfully deleted event from Google", eventId);
|
||
} catch (error) {
|
||
logger.error("[GOOGLE_SYNC] Error deleting event from Google", error);
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
UTILS FOR FETCH EVENTS (CALLABLE)
|
||
───────────────────────────────── */
|
||
const createEventHash = (event) => {
|
||
const str = `${event.startDate?.seconds || ""}-${event.endDate?.seconds || ""}-${
|
||
event.title || ""
|
||
}-${event.location || ""}-${event.allDay ? "true" : "false"}`;
|
||
let hash = 0;
|
||
for (let i = 0; i < str.length; i++) {
|
||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||
hash = hash & hash;
|
||
}
|
||
return hash.toString(36);
|
||
};
|
||
|
||
async function fetchEventsFromFirestore(userId, profileData, isFamilyView) {
|
||
const eventsQuery = db.collection("Events");
|
||
let constraints;
|
||
const familyId = profileData?.familyId;
|
||
if (profileData?.userType === "FAMILY_DEVICE") {
|
||
constraints = [ eventsQuery.where("familyId", "==", familyId) ];
|
||
} else {
|
||
if (isFamilyView) {
|
||
constraints = [
|
||
eventsQuery.where("familyId", "==", familyId),
|
||
eventsQuery.where("creatorId", "==", userId),
|
||
eventsQuery.where("attendees", "array-contains", userId),
|
||
];
|
||
} else {
|
||
constraints = [
|
||
eventsQuery.where("creatorId", "==", userId),
|
||
eventsQuery.where("attendees", "array-contains", userId),
|
||
];
|
||
}
|
||
}
|
||
try {
|
||
const snapshots = await Promise.all(constraints.map(query => query.get()));
|
||
const uniqueEvents = new Map();
|
||
const processedHashes = new Set();
|
||
const creatorIds = new Set();
|
||
snapshots.forEach(snapshot => {
|
||
snapshot.docs.forEach(doc => {
|
||
const event = doc.data();
|
||
const hash = createEventHash(event);
|
||
if (!processedHashes.has(hash)) {
|
||
processedHashes.add(hash);
|
||
creatorIds.add(event.creatorId);
|
||
uniqueEvents.set(doc.id, event);
|
||
}
|
||
});
|
||
});
|
||
const creatorIdsArray = Array.from(creatorIds);
|
||
const creatorProfiles = new Map();
|
||
const BATCH_SIZE = 10;
|
||
for (let i = 0; i < creatorIdsArray.length; i += BATCH_SIZE) {
|
||
const chunk = creatorIdsArray.slice(i, i + BATCH_SIZE);
|
||
const profilesSnapshot = await db.collection("Profiles")
|
||
.where(FieldPath.documentId(), "in", chunk)
|
||
.get();
|
||
profilesSnapshot.docs.forEach(doc => {
|
||
creatorProfiles.set(doc.id, doc.data()?.eventColor || "#ff69b4");
|
||
});
|
||
}
|
||
return Array.from(uniqueEvents.entries()).map(([id, event]) => ({
|
||
...event,
|
||
id,
|
||
start: event.allDay ? new Date(new Date(event.startDate.seconds * 1000).setHours(0, 0, 0, 0))
|
||
: new Date(event.startDate.seconds * 1000),
|
||
end: event.allDay ? new Date(new Date(event.endDate.seconds * 1000).setHours(0, 0, 0, 0))
|
||
: new Date(event.endDate.seconds * 1000),
|
||
hideHours: event.allDay,
|
||
eventColor: creatorProfiles.get(event.creatorId) || "#ff69b4",
|
||
notes: event.notes,
|
||
}));
|
||
} catch (error) {
|
||
logger.error("Error fetching events", error);
|
||
throw new functions.https.HttpsError("internal", "Error fetching events");
|
||
}
|
||
}
|
||
|
||
exports.fetchEvents = functions.https.onCall(async (data, context) => {
|
||
if (!context.auth) {
|
||
throw new functions.https.HttpsError("unauthenticated", "User must be authenticated");
|
||
}
|
||
try {
|
||
const { isFamilyView } = data;
|
||
const userId = context.auth.uid;
|
||
const profileDoc = await db.collection("Profiles").doc(userId).get();
|
||
if (!profileDoc.exists) {
|
||
throw new functions.https.HttpsError("not-found", "User profile not found");
|
||
}
|
||
const profileData = profileDoc.data();
|
||
const events = await fetchEventsFromFirestore(userId, profileData, isFamilyView);
|
||
return { events };
|
||
} catch (error) {
|
||
logger.error("Error in fetchEvents", error);
|
||
throw new functions.https.HttpsError("internal", error.message || "An unknown error occurred");
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
HTTP FUNCTIONS (e.g., createSubUser, removeSubUser, generateCustomToken)
|
||
───────────────────────────────── */
|
||
// Create Sub User
|
||
exports.createSubUser = onRequest(async (req, res) => {
|
||
const authHeader = req.get("Authorization");
|
||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||
logger.warn("Missing or incorrect Authorization header", { authHeader });
|
||
return res.status(401).json({ error: "Unauthorized" });
|
||
}
|
||
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 });
|
||
return res.status(401).json({ error: "Unauthorized: Invalid token" });
|
||
}
|
||
const { userType, firstName, lastName, email, password, familyId } = req.body.data;
|
||
if (!email || !password || !firstName || !userType || !familyId) {
|
||
logger.warn("Missing required fields in request body", { requestBody: req.body.data });
|
||
return res.status(400).json({ error: "Missing required fields" });
|
||
}
|
||
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 });
|
||
return res.status(500).json({ error: "Failed to create user" });
|
||
}
|
||
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 });
|
||
return res.status(500).json({ error: "Failed to save user profile" });
|
||
}
|
||
return res.status(200).json({ data: { message: "User created successfully", userId: userRecord.uid } });
|
||
} catch (error) {
|
||
logger.error("Error in createSubUser function", { error: error.message });
|
||
return res.status(500).json({ data: { error: error.message } });
|
||
}
|
||
});
|
||
|
||
// Remove Sub User
|
||
exports.removeSubUser = onRequest(async (req, res) => {
|
||
const authHeader = req.get("Authorization");
|
||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||
logger.warn("Missing or incorrect Authorization header", { authHeader });
|
||
return res.status(401).json({ error: "Unauthorized" });
|
||
}
|
||
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 });
|
||
return res.status(401).json({ error: "Unauthorized: Invalid token" });
|
||
}
|
||
const { userId, familyId } = req.body.data;
|
||
if (!userId || !familyId) {
|
||
logger.warn("Missing required fields in request body", { requestBody: req.body.data });
|
||
return res.status(400).json({ error: "Missing required fields" });
|
||
}
|
||
const userProfileDoc = await getFirestore().collection("Profiles").doc(userId).get();
|
||
if (!userProfileDoc.exists) {
|
||
logger.error("User profile not found", { userId });
|
||
return res.status(404).json({ error: "User not found" });
|
||
}
|
||
if (userProfileDoc.data().familyId !== familyId) {
|
||
logger.error("User does not belong to the specified family", {
|
||
userId,
|
||
requestedFamilyId: familyId,
|
||
actualFamilyId: userProfileDoc.data().familyId,
|
||
});
|
||
return res.status(403).json({ error: "User does not belong to the specified family" });
|
||
}
|
||
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 });
|
||
return res.status(200).json({ data: { message: "User removed successfully", success: true } });
|
||
} catch (error) {
|
||
logger.error("Error in removeSubUser function", { error: error.message });
|
||
return res.status(500).json({ data: { error: error.message } });
|
||
}
|
||
});
|
||
|
||
// Generate Custom Token
|
||
exports.generateCustomToken = onRequest(async (req, res) => {
|
||
try {
|
||
const { userId } = req.body.data;
|
||
logger.info("Generating custom token for userId", { userId });
|
||
if (!userId) return res.status(400).json({ error: "Missing userId" });
|
||
const customToken = await getAuth().createCustomToken(userId);
|
||
return res.status(200).json({ data: { token: customToken } });
|
||
} catch (error) {
|
||
logger.error("Error generating custom token", { error: error.message });
|
||
return res.status(500).json({ error: "Failed to generate custom token" });
|
||
}
|
||
});
|
||
|
||
async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) {
|
||
try {
|
||
logger.info('[GOOGLE_SYNC] Starting to sync event to Google Calendar', {
|
||
eventId: event.id,
|
||
creatorId
|
||
});
|
||
|
||
let token = accessToken;
|
||
const googleEvent = {
|
||
summary: event.title,
|
||
start: {
|
||
dateTime: event.allDay ? undefined : event.startDate.toISOString(),
|
||
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined,
|
||
timeZone: 'UTC'
|
||
},
|
||
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,
|
||
timeZone: 'UTC'
|
||
},
|
||
visibility: event.private ? 'private' : 'default',
|
||
status: 'confirmed',
|
||
reminders: {
|
||
useDefault: true
|
||
},
|
||
// Add extendedProperties to store our Firestore ID
|
||
extendedProperties: {
|
||
private: {
|
||
firestoreId: event.id
|
||
}
|
||
}
|
||
};
|
||
|
||
// For new events, use POST to create
|
||
const url = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
|
||
let response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(googleEvent)
|
||
});
|
||
|
||
// Handle token refresh if needed
|
||
if (response.status === 401 && refreshToken) {
|
||
logger.info('[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: 'POST',
|
||
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}`);
|
||
}
|
||
|
||
const responseData = await response.json();
|
||
|
||
// Store the Google Calendar event ID in Firestore
|
||
await db.collection('Events').doc(event.id).update({
|
||
googleEventId: responseData.id
|
||
});
|
||
|
||
logger.info('[GOOGLE_SYNC] Successfully synced event to Google Calendar', {
|
||
firestoreId: event.id,
|
||
googleEventId: responseData.id
|
||
});
|
||
|
||
return true;
|
||
} catch (error) {
|
||
logger.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
/* ─────────────────────────────────
|
||
PUSH NOTIFICATION HELPERS
|
||
───────────────────────────────── */
|
||
async function getPushTokensForFamily(familyId, excludeUserId = null) {
|
||
const snapshot = await db.collection("Profiles")
|
||
.where("familyId", "==", familyId)
|
||
.get();
|
||
|
||
let pushTokens = [];
|
||
logger.info("Getting push tokens", {familyId, excludeUserId});
|
||
snapshot.forEach(doc => {
|
||
const data = doc.data();
|
||
const userId = doc.id;
|
||
logger.debug("Processing user", {
|
||
docId: userId,
|
||
hasToken: !!data.pushToken,
|
||
excluded: userId === excludeUserId
|
||
});
|
||
if (userId !== excludeUserId && data.pushToken) {
|
||
logger.info("Including token for user", {userId, excludeUserId});
|
||
pushTokens.push(data.pushToken);
|
||
} else {
|
||
logger.debug("Excluding token for user", {userId, excludeUserId});
|
||
}
|
||
});
|
||
// Remove duplicates before sending
|
||
return [...new Set(pushTokens)];
|
||
}
|
||
|
||
async function removeInvalidPushToken(pushToken) {
|
||
try {
|
||
const snapshot = await db.collection("Profiles")
|
||
.where("pushToken", "==", pushToken)
|
||
.get();
|
||
const batch = db.batch();
|
||
snapshot.forEach(doc => batch.update(doc.ref, {pushToken: FieldValue.delete()}));
|
||
await batch.commit();
|
||
logger.info(`Removed invalid push token: ${pushToken}`);
|
||
} catch (error) {
|
||
logger.error("Error removing invalid push token", error);
|
||
}
|
||
}
|
||
|
||
async function sendNotifications(pushTokens, notification) {
|
||
if (!pushTokens.length) return;
|
||
const messages = pushTokens
|
||
.filter(token => Expo.isExpoPushToken(token))
|
||
.map(token => ({
|
||
to: token,
|
||
sound: "default",
|
||
priority: "high",
|
||
...notification,
|
||
}));
|
||
const chunks = expo.chunkPushNotifications(messages);
|
||
for (let chunk of chunks) {
|
||
try {
|
||
const tickets = await expo.sendPushNotificationsAsync(chunk);
|
||
tickets.forEach(ticket => {
|
||
if (ticket.status === "error") {
|
||
if (ticket.details?.error === "DeviceNotRegistered") {
|
||
removeInvalidPushToken(ticket.to);
|
||
}
|
||
logger.error("Push notification error", ticket.message);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
logger.error("Error sending notifications", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function storeNotification(notificationData) {
|
||
try {
|
||
await db.collection("Notifications").add(notificationData);
|
||
} catch (error) {
|
||
logger.error("Error storing notification", error);
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────
|
||
TOKEN REFRESH HELPERS (unchanged)
|
||
───────────────────────────────── */
|
||
// [refreshGoogleToken and refreshMicrosoftToken remain unchanged as in your code]
|
||
|
||
/* ─────────────────────────────────
|
||
NEW NOTIFICATION TRIGGER – No Batching
|
||
───────────────────────────────── */
|
||
|
||
/*
|
||
We now remove the “batch processing” functions
|
||
and instead trigger notifications directly when an event is created.
|
||
We also use a field named "notifiedAt" to ensure that repeated triggers (or re‑processing on update) don’t result in duplicate notifications.
|
||
*/
|
||
|
||
// Firestore trigger: send notification immediately when a new event is created (if not already notified).
|
||
exports.notifyOnEventCreation = functions.firestore
|
||
.document("Events/{eventId}")
|
||
.onCreate(async (snapshot, context) => {
|
||
const event = snapshot.data();
|
||
const eventId = context.params.eventId;
|
||
|
||
// If the event was created by an automated sync (for example, externalOrigin === "google"), skip notification.
|
||
if (event.externalOrigin === "google") {
|
||
logger.info("[NOTIFY] Skipping notification for Google-originated event", eventId);
|
||
return;
|
||
}
|
||
|
||
// Check if already notified (could be set by an update trigger)
|
||
if (event.notifiedAt) {
|
||
logger.info("[NOTIFY] Event already notified", eventId);
|
||
return;
|
||
}
|
||
|
||
// Construct a notification message (customize as needed)
|
||
const notificationMessage = `New event "${event.title}" added to the calendar.`;
|
||
|
||
try {
|
||
// Get push tokens excluding the creator
|
||
const pushTokens = await getPushTokensForFamily(event.familyId, event.creatorId);
|
||
|
||
if (pushTokens.length) {
|
||
await sendNotifications(pushTokens, {
|
||
title: "New Family Calendar Event",
|
||
body: notificationMessage,
|
||
data: {type: "event_created", eventId},
|
||
});
|
||
await storeNotification({
|
||
type: "event_created",
|
||
familyId: event.familyId,
|
||
content: notificationMessage,
|
||
timestamp: FieldValue.serverTimestamp(),
|
||
eventId,
|
||
});
|
||
}
|
||
// Mark event as notified (to prevent duplicate notifications on re-trigger)
|
||
await snapshot.ref.update({notifiedAt: FieldValue.serverTimestamp()});
|
||
logger.info("[NOTIFY] Notification sent for event", eventId);
|
||
} catch (error) {
|
||
logger.error("[NOTIFY] Error sending notification for event", eventId, error);
|
||
}
|
||
});
|
||
|
||
/*
|
||
You can create similar triggers for event updates if needed.
|
||
For example, if the event update includes changes that warrant a new notification (and if you clear or update the notifiedAt field accordingly).
|
||
This simplified approach reduces extra writes because you update the event document only once to mark it as notified.
|
||
*/
|
||
|
||
// A sample Firestore trigger for critical updates (if necessary):
|
||
exports.notifyOnEventUpdate = functions.firestore
|
||
.document("Events/{eventId}")
|
||
.onUpdate(async (change, context) => {
|
||
const before = change.before.data();
|
||
const after = change.after.data();
|
||
const eventId = context.params.eventId;
|
||
|
||
// Only trigger if certain fields have changed (e.g., title change or a significant update)
|
||
if (before.title === after.title) {
|
||
logger.info("[NOTIFY] No relevant change detected; skipping update notification", eventId);
|
||
return;
|
||
}
|
||
|
||
// Use a different notification type for updates
|
||
const notificationMessage = `Event "${after.title}" has been updated.`;
|
||
|
||
try {
|
||
const pushTokens = await getPushTokensForFamily(after.familyId, after.creatorId);
|
||
if (pushTokens.length) {
|
||
await sendNotifications(pushTokens, {
|
||
title: "Family Calendar Event Updated",
|
||
body: notificationMessage,
|
||
data: {type: "event_updated", eventId},
|
||
});
|
||
await storeNotification({
|
||
type: "event_updated",
|
||
familyId: after.familyId,
|
||
content: notificationMessage,
|
||
timestamp: FieldValue.serverTimestamp(),
|
||
eventId,
|
||
});
|
||
}
|
||
// Optionally update a notification timestamp on the event document.
|
||
await change.after.ref.update({notifiedAt: FieldValue.serverTimestamp()});
|
||
logger.info("[NOTIFY] Update notification sent for event", eventId);
|
||
} catch (error) {
|
||
logger.error("[NOTIFY] Error sending update notification for event", eventId, error);
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
REMINDER FUNCTION (for upcoming events)
|
||
───────────────────────────────── */
|
||
// We keep the reminder scheduler mostly as-is but ensure that once a notification is sent, the event is updated
|
||
exports.checkUpcomingEvents = functions.pubsub
|
||
.schedule("every 5 minutes") // Run more frequently to catch reminders
|
||
.onRun(async (context) => {
|
||
const now = Timestamp.now();
|
||
const thirtyMinutesFromNow = new Date(now.toDate().getTime() + 30 * 60 * 1000);
|
||
|
||
// Query only events starting in the next 30 minutes that haven't been reminded
|
||
const eventsSnapshot = await db.collection("Events")
|
||
.where("startDate", ">=", now)
|
||
.where("startDate", "<=", thirtyMinutesFromNow)
|
||
.where("reminderSent", "==", false) // Only get events that haven't been reminded
|
||
.get();
|
||
|
||
const batch = db.batch(); // Batch our updates
|
||
const notificationPromises = [];
|
||
|
||
for (const doc of eventsSnapshot.docs) {
|
||
const event = doc.data();
|
||
if (!event?.startDate) continue;
|
||
|
||
const { familyId, title, allDay } = event;
|
||
try {
|
||
const familyDoc = await db.collection("Families").doc(familyId).get();
|
||
if (!familyDoc.exists) continue;
|
||
|
||
const familySettings = familyDoc.data()?.settings || {};
|
||
const reminderTime = familySettings.defaultReminderTime || 10; // Default to 10 minutes
|
||
const eventTime = event.startDate.toDate();
|
||
const reminderThreshold = new Date(now.toDate().getTime() + reminderTime * 60 * 1000);
|
||
|
||
// Check if we're within the reminder window
|
||
const timeUntilEvent = eventTime.getTime() - now.toDate().getTime();
|
||
const minutesUntilEvent = Math.floor(timeUntilEvent / (60 * 1000));
|
||
|
||
if (minutesUntilEvent <= reminderTime && minutesUntilEvent > 0) {
|
||
notificationPromises.push(async () => {
|
||
const pushTokens = await getPushTokensForFamily(familyId);
|
||
if (pushTokens.length) {
|
||
await sendNotifications(pushTokens, {
|
||
title: "Upcoming Event",
|
||
body: `In ${minutesUntilEvent} minutes: ${title}`,
|
||
data: { type: 'event_reminder', eventId: doc.id }
|
||
});
|
||
batch.update(doc.ref, { reminderSent: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Handle all-day events separately
|
||
if (allDay && !event.eveningReminderSent) {
|
||
const eveningBefore = new Date(eventTime);
|
||
eveningBefore.setDate(eveningBefore.getDate() - 1);
|
||
eveningBefore.setHours(20, 0, 0, 0);
|
||
|
||
if (now.toDate() >= eveningBefore) {
|
||
notificationPromises.push(async () => {
|
||
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 }
|
||
});
|
||
batch.update(doc.ref, { eveningReminderSent: true });
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`Error processing reminder for event ${doc.id}:`, error);
|
||
}
|
||
}
|
||
|
||
// Execute all notifications
|
||
await Promise.all(notificationPromises.map(fn => fn()));
|
||
|
||
// Commit all updates in one batch
|
||
if (batch._ops.length > 0) {
|
||
await batch.commit();
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
MIGRATION UTILITY
|
||
───────────────────────────────── */
|
||
exports.migrateEventNotifications = functions.https.onRequest(async (req, res) => {
|
||
try {
|
||
const batch = db.batch();
|
||
const existingEvents = await db.collection('Events')
|
||
.where('notifiedAt', '==', null)
|
||
.get();
|
||
|
||
logger.info("[MIGRATE] Starting event migration", {count: existingEvents.size});
|
||
|
||
if (existingEvents.empty) {
|
||
logger.info("[MIGRATE] No events require migration");
|
||
res.status(200).send("No events needed migration");
|
||
return;
|
||
}
|
||
|
||
existingEvents.forEach(doc => {
|
||
batch.update(doc.ref, {
|
||
notifiedAt: FieldValue.serverTimestamp(),
|
||
eveningReminderSent: false,
|
||
reminderSent: false,
|
||
lastNotifiedAt: null
|
||
});
|
||
});
|
||
|
||
await batch.commit();
|
||
|
||
// Store migration record
|
||
await db.collection('SystemLogs').add({
|
||
type: 'migration',
|
||
operation: 'event_notifications',
|
||
count: existingEvents.size,
|
||
timestamp: FieldValue.serverTimestamp(),
|
||
status: 'completed'
|
||
});
|
||
|
||
logger.info("[MIGRATE] Completed event migration", {
|
||
count: existingEvents.size,
|
||
status: 'success'
|
||
});
|
||
|
||
res.status(200).send({
|
||
status: 'success',
|
||
message: `Successfully migrated ${existingEvents.size} events`,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
|
||
} catch (error) {
|
||
logger.error("[MIGRATE] Error during migration", {
|
||
error: error.message,
|
||
stack: error.stack
|
||
});
|
||
|
||
// Store error record
|
||
await db.collection('SystemLogs').add({
|
||
type: 'migration',
|
||
operation: 'event_notifications',
|
||
error: error.message,
|
||
errorStack: error.stack,
|
||
timestamp: FieldValue.serverTimestamp(),
|
||
status: 'failed'
|
||
});
|
||
|
||
res.status(500).send({
|
||
status: 'error',
|
||
message: 'Migration failed',
|
||
error: error.message,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
}
|
||
}); |