mirror of
https://github.com/urosran/cally.git
synced 2025-07-09 22:57:16 +00:00
1938 lines
78 KiB
JavaScript
1938 lines
78 KiB
JavaScript
const { onRequest, onCall, HttpsError } = 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://sendsyncnotification-ne4kpcdqwa-uc.a.run.app";
|
||
|
||
/* ─────────────────────────────────
|
||
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;
|
||
}
|
||
}
|
||
|
||
exports.sendOverviews = functions.pubsub
|
||
.schedule('0 20 * * *') // Runs at 8 PM daily
|
||
.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') // Runs at 8 PM every Sunday
|
||
.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.checkUpcomingEvents = functions.pubsub
|
||
.schedule("every 1 minutes")
|
||
.onRun(async (context) => {
|
||
const now = admin.firestore.Timestamp.now();
|
||
const oneHourFromNow = new Date(now.toDate().getTime() + 60 * 60 * 1000);
|
||
|
||
logger.info("Checking upcoming events", {
|
||
currentTime: now.toDate().toISOString(),
|
||
lookAheadTime: oneHourFromNow.toISOString()
|
||
});
|
||
|
||
const eventsSnapshot = await admin.firestore()
|
||
.collection("Events")
|
||
.where("startDate", ">=", now)
|
||
.where("startDate", "<=", admin.firestore.Timestamp.fromDate(oneHourFromNow))
|
||
.get();
|
||
|
||
logger.info(`Found ${eventsSnapshot.size} upcoming events to check`);
|
||
|
||
const processPromises = eventsSnapshot.docs.map(async (doc) => {
|
||
const event = doc.data();
|
||
const eventId = doc.id;
|
||
|
||
// Skip if reminder already sent
|
||
if (event.reminderSent === true ||
|
||
event.notifiedAt ||
|
||
(event.reminderSentAt &&
|
||
now.toMillis() - event.reminderSentAt.toMillis() < 60000)) {
|
||
return;
|
||
}
|
||
try {
|
||
const householdSnapshot = await admin.firestore()
|
||
.collection("Households")
|
||
.where("familyId", "==", event.familyId)
|
||
.limit(1)
|
||
.get();
|
||
|
||
if (householdSnapshot.empty) {
|
||
return;
|
||
}
|
||
|
||
const householdDoc = householdSnapshot.docs[0];
|
||
const householdSettings = householdDoc.data()?.settings || {};
|
||
const reminderTime = householdSettings.defaultReminderTime || 15;
|
||
const eventTime = event.startDate.toDate();
|
||
const currentTime = now.toDate();
|
||
const minutesUntilEvent = Math.round((eventTime - currentTime) / (60 * 1000));
|
||
|
||
// Only send exactly at the reminder time
|
||
if (minutesUntilEvent === reminderTime) {
|
||
// Double-check reminder hasn't been sent
|
||
const freshEventDoc = await doc.ref.get();
|
||
if (freshEventDoc.data()?.reminderSent === true) {
|
||
return;
|
||
}
|
||
|
||
// Mark as sent FIRST
|
||
await doc.ref.update({
|
||
reminderSent: true,
|
||
reminderSentAt: admin.firestore.FieldValue.serverTimestamp()
|
||
});
|
||
|
||
const pushTokens = await getPushTokensForFamily(event.familyId);
|
||
if (pushTokens.length > 0) {
|
||
// Send notification
|
||
await sendNotifications(pushTokens, {
|
||
title: "Upcoming Event Reminder",
|
||
body: `In ${reminderTime} minutes: ${event.title}`,
|
||
data: {
|
||
type: "event_reminder",
|
||
eventId: eventId,
|
||
familyId: event.familyId
|
||
}
|
||
});
|
||
|
||
// Store notification record
|
||
await storeNotification({
|
||
type: "event_reminder",
|
||
familyId: event.familyId,
|
||
content: `Reminder: ${event.title} starts in ${reminderTime} minutes`,
|
||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||
eventId: eventId
|
||
});
|
||
|
||
logger.info(`Sent reminder for event: ${event.title}`, {
|
||
eventId,
|
||
minutesUntilEvent,
|
||
reminderTime,
|
||
currentTime: currentTime.toISOString(),
|
||
eventTime: eventTime.toISOString()
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
logger.error(`Error processing reminder for event ${eventId}:`, error);
|
||
}
|
||
});
|
||
|
||
await Promise.all(processPromises);
|
||
});
|
||
// 3. Add a function to reset reminder flags for testing
|
||
exports.resetReminderFlags = functions.https.onRequest(async (req, res) => {
|
||
try {
|
||
const batch = admin.firestore().batch();
|
||
const events = await admin.firestore()
|
||
.collection("Events")
|
||
.where("reminderSent", "==", true)
|
||
.get();
|
||
|
||
events.docs.forEach(doc => {
|
||
batch.update(doc.ref, {
|
||
reminderSent: false,
|
||
reminderSentAt: null
|
||
});
|
||
});
|
||
|
||
await batch.commit();
|
||
res.status(200).send(`Reset ${events.size} events`);
|
||
} catch (error) {
|
||
res.status(500).send(error.message);
|
||
}
|
||
});
|
||
|
||
exports.initializeEventFlags = functions.firestore
|
||
.document('Events/{eventId}')
|
||
.onCreate(async (snapshot, context) => {
|
||
try {
|
||
const eventData = snapshot.data();
|
||
if (eventData.reminderSent === undefined) {
|
||
await snapshot.ref.update({
|
||
reminderSent: false,
|
||
reminderSentAt: null
|
||
});
|
||
}
|
||
} catch (error) {
|
||
logger.error(`Error initializing event ${context.params.eventId}:`, 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.reminderSent === undefined) {
|
||
await snapshot.ref.update({
|
||
reminderSent: false,
|
||
eveningReminderSent: false,
|
||
notifiedAt: null
|
||
});
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────────────
|
||
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()
|
||
});
|
||
}
|
||
});
|
||
|
||
exports.sendSyncNotification = onRequest(async (req, res) => {
|
||
const userId = req.query.userId;
|
||
|
||
if (!userId) {
|
||
logger.error('[SYNC] Missing userId in request');
|
||
return res.status(400).send('Missing userId');
|
||
}
|
||
|
||
try {
|
||
const userDoc = await db.collection("Profiles").doc(userId).get();
|
||
if (!userDoc.exists) {
|
||
logger.error(`[SYNC] No profile found for user ${userId}`);
|
||
return res.status(404).send("User profile not found");
|
||
}
|
||
|
||
const userData = userDoc.data();
|
||
const familyId = userData.familyId;
|
||
const googleAccounts = userData.googleAccounts || {};
|
||
|
||
// Get first Google account
|
||
const [email] = Object.keys(googleAccounts);
|
||
if (!email) {
|
||
logger.error(`[SYNC] No Google account found for user ${userId}`);
|
||
return res.status(400).send("No Google account found");
|
||
}
|
||
|
||
const accountData = googleAccounts[email];
|
||
const { accessToken, refreshToken } = accountData;
|
||
|
||
if (!accessToken) {
|
||
logger.error(`[SYNC] No access token for user ${userId}`);
|
||
return res.status(400).send("No access token found");
|
||
}
|
||
|
||
if (!familyId) {
|
||
logger.error(`[SYNC] No family ID for user ${userId}`);
|
||
return res.status(400).send("No family ID found");
|
||
}
|
||
|
||
// Check if household exists and create if it doesn't
|
||
const householdsSnapshot = await db.collection('Households')
|
||
.where("familyId", "==", familyId)
|
||
.get();
|
||
|
||
if (householdsSnapshot.empty) {
|
||
logger.info(`[SYNC] No household found for family ${familyId}, creating one`);
|
||
// Create a new household document
|
||
await db.collection('Households').add({
|
||
familyId,
|
||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||
createdBy: userId,
|
||
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||
settings: {
|
||
defaultReminderTime: 30, // Default 30 minutes reminder
|
||
}
|
||
});
|
||
logger.info(`[SYNC] Created new household for family ${familyId}`);
|
||
}
|
||
|
||
logger.info(`[SYNC] Starting calendar sync for user ${userId}`);
|
||
const syncStartTime = Date.now();
|
||
|
||
// Trigger immediate sync and get event count
|
||
const totalEvents = await fetchAndSaveGoogleEvents({
|
||
token: accessToken,
|
||
refreshToken,
|
||
email,
|
||
familyId,
|
||
creatorId: userId
|
||
});
|
||
|
||
// Update household timestamps (will now include newly created household if any)
|
||
const updatedHouseholdsSnapshot = await db.collection('Households')
|
||
.where("familyId", "==", familyId)
|
||
.get();
|
||
|
||
const batch = db.batch();
|
||
updatedHouseholdsSnapshot.docs.forEach((doc) => {
|
||
batch.update(doc.ref, {
|
||
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp()
|
||
});
|
||
});
|
||
|
||
await batch.commit();
|
||
|
||
logger.info(`[SYNC] Completed sync for user ${userId} in ${Date.now() - syncStartTime}ms`);
|
||
res.status(200).send("Sync completed successfully");
|
||
} catch (error) {
|
||
logger.error(`[SYNC] Error in sync notification:`, error);
|
||
res.status(500).send("Sync failed");
|
||
}
|
||
});
|
||
|
||
exports.renewGoogleCalendarWatch = functions.pubsub
|
||
.schedule("every 10 minutes")
|
||
.onRun(async (context) => {
|
||
logger.info("[WATCH] Starting calendar watch renewal check");
|
||
|
||
try {
|
||
const profilesWithGoogle = await db.collection('Profiles')
|
||
.where('googleAccounts', '!=', null)
|
||
.get();
|
||
|
||
logger.info(`[WATCH] Found ${profilesWithGoogle.size} profiles with Google accounts`);
|
||
|
||
const existingWatches = await db.collection('CalendarWatches').get();
|
||
logger.info(`[WATCH] Found ${existingWatches.size} existing watches`);
|
||
|
||
const now = Date.now();
|
||
const watchMap = new Map();
|
||
existingWatches.forEach(doc => {
|
||
watchMap.set(doc.id, doc.data());
|
||
logger.debug(`[WATCH] Existing watch for ${doc.id}`, doc.data());
|
||
});
|
||
|
||
const batch = db.batch();
|
||
let processedCount = 0;
|
||
let newWatchCount = 0;
|
||
let renewalCount = 0;
|
||
|
||
for (const profile of profilesWithGoogle.docs) {
|
||
const userId = profile.id;
|
||
const userData = profile.data();
|
||
const existingWatch = watchMap.get(userId);
|
||
|
||
logger.info(`[WATCH] Processing user ${userId}`, {
|
||
hasGoogleAccounts: !!userData.googleAccounts,
|
||
accountCount: Object.keys(userData.googleAccounts || {}).length,
|
||
hasExistingWatch: !!existingWatch,
|
||
watchExpiration: existingWatch?.expiration
|
||
});
|
||
|
||
if (!userData.googleAccounts || Object.keys(userData.googleAccounts).length === 0) {
|
||
logger.info(`[WATCH] Skipping user ${userId} - no Google accounts`);
|
||
continue;
|
||
}
|
||
|
||
const firstAccount = Object.values(userData.googleAccounts)[0];
|
||
const token = firstAccount?.accessToken;
|
||
|
||
if (!token) {
|
||
logger.info(`[WATCH] No token found for user ${userId}`);
|
||
continue;
|
||
}
|
||
|
||
const needsRenewal = !existingWatch ||
|
||
!existingWatch.expiration ||
|
||
existingWatch.expiration < (now + 30 * 60 * 1000);
|
||
|
||
if (!needsRenewal) {
|
||
logger.info(`[WATCH] Watch still valid for user ${userId}, skipping renewal`);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
logger.info(`[WATCH] ${existingWatch ? 'Renewing' : 'Creating new'} watch for user ${userId}`);
|
||
|
||
const url = `https://www.googleapis.com/calendar/v3/calendars/${GOOGLE_CALENDAR_ID}/events/watch`;
|
||
const watchBody = {
|
||
id: `${CHANNEL_ID}-${userId}`,
|
||
type: "web_hook",
|
||
address: `${WEBHOOK_URL}?userId=${userId}`,
|
||
params: {ttl: "604800"}
|
||
};
|
||
|
||
logger.info(`[WATCH] Making request for user ${userId}`, {
|
||
url,
|
||
watchBody,
|
||
tokenPrefix: token.substring(0, 10)
|
||
});
|
||
|
||
const response = await fetch(url, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(watchBody),
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
logger.info(`[WATCH] Received response for ${userId}`, {
|
||
status: response.status,
|
||
response: responseText
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to set up watch: ${responseText}`);
|
||
}
|
||
|
||
const result = JSON.parse(responseText);
|
||
|
||
batch.set(db.collection('CalendarWatches').doc(userId), {
|
||
watchId: result.id,
|
||
resourceId: result.resourceId,
|
||
expiration: result.expiration,
|
||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||
}, {merge: true});
|
||
|
||
processedCount++;
|
||
if (existingWatch) {
|
||
renewalCount++;
|
||
} else {
|
||
newWatchCount++;
|
||
}
|
||
|
||
logger.info(`[WATCH] Successfully ${existingWatch ? 'renewed' : 'created'} watch for user ${userId}`, {
|
||
watchId: result.id,
|
||
expiration: result.expiration
|
||
});
|
||
|
||
} catch (error) {
|
||
logger.error(`[WATCH] Failed to ${existingWatch ? 'renew' : 'create'} watch for user ${userId}:`, {
|
||
error: error.message,
|
||
stack: error.stack
|
||
});
|
||
|
||
batch.set(db.collection('CalendarWatchErrors').doc(), {
|
||
userId,
|
||
error: error.message,
|
||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||
});
|
||
}
|
||
}
|
||
|
||
if (processedCount > 0) {
|
||
logger.info(`[WATCH] Committing batch with ${processedCount} operations`);
|
||
await batch.commit();
|
||
}
|
||
|
||
logger.info(`[WATCH] Completed calendar watch processing`, {
|
||
totalProcessed: processedCount,
|
||
newWatches: newWatchCount,
|
||
renewals: renewalCount
|
||
});
|
||
|
||
return null;
|
||
} catch (error) {
|
||
logger.error('[WATCH] Critical error in watch renewal:', {
|
||
error: error.message,
|
||
stack: error.stack
|
||
});
|
||
throw error;
|
||
}
|
||
});
|
||
|
||
exports.triggerGoogleSync = onCall({
|
||
memory: '256MiB',
|
||
enforceAppCheck: false,
|
||
maxInstances: 10,
|
||
region: 'us-central1',
|
||
invoker: 'public',
|
||
}, async (request) => {
|
||
if (!request.auth) {
|
||
throw new HttpsError('unauthenticated', 'Authentication required');
|
||
}
|
||
|
||
try {
|
||
const userId = request.auth.uid;
|
||
const {email} = request.data;
|
||
|
||
logger.info("Starting manual Google sync", {userId, email});
|
||
|
||
const userDoc = await db.collection("Profiles").doc(userId).get();
|
||
const userData = userDoc.data();
|
||
|
||
if (!userData?.googleAccounts?.[email]) {
|
||
logger.error("No valid Google account found", {userId, email});
|
||
throw new HttpsError('failed-precondition', 'No valid Google account found');
|
||
}
|
||
|
||
const accountData = userData.googleAccounts[email];
|
||
const eventCount = await calendarSync({
|
||
userId,
|
||
email,
|
||
token: accountData.accessToken,
|
||
refreshToken: accountData.refreshToken,
|
||
familyId: userData.familyId
|
||
});
|
||
|
||
logger.info("Manual sync completed successfully", {userId, eventCount});
|
||
|
||
return {
|
||
success: true,
|
||
eventCount,
|
||
message: "Google calendar sync completed successfully"
|
||
};
|
||
} catch (error) {
|
||
logger.error("Manual sync failed", error);
|
||
throw new HttpsError('internal', error.message);
|
||
}
|
||
});
|
||
|
||
exports.triggerMicrosoftSync = onCall({
|
||
memory: '256MiB',
|
||
enforceAppCheck: false,
|
||
maxInstances: 10,
|
||
region: 'us-central1',
|
||
invoker: 'public',
|
||
}, async (request) => {
|
||
if (!request.auth) {
|
||
throw new HttpsError('unauthenticated', 'Authentication required');
|
||
}
|
||
|
||
try {
|
||
const userId = request.auth.uid;
|
||
const {email} = request.data;
|
||
|
||
if (!email) {
|
||
throw new HttpsError('invalid-argument', 'Email is required');
|
||
}
|
||
|
||
logger.info('Starting Microsoft sync', {userId, email});
|
||
|
||
const userDoc = await db.collection("Profiles").doc(userId).get();
|
||
if (!userDoc.exists) {
|
||
throw new HttpsError('not-found', 'User profile not found');
|
||
}
|
||
|
||
const userData = userDoc.data();
|
||
const accountData = userData.microsoftAccounts?.[email];
|
||
|
||
if (!accountData) {
|
||
throw new HttpsError('failed-precondition', 'Microsoft account not found');
|
||
}
|
||
|
||
let {accessToken, refreshToken} = accountData;
|
||
|
||
// Try to refresh token if it exists
|
||
if (refreshToken) {
|
||
try {
|
||
const refreshedTokens = await refreshMicrosoftToken(refreshToken);
|
||
accessToken = refreshedTokens.accessToken;
|
||
refreshToken = refreshedTokens.refreshToken || refreshToken;
|
||
|
||
// Update the stored tokens
|
||
await db.collection("Profiles").doc(userId).update({
|
||
[`microsoftAccounts.${email}`]: {
|
||
...accountData,
|
||
accessToken,
|
||
refreshToken,
|
||
lastRefresh: FieldValue.serverTimestamp()
|
||
}
|
||
});
|
||
} catch (refreshError) {
|
||
logger.error('Token refresh failed:', refreshError);
|
||
throw new HttpsError(
|
||
'failed-precondition',
|
||
'Failed to refresh Microsoft token. Please reconnect your account.',
|
||
{requiresReauth: true}
|
||
);
|
||
}
|
||
}
|
||
|
||
try {
|
||
const eventCount = await fetchAndSaveMicrosoftEvents({
|
||
token: accessToken,
|
||
refreshToken,
|
||
email,
|
||
familyId: userData.familyId,
|
||
creatorId: userId
|
||
});
|
||
|
||
logger.info('Microsoft sync completed successfully', {eventCount});
|
||
return {
|
||
success: true,
|
||
eventCount,
|
||
message: "Microsoft calendar sync completed successfully"
|
||
};
|
||
} catch (syncError) {
|
||
if (syncError.message?.includes('401') ||
|
||
syncError.message?.includes('unauthorized') ||
|
||
syncError.message?.includes('invalid_grant')) {
|
||
throw new HttpsError(
|
||
'unauthenticated',
|
||
'Microsoft authentication expired. Please reconnect your account.',
|
||
{requiresReauth: true}
|
||
);
|
||
}
|
||
throw new HttpsError('internal', syncError.message);
|
||
}
|
||
} catch (error) {
|
||
logger.error('Microsoft sync function error:', error);
|
||
if (error instanceof HttpsError) {
|
||
throw error;
|
||
}
|
||
throw new HttpsError('internal', error.message || 'Unknown error occurred');
|
||
}
|
||
});
|
||
|
||
exports.forceWatchRenewal = onRequest(async (req, res) => {
|
||
const batch = db.batch();
|
||
const watches = await db.collection('CalendarWatches').get();
|
||
watches.forEach(doc => {
|
||
batch.update(doc.ref, {
|
||
expiration: 0
|
||
});
|
||
});
|
||
await batch.commit();
|
||
res.status(200).send('Forced renewal of all watches');
|
||
});
|
||
|
||
exports.deleteFamily = functions.https.onCall(async (data, context) => {
|
||
if (!context.auth) {
|
||
throw new functions.https.HttpsError('unauthenticated', 'User must be authenticated');
|
||
}
|
||
|
||
const { familyId } = data;
|
||
|
||
if (!familyId) {
|
||
throw new functions.https.HttpsError('invalid-argument', 'Family ID is required');
|
||
}
|
||
|
||
try {
|
||
const db = admin.firestore();
|
||
|
||
const requestingUserProfile = await db.collection('Profiles')
|
||
.doc(context.auth.uid)
|
||
.get();
|
||
|
||
if (!requestingUserProfile.exists || requestingUserProfile.data().userType !== 'parent') {
|
||
throw new functions.https.HttpsError('permission-denied', 'Only parents can delete families');
|
||
}
|
||
|
||
if (requestingUserProfile.data().familyId !== familyId) {
|
||
throw new functions.https.HttpsError('permission-denied', 'You can not delete other families');
|
||
}
|
||
|
||
const profilesSnapshot = await db.collection('Profiles')
|
||
.where('familyId', '==', familyId)
|
||
.get();
|
||
|
||
const batch = db.batch();
|
||
const profileIds = [];
|
||
|
||
for (const profile of profilesSnapshot.docs) {
|
||
const userId = profile.id;
|
||
profileIds.push(userId);
|
||
|
||
const collections = [
|
||
'BrainDumps',
|
||
'Groceries',
|
||
'Todos',
|
||
'Events',
|
||
//'Feedbacks'
|
||
];
|
||
|
||
for (const collectionName of collections) {
|
||
const userDocsSnapshot = await db.collection(collectionName)
|
||
.where('creatorId', '==', userId)
|
||
.get();
|
||
|
||
userDocsSnapshot.docs.forEach(doc => {
|
||
batch.delete(doc.ref);
|
||
});
|
||
}
|
||
|
||
batch.delete(profile.ref);
|
||
}
|
||
|
||
const householdSnapshot = await db.collection('Households')
|
||
.where('familyId', '==', familyId)
|
||
.get();
|
||
|
||
if (!householdSnapshot.empty) {
|
||
const householdDoc = householdSnapshot.docs[0];
|
||
batch.delete(householdDoc.ref);
|
||
} else {
|
||
console.log('Household not found for familyId:', familyId);
|
||
}
|
||
|
||
await batch.commit();
|
||
|
||
// Delete Firebase Auth accounts
|
||
await Promise.all(profileIds.map(userId =>
|
||
admin.auth().deleteUser(userId)
|
||
));
|
||
|
||
return { success: true, message: 'Family deleted successfully' };
|
||
|
||
} catch (error) {
|
||
console.error('Error deleting family:', error);
|
||
throw new functions.https.HttpsError('internal', 'Error deleting family data');
|
||
}
|
||
}); |