Syncing rework

This commit is contained in:
Milan Paunovic
2024-11-26 21:13:54 +01:00
parent 5cfdc84055
commit f2af60111b
14 changed files with 960 additions and 595 deletions

View File

@ -1,4 +1,4 @@
import React, {useMemo} from "react";
import React from "react";
import {Drawer} from "expo-router/drawer";
import {useSignOut} from "@/hooks/firebase/useSignOut";
import {DrawerContentScrollView, DrawerNavigationOptions, DrawerNavigationProp} from "@react-navigation/drawer";
@ -64,46 +64,42 @@ export default function TabLayout() {
const setUserView = useSetAtom(userSettingsView);
const setToDosIndex = useSetAtom(toDosPageIndex);
const screenOptions = useMemo(
() =>
({
navigation,
route,
}: {
navigation: DrawerNavigationProp<DrawerParamList>;
route: RouteProp<DrawerParamList>;
}): DrawerNavigationOptions => ({
headerShown: true,
headerTitleAlign: Device.deviceType === DeviceType.TABLET ? "left" : "center",
headerTitleStyle: {
fontFamily: "Manrope_600SemiBold",
fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17,
},
headerLeft: () => (
<TouchableOpacity
onPress={navigation.toggleDrawer}
style={{marginLeft: 16}}
>
<DrawerIcon/>
</TouchableOpacity>
),
headerRight: () => {
const showViewSwitch = ["calendar", "todos", "index"].includes(route.name);
const screenOptions = ({
navigation,
route,
}: {
navigation: DrawerNavigationProp<DrawerParamList>;
route: RouteProp<DrawerParamList>;
}): DrawerNavigationOptions => ({
headerShown: true,
headerTitleAlign: Device.deviceType === DeviceType.TABLET ? "left" : "center",
headerTitleStyle: {
fontFamily: "Manrope_600SemiBold",
fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17,
},
headerLeft: () => (
<TouchableOpacity
onPress={navigation.toggleDrawer}
style={{marginLeft: 16}}
>
<DrawerIcon/>
</TouchableOpacity>
),
headerRight: () => {
const showViewSwitch = ["calendar", "todos", "index"].includes(route.name);
if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
return null;
}
if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
return null;
}
return <MemoizedViewSwitch navigation={navigation}/>;
},
drawerStyle: {
width: Device.deviceType === DeviceType.TABLET ? "30%" : "90%",
backgroundColor: "#f9f8f7",
height: "100%",
},
}),
[]
);
return <MemoizedViewSwitch navigation={navigation}/>;
},
drawerStyle: {
width: Device.deviceType === DeviceType.TABLET ? "30%" : "90%",
backgroundColor: "#f9f8f7",
height: "100%",
},
})
return (
<Drawer

View File

@ -58,7 +58,7 @@ export default function Screen() {
]}
tintColor={colorMap.pink}
progressBackgroundColor={"white"}
refreshing={refreshing || isSyncing}
refreshing={isSyncing}
onRefresh={onRefresh}
style={{
position: "absolute",
@ -72,7 +72,7 @@ export default function Screen() {
}
bounces={true}
showsVerticalScrollIndicator={false}
pointerEvents={refreshing || isSyncing ? "auto" : "none"}
pointerEvents={isSyncing ? "auto" : "none"}
/>
</View>
);

View File

@ -1,21 +1,34 @@
import React, {memo} from "react";
import {Button, Picker, PickerModes, SegmentedControl, Text, View,} from "react-native-ui-lib";
import {MaterialIcons} from "@expo/vector-icons";
import {modeMap, months} from "./constants";
import {months} from "./constants";
import {StyleSheet} from "react-native";
import {useAtom} from "jotai";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {format, isSameDay} from "date-fns";
import * as Device from "expo-device";
import {Mode} from "react-native-big-calendar";
export const CalendarHeader = memo(() => {
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom);
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const segments = isTablet
? [{label: "D"}, {label: "W"}, {label: "M"}] // Tablet segments
: [{label: "D"}, {label: "3D"}, {label: "M"}]; // Phone segments
const handleSegmentChange = (index: number) => {
const selectedMode = modeMap.get(index);
let selectedMode: Mode;
if (isTablet) {
selectedMode = ["day", "week", "month"][index] as Mode;
} else {
selectedMode = ["day", "3days", "month"][index] as Mode;
}
if (selectedMode) {
setTimeout(() => {
setMode(selectedMode as "day" | "week" | "month");
setMode(selectedMode as "day" | "week" | "month" | "3days");
}, 150);
}
};
@ -31,6 +44,34 @@ export const CalendarHeader = memo(() => {
const isSelectedDateToday = isSameDay(selectedDate, new Date());
const getInitialIndex = () => {
if (isTablet) {
// Tablet index mapping
switch (mode) {
case "day":
return 0;
case "week":
return 1;
case "month":
return 2;
default:
return 1; // Default to week view for tablets
}
} else {
// Phone index mapping
switch (mode) {
case "day":
return 0;
case "3days":
return 1;
case "month":
return 2;
default:
return 1; // Default to 3day view for phones
}
}
};
return (
<View
style={{
@ -96,7 +137,7 @@ export const CalendarHeader = memo(() => {
<View>
<SegmentedControl
segments={[{label: "D"}, {label: "W"}, {label: "M"}]}
segments={segments}
backgroundColor="#ececec"
inactiveColor="#919191"
activeBackgroundColor="#ea156c"
@ -105,7 +146,7 @@ export const CalendarHeader = memo(() => {
outlineWidth={3}
segmentLabelStyle={styles.segmentslblStyle}
onChangeIndex={handleSegmentChange}
initialIndex={mode === "day" ? 0 : mode === "week" ? 1 : 2}
initialIndex={getInitialIndex()}
/>
</View>
</View>
@ -118,4 +159,4 @@ const styles = StyleSheet.create({
fontSize: 12,
fontFamily: "Manrope_600SemiBold",
},
});
});

View File

@ -33,7 +33,7 @@ const getTotalMinutes = () => {
export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
({calendarHeight}) => {
const {data: events, isLoading, refetch} = useGetEvents();
const {data: events, isLoading} = useGetEvents();
const {profileData, user} = useAuthContext();
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom);
@ -52,9 +52,8 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const handlePressEvent = useCallback(
(event: CalendarEvent) => {
if (mode === "day" || mode === "week") {
if (mode === "day" || mode === "week" || mode === "3days") {
setEditVisible(true);
// console.log({event});
setEventForEdit(event);
} else {
setMode("day");
@ -66,7 +65,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const handlePressCell = useCallback(
(date: Date) => {
if (mode === "day" || mode === "week") {
if (mode === "day" || mode === "week" || mode === "3days") {
setSelectedNewEndDate(date);
} else {
setMode("day");
@ -83,9 +82,8 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
setSelectedNewEndDate(date);
setEditVisible(true);
}
if (mode === 'week') {
if (mode === 'week' || mode === '3days') {
setSelectedDate(date)
setMode("day")
}
},
@ -102,7 +100,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const memoizedEventCellStyle = useCallback(
(event: CalendarEvent) => {
let eventColor = event.eventColor;
if (!isFamilyView && (event.attendees?.includes(user?.uid) || event.creatorId === user?.uid)) {
if (!isFamilyView && (event.attendees?.includes(user?.uid!) || event.creatorId! === user?.uid)) {
eventColor = profileData?.eventColor ?? colorMap.teal;
}
@ -129,7 +127,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
}, [selectedDate, mode]);
const dateStyle = useMemo(() => {
if (mode === "week") return undefined;
if (mode === "week" || mode === "3days") return undefined;
return isSameDate(todaysDate, selectedDate) && mode === "day"
? styles.dayHeader
: styles.otherDayHeader;
@ -138,7 +136,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
const memoizedHeaderContentStyle = useMemo(() => {
if (mode === "day") {
return styles.dayModeHeader;
} else if (mode === "week") {
} else if (mode === "week" || mode === "3days") {
return styles.weekModeHeader;
} else if (mode === "month") {
return styles.monthModeHeader;
@ -146,12 +144,11 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
return {};
}
}, [mode]);
const {enrichedEvents, filteredEvents} = useMemo(() => {
const startTime = Date.now(); // Start timer
const startTime = Date.now();
const startOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
const endOffset = mode === "month" ? 40 : mode === "week" ? 10 : 1;
const startOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1;
const endOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1;
const filteredEvents =
events?.filter(
@ -177,7 +174,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
overlapCount: 0,
});
acc[dateKey].sort((a, b) => compareAsc(a.start, b.start));
acc[dateKey].sort((a: { start: any; }, b: { start: any; }) => compareAsc(a.start, b.start));
return acc;
}, {} as Record<string, CalendarEvent[]>);
@ -230,7 +227,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
</View>
);
},
[todaysDate, currentDateStyle, defaultStyle] // dependencies
[todaysDate, currentDateStyle, defaultStyle]
);
return renderDate(date);
@ -249,8 +246,6 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo(
);
}
// console.log(enrichedEvents, filteredEvents)
return (
<>
{isSyncing && (

View File

@ -1,15 +1,21 @@
import { atom } from "jotai";
import * as Device from "expo-device";
import { CalendarEvent } from "@/components/pages/calendar/interfaces";
const getDefaultMode = () => {
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
return isTablet ? "week" : "3days";
};
export const editVisibleAtom = atom<boolean>(false);
export const isAllDayAtom = atom<boolean>(false);
export const eventForEditAtom = atom<CalendarEvent | undefined>(undefined);
export const isFamilyViewAtom = atom<boolean>(false);
export const modeAtom = atom<"week" | "month" | "day">("week");
export const modeAtom = atom<"week" | "month" | "day" | "3days">(getDefaultMode());
export const selectedDateAtom = atom<Date>(new Date());
export const selectedNewEventDateAtom = atom<Date | undefined>(undefined);
export const settingsPageIndex = atom<number>(0);
export const userSettingsView = atom<boolean>(true);
export const toDosPageIndex = atom<number>(0);
export const refreshTriggerAtom = atom<boolean>(false);
export const refreshEnabledAtom = atom<boolean>(true);
export const refreshEnabledAtom = atom<boolean>(true);

View File

@ -1,7 +1,8 @@
export const modeMap = new Map([
[0, "day"],
[1, "week"],
[2, "month"],
[1, "3days"],
[2, "week"],
[3, "month"]
]);
export const months = [

View File

@ -1,8 +1,9 @@
export interface CalendarEvent {
id?: number | string; // Unique identifier for the event
user?: string;
creatorId?: string;
title: string; // Event title or name
description?: string; // Optional description for the event
description?: string; // Optiional description for the event
start: Date; // Start date and time of the event
end: Date; // End date and time of the event
location?: string; // Optional event location

View File

@ -190,7 +190,10 @@ exports.sendNotificationOnEventCreation = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const {familyId, creatorId, email, title, externalOrigin} = eventData;
const familyId = eventData.familyId;
const creatorId = eventData.creatorId;
const title = eventData.title || '';
const externalOrigin = eventData.externalOrigin || false;
if (!familyId || !creatorId) {
console.error('Missing familyId or creatorId in event data');
@ -209,10 +212,10 @@ exports.sendNotificationOnEventCreation = functions.firestore
transaction.set(batchRef, {
familyId,
creatorId,
externalOrigin,
externalOrigin: externalOrigin || false,
events: [{
id: context.params.eventId,
title: title,
title: title || '',
timestamp: new Date().toISOString()
}],
createdAt: admin.firestore.FieldValue.serverTimestamp(),
@ -224,7 +227,7 @@ exports.sendNotificationOnEventCreation = functions.firestore
transaction.update(batchRef, {
events: [...existingEvents, {
id: context.params.eventId,
title: title,
title: title || '',
timestamp: new Date().toISOString()
}]
});
@ -256,7 +259,7 @@ exports.processEventBatches = functions.pubsub
try {
const pushTokens = await getPushTokensForFamily(
familyId,
externalOrigin ? null : creatorId
creatorId
);
if (pushTokens.length) {
@ -269,24 +272,23 @@ exports.processEventBatches = functions.pubsub
: `${events.length} new events have been added to the family calendar.`;
}
await Promise.all([
sendNotifications(pushTokens, {
title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events',
body: notificationMessage,
data: {
type: externalOrigin ? 'sync' : 'manual',
count: events.length
}
}),
storeNotification({
await sendNotifications(pushTokens, {
title: 'New Family Calendar Events',
body: notificationMessage,
data: {
type: externalOrigin ? 'sync' : 'manual',
familyId,
content: notificationMessage,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
creatorId,
eventCount: events.length
})
]);
count: events.length
}
});
await storeNotification({
type: externalOrigin ? 'sync' : 'manual',
familyId,
content: notificationMessage,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
creatorId,
eventCount: events.length
});
}
await doc.ref.update({
@ -307,71 +309,6 @@ exports.processEventBatches = functions.pubsub
await Promise.all(processPromises);
});
exports.processEventBatchesRealtime = functions.firestore
.document('EventBatches/{batchId}')
.onWrite(async (change, context) => {
const batchData = change.after.data();
if (!batchData || batchData.processed) {
return null;
}
const {familyId, creatorId, externalOrigin, events} = batchData;
try {
const pushTokens = await getPushTokensForFamily(
familyId,
externalOrigin ? null : creatorId
);
if (pushTokens.length) {
let notificationMessage;
if (externalOrigin) {
notificationMessage = `Calendar sync completed: ${events.length} ${events.length === 1 ? 'event has' : 'events have'} been added.`;
} else {
notificationMessage = events.length === 1
? `New event "${events[0].title}" has been added to the family calendar.`
: `${events.length} new events have been added to the family calendar.`;
}
await Promise.all([
sendNotifications(pushTokens, {
title: externalOrigin ? 'Calendar Sync Complete' : 'New Family Calendar Events',
body: notificationMessage,
data: {
type: externalOrigin ? 'sync' : 'manual',
count: events.length
}
}),
storeNotification({
type: externalOrigin ? 'sync' : 'manual',
familyId,
content: notificationMessage,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
creatorId,
eventCount: events.length
})
]);
}
await change.after.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp()
});
} catch (error) {
console.error(`Error processing batch ${context.params.batchId}:`, error);
await change.after.ref.update({
processed: true,
processedAt: admin.firestore.FieldValue.serverTimestamp(),
error: error.message
});
}
});
async function addToUpdateBatch(eventData, eventId) {
const timeWindow = Math.floor(Date.now() / 2000);
const batchId = `${timeWindow}_${eventData.familyId}_${eventData.lastModifiedBy}`;
@ -1126,85 +1063,16 @@ exports.renewGoogleCalendarWatch = functions.pubsub
});
});
exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
const calendarId = req.body.resourceId;
const tokenRefreshInProgress = new Map();
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
exports.cleanupTokenRefreshFlags = functions.pubsub
.schedule('every 5 minutes')
.onRun(() => {
tokenRefreshInProgress.clear();
console.log('[CLEANUP] Cleared all token refresh flags');
return null;
});
try {
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0];
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
if (userData.pushToken) {
await sendNotifications(
Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken],
{
title: "Calendar Sync",
body: "Calendar sync in progress...",
data: {type: 'sync_started'}
}
);
}
console.log("Starting calendar sync...");
const eventCount = await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
if (userData.pushToken) {
const syncMessage = `Calendar sync completed: ${eventCount} ${eventCount === 1 ? 'event has' : 'events have'} been synced.`;
await Promise.all([
sendNotifications(
Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken],
{
title: "Calendar Sync",
body: syncMessage,
data: {
type: 'sync_completed',
count: eventCount
}
}
),
storeNotification({
type: 'sync',
familyId,
content: syncMessage,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
creatorId: userId,
date: new Date()
})
]);
}
res.status(200).send("Sync completed successfully.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
if (userData?.pushToken) {
await sendNotifications(
Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken],
{
title: "Calendar Sync Error",
body: "There was an error syncing your calendar. Please try again later.",
data: {type: 'sync_error'}
}
);
}
res.status(500).send("Failed to send sync notification.");
}
});
async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId}) {
const baseDate = new Date();
@ -1216,7 +1084,7 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c
const batchSize = 50;
try {
console.log(`Fetching events for user: ${email}`);
console.log(`[FETCH] Starting event fetch for user: ${email}`);
do {
let events = [];
@ -1227,19 +1095,32 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c
url.searchParams.set("maxResults", batchSize.toString());
if (pageToken) url.searchParams.set("pageToken", pageToken);
const response = await fetch(url.toString(), {
console.log(`[FETCH] Making request with token: ${token.substring(0, 10)}...`);
let response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.status === 401 && refreshToken) {
console.log(`Token expired for user: ${email}, attempting to refresh`);
const refreshedToken = await refreshGoogleToken(refreshToken);
token = refreshedToken;
console.log(`[TOKEN] Token expired during fetch, refreshing for ${email}`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
if (refreshedGoogleToken) {
console.log(`[TOKEN] Token refreshed successfully during fetch`);
token = refreshedGoogleToken;
if (token) {
return fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, creatorId});
// Update token in Firestore
await db.collection("Profiles").doc(creatorId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
// Retry the request with new token
response = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${refreshedGoogleToken}`,
},
});
}
}
@ -1267,8 +1148,8 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c
events.push(googleEvent);
});
if (events.length > 0) {
console.log(`[FETCH] Saving batch of ${events.length} events`);
await saveEventsToFirestore(events);
totalEvents += events.length;
}
@ -1276,9 +1157,10 @@ async function fetchAndSaveGoogleEvents({token, refreshToken, email, familyId, c
pageToken = data.nextPageToken;
} while (pageToken);
console.log(`[FETCH] Completed with ${totalEvents} total events`);
return totalEvents;
} catch (error) {
console.error(`Error fetching Google Calendar events for ${email}:`, error);
console.error(`[ERROR] Failed fetching events for ${email}:`, error);
throw error;
}
}
@ -1293,8 +1175,20 @@ async function saveEventsToFirestore(events) {
}
async function calendarSync({userId, email, token, refreshToken, familyId}) {
console.log(`Starting calendar sync for user ${userId} with email ${email}`);
console.log(`[SYNC] Starting calendar sync for user ${userId} with email ${email}`);
try {
if (refreshToken) {
console.log(`[TOKEN] Initial token refresh for ${email}`);
const {refreshedGoogleToken} = await refreshGoogleToken(refreshToken);
if (refreshedGoogleToken) {
console.log(`[TOKEN] Token refreshed successfully for ${email}`);
token = refreshedGoogleToken;
await db.collection("Profiles").doc(userId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
}
}
const eventCount = await fetchAndSaveGoogleEvents({
token,
refreshToken,
@ -1302,10 +1196,11 @@ async function calendarSync({userId, email, token, refreshToken, familyId}) {
familyId,
creatorId: userId,
});
console.log("Calendar events synced successfully.");
console.log(`[SYNC] Calendar sync completed. Processed ${eventCount} events`);
return eventCount;
} catch (error) {
console.error(`Error syncing calendar for user ${userId}:`, error);
console.error(`[ERROR] Calendar sync failed for user ${userId}:`, error);
throw error;
}
}
@ -1314,41 +1209,83 @@ exports.sendSyncNotification = functions.https.onRequest(async (req, res) => {
const userId = req.query.userId;
const calendarId = req.body.resourceId;
console.log(`Received notification for user ${userId} with calendar ID ${calendarId}`);
console.log(`[SYNC START] Received notification for user ${userId} with calendar ID ${calendarId}`);
console.log('Request headers:', req.headers);
console.log('Request body:', req.body);
try {
console.log(`[PROFILE] Fetching user profile for ${userId}`);
const userDoc = await db.collection("Profiles").doc(userId).get();
if (!userDoc.exists) {
console.error(`[ERROR] No profile found for user ${userId}`);
return res.status(404).send("User profile not found");
}
const userData = userDoc.data();
let pushTokens = [];
if (userData && userData.pushToken) {
pushTokens = Array.isArray(userData.pushToken) ? userData.pushToken : [userData.pushToken];
}
if (pushTokens.length === 0) {
console.log(`No push tokens found for user ${userId}`);
res.status(200).send("No push tokens found for user.");
return;
}
console.log(`[PROFILE] Found profile data for user ${userId}:`, {
hasGoogleAccounts: !!userData.googleAccounts,
familyId: userData.familyId
});
const {googleAccounts} = userData;
const email = Object.keys(googleAccounts || {})[0];
if (!email) {
console.error(`[ERROR] No Google account found for user ${userId}`);
return res.status(400).send("No Google account found");
}
console.log(`[GOOGLE] Using Google account: ${email}`);
const accountData = googleAccounts[email] || {};
const token = accountData.accessToken;
const refreshToken = accountData.refreshToken;
const familyId = userData.familyId;
console.log("Starting calendar sync...");
await calendarSync({userId, email, token, refreshToken, familyId});
console.log("Calendar sync completed.");
if (!familyId) {
console.error(`[ERROR] No family ID found for user ${userId}`);
return res.status(400).send("No family ID found");
}
res.status(200).send("Sync notification sent.");
console.log(`[SYNC] Starting calendar sync for user ${userId} (family: ${familyId})`);
const syncStartTime = Date.now();
await calendarSync({userId, email, token, refreshToken, familyId});
console.log(`[SYNC] Calendar sync completed in ${Date.now() - syncStartTime}ms`);
console.log(`[HOUSEHOLDS] Fetching households for family ${familyId}`);
const querySnapshot = await db.collection('Households')
.where("familyId", "==", familyId)
.get();
console.log(`[HOUSEHOLDS] Found ${querySnapshot.size} households to update`);
const batch = db.batch();
querySnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLDS] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
console.log(`[HOUSEHOLDS] Committing batch update for ${querySnapshot.size} households`);
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLDS] Batch update completed in ${Date.now() - batchStartTime}ms`);
console.log(`[SYNC COMPLETE] Successfully processed sync for user ${userId}`);
res.status(200).send("Sync completed successfully.");
} catch (error) {
console.error(`Error in sendSyncNotification for user ${userId}:`, error.message);
console.error(`[ERROR] Error in sendSyncNotification for user ${userId}:`, {
errorMessage: error.message,
errorStack: error.stack,
errorCode: error.code
});
res.status(500).send("Failed to send sync notification.");
}
});
async function refreshMicrosoftToken(refreshToken) {
try {
console.log("Refreshing Microsoft token...");
@ -1556,4 +1493,257 @@ exports.microsoftCalendarWebhook = functions.https.onRequest(async (req, res) =>
console.error(`Error processing Microsoft webhook for ${userId}:`, error);
res.status(500).send();
}
});
});
exports.triggerGoogleSync = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'Authentication required'
);
}
try {
const {email} = data;
const userId = context.auth.uid;
const userDoc = await db.collection("Profiles").doc(userId).get();
const userData = userDoc.data();
if (!userData?.googleAccounts?.[email]) {
throw new functions.https.HttpsError(
'failed-precondition',
'No valid Google account found'
);
}
const accountData = userData.googleAccounts[email];
const eventCount = await calendarSync({
userId,
email,
token: accountData.accessToken,
refreshToken: accountData.refreshToken,
familyId: userData.familyId
});
return {
success: true,
eventCount,
message: "Google calendar sync completed successfully"
};
} catch (error) {
console.error('Google sync error:', error);
throw new functions.https.HttpsError('internal', error.message);
}
});
exports.triggerMicrosoftSync = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'Authentication required'
);
}
try {
const {email} = data;
if (!email) {
throw new functions.https.HttpsError(
'invalid-argument',
'Email is required'
);
}
console.log('Starting Microsoft sync for:', {userId: context.auth.uid, email});
const userDoc = await db.collection("Profiles").doc(context.auth.uid).get();
if (!userDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'User profile not found'
);
}
const userData = userDoc.data();
const accountData = userData.microsoftAccounts?.[email];
if (!accountData) {
throw new functions.https.HttpsError(
'failed-precondition',
'Microsoft account not found'
);
}
let {accessToken, refreshToken} = accountData;
// Try to refresh token if it exists
if (refreshToken) {
try {
const refreshedTokens = await refreshMicrosoftToken(refreshToken);
accessToken = refreshedTokens.accessToken;
refreshToken = refreshedTokens.refreshToken || refreshToken;
// Update the stored tokens
await db.collection("Profiles").doc(context.auth.uid).update({
[`microsoftAccounts.${email}`]: {
...accountData,
accessToken,
refreshToken,
lastRefresh: admin.firestore.FieldValue.serverTimestamp()
}
});
} catch (refreshError) {
console.error('Token refresh failed:', refreshError);
throw new functions.https.HttpsError(
'failed-precondition',
'Failed to refresh Microsoft token. Please reconnect your account.',
{requiresReauth: true}
);
}
} else if (!accessToken) {
throw new functions.https.HttpsError(
'failed-precondition',
'Microsoft account requires authentication. Please reconnect your account.',
{requiresReauth: true}
);
}
try {
console.log('Fetching Microsoft events with token');
const eventCount = await fetchAndSaveMicrosoftEvents({
token: accessToken,
refreshToken,
email,
familyId: userData.familyId,
creatorId: context.auth.uid
});
console.log('Microsoft sync completed successfully:', {eventCount});
return {
success: true,
eventCount,
message: "Microsoft calendar sync completed successfully"
};
} catch (syncError) {
// Check if the error is due to invalid token
if (syncError.message?.includes('401') ||
syncError.message?.includes('unauthorized') ||
syncError.message?.includes('invalid_grant')) {
throw new functions.https.HttpsError(
'unauthenticated',
'Microsoft authentication expired. Please reconnect your account.',
{requiresReauth: true}
);
}
throw new functions.https.HttpsError(
'internal',
syncError.message || 'Failed to sync Microsoft calendar',
{originalError: syncError}
);
}
} catch (error) {
console.error('Microsoft sync function error:', error);
if (error instanceof functions.https.HttpsError) {
throw error;
}
throw new functions.https.HttpsError(
'internal',
error.message || 'Unknown error occurred',
{originalError: error}
);
}
});
exports.updateHouseholdTimestampOnEventCreate = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const eventData = snapshot.data();
const familyId = eventData.familyId;
const eventId = context.params.eventId;
console.log(`[HOUSEHOLD_UPDATE] Event created - Processing timestamp updates`, {
eventId,
familyId,
eventTitle: eventData.title || 'Untitled'
});
try {
const householdsSnapshot = await db.collection('Households')
.where('familyId', '==', familyId)
.get();
console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`);
const batch = db.batch();
householdsSnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastUpdateTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, {
familyId,
householdsUpdated: householdsSnapshot.size,
eventId
});
} catch (error) {
console.error(`[HOUSEHOLD_UPDATE] Error updating households for event creation`, {
eventId,
familyId,
error: error.message,
stack: error.stack
});
throw error;
}
});
exports.updateHouseholdTimestampOnEventUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const eventData = change.after.data();
const familyId = eventData.familyId;
const eventId = context.params.eventId;
console.log(`[HOUSEHOLD_UPDATE] Event updated - Processing timestamp updates`, {
eventId,
familyId,
eventTitle: eventData.title || 'Untitled'
});
try {
const householdsSnapshot = await db.collection('Households')
.where('familyId', '==', familyId)
.get();
console.log(`[HOUSEHOLD_UPDATE] Found ${householdsSnapshot.size} households to update for family ${familyId}`);
const batch = db.batch();
householdsSnapshot.docs.forEach((doc) => {
console.log(`[HOUSEHOLD_UPDATE] Adding household ${doc.id} to update batch`);
batch.update(doc.ref, {
lastUpdateTimestamp: admin.firestore.FieldValue.serverTimestamp()
});
});
const batchStartTime = Date.now();
await batch.commit();
console.log(`[HOUSEHOLD_UPDATE] Batch update completed in ${Date.now() - batchStartTime}ms`, {
familyId,
householdsUpdated: householdsSnapshot.size,
eventId
});
} catch (error) {
console.error(`[HOUSEHOLD_UPDATE] Error updating households for event update`, {
eventId,
familyId,
error: error.message,
stack: error.stack
});
throw error;
}
});

View File

@ -1,10 +1,29 @@
import {ProfileType} from "@/contexts/AuthContext";
export type ProfileType = 'parent' | 'child';
export interface User {
uid: string;
email: string | null;
export interface CalendarAccount {
accessToken: string;
refreshToken?: string;
resourceId?: string;
email?: string;
expiresAt?: Date;
}
export interface GoogleAccount extends CalendarAccount {
scope?: string;
}
export interface MicrosoftAccount extends CalendarAccount {
subscriptionId?: string;
}
export interface AppleAccount extends CalendarAccount {
identityToken?: string;
}
export type CalendarAccounts = {
[email: string]: GoogleAccount | MicrosoftAccount | AppleAccount;
};
export interface UserProfile {
userType: ProfileType;
firstName: string;
@ -21,23 +40,7 @@ export interface UserProfile {
eventColor?: string | null;
timeZone?: string | null;
firstDayOfWeek?: string | null;
googleAccounts?: Object;
microsoftAccounts?: Object;
appleAccounts?: Object;
}
export interface ParentProfile extends UserProfile {
userType: ProfileType.PARENT;
childrenIds: string[];
}
export interface ChildProfile extends UserProfile {
userType: ProfileType.CHILD;
birthday: Date;
parentId: string;
}
export interface CaregiverProfile extends UserProfile {
userType: ProfileType.CAREGIVER;
contact: string;
}
googleAccounts?: { [email: string]: GoogleAccount };
microsoftAccounts?: { [email: string]: MicrosoftAccount };
appleAccounts?: { [email: string]: AppleAccount };
}

View File

@ -1,51 +1,82 @@
import {useQuery} from "react-query";
import {useQuery, useQueryClient} from "react-query";
import firestore from "@react-native-firebase/firestore";
import {useAuthContext} from "@/contexts/AuthContext";
import {useAtomValue} from "jotai";
import {isFamilyViewAtom} from "@/components/pages/calendar/atoms";
import {colorMap} from "@/constants/colorMap";
import {uuidv4} from "@firebase/util";
import {useEffect} from "react";
export const useGetEvents = () => {
const {user, profileData} = useAuthContext();
const isFamilyView = useAtomValue(isFamilyViewAtom);
const queryClient = useQueryClient();
useEffect(() => {
if (!profileData?.familyId) return;
console.log(`[SYNC] Setting up sync listener for family: ${profileData.familyId}`);
const unsubscribe = firestore()
.collection('Households')
.where("familyId", "==", profileData.familyId)
.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'modified') {
const data = change.doc.data();
if (data?.lastSyncTimestamp) {
console.log(`[SYNC] Change detected at ${data.lastSyncTimestamp.toDate()}`);
console.log(`[SYNC] Household ${change.doc.id} triggered refresh`);
queryClient.invalidateQueries(["events", user?.uid, isFamilyView]);
}
}
});
}, (error) => {
console.error('[SYNC] Listener error:', error);
});
return () => {
console.log('[SYNC] Cleaning up sync listener');
unsubscribe();
};
}, [profileData?.familyId, user?.uid, isFamilyView, queryClient]);
return useQuery({
queryKey: ["events", user?.uid, isFamilyView],
queryFn: async () => {
console.log(`Fetching events - Family View: ${isFamilyView}, User: ${user?.uid}`);
const db = firestore();
const userId = user?.uid;
const familyId = profileData?.familyId;
let allEvents = [];
if (isFamilyView) {
// Get public family events
const publicFamilyEvents = await db.collection("Events")
.where("familyId", "==", familyId)
.where("private", "==", false)
.get();
// Get private events where user is creator
const privateCreatorEvents = await db.collection("Events")
.where("familyId", "==", familyId)
.where("private", "==", true)
.where("creatorId", "==", userId)
.get();
// Get private events where user is attendee
const privateAttendeeEvents = await db.collection("Events")
.where("private", "==", true)
.where("attendees", "array-contains", userId)
.get();
console.log(`Found ${publicFamilyEvents.size} public events, ${privateCreatorEvents.size} private creator events, ${privateAttendeeEvents.size} private attendee events`);
allEvents = [
...publicFamilyEvents.docs.map(doc => doc.data()),
...privateCreatorEvents.docs.map(doc => doc.data()),
...privateAttendeeEvents.docs.map(doc => doc.data())
...publicFamilyEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...privateCreatorEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...privateAttendeeEvents.docs.map(doc => ({...doc.data(), id: doc.id}))
];
} else {
// Personal view: Only show events where user is creator or attendee
const [creatorEvents, attendeeEvents] = await Promise.all([
db.collection("Events")
.where("creatorId", "==", userId)
@ -55,24 +86,28 @@ export const useGetEvents = () => {
.get()
]);
console.log(`Found ${creatorEvents.size} creator events, ${attendeeEvents.size} attendee events`);
allEvents = [
...creatorEvents.docs.map(doc => doc.data()),
...attendeeEvents.docs.map(doc => doc.data())
...creatorEvents.docs.map(doc => ({...doc.data(), id: doc.id})),
...attendeeEvents.docs.map(doc => ({...doc.data(), id: doc.id}))
];
}
// Ensure uniqueness
const uniqueEventsMap = new Map();
allEvents.forEach(event => {
if (event.id) {
uniqueEventsMap.set(event.id, event);
} else {
uniqueEventsMap.set(uuidv4(), event);
const newId = uuidv4();
console.log(`Generated new ID for event without ID: ${newId}`);
uniqueEventsMap.set(newId, {...event, id: newId});
}
});
// Map events with creator colors
return await Promise.all(
console.log(`Processing ${uniqueEventsMap.size} unique events`);
const processedEvents = await Promise.all(
Array.from(uniqueEventsMap.values()).map(async (event) => {
const profileSnapshot = await db
.collection("Profiles")
@ -96,9 +131,15 @@ export const useGetEvents = () => {
};
})
);
console.log(`Events processing completed, returning ${processedEvents.length} events`);
return processedEvents;
},
staleTime: Infinity,
cacheTime: Infinity,
staleTime: 5 * 60 * 1000,
cacheTime: 30 * 60 * 1000,
keepPreviousData: true,
onError: (error) => {
console.error('Error fetching events:', error);
}
});
};
};

View File

@ -2,7 +2,7 @@ import {useAuthContext} from "@/contexts/AuthContext";
import {useEffect} from "react";
import {useUpdateUserData} from "@/hooks/firebase/useUpdateUserData";
import {useFetchAndSaveGoogleEvents} from "@/hooks/useFetchAndSaveGoogleEvents";
import {useFetchAndSaveOutlookEvents} from "@/hooks/useFetchAndSaveOutlookEvents";
import {useFetchAndSaveMicrosoftEvents} from "@/hooks/useFetchAndSaveOutlookEvents";
import {useFetchAndSaveAppleEvents} from "@/hooks/useFetchAndSaveAppleEvents";
import * as WebBrowser from "expo-web-browser";
import * as Google from "expo-auth-session/providers/google";
@ -10,14 +10,12 @@ import * as AuthSession from "expo-auth-session";
import * as AppleAuthentication from "expo-apple-authentication";
import * as Notifications from 'expo-notifications';
import {useQueryClient} from "react-query";
import {AppleAccount, GoogleAccount, MicrosoftAccount} from "@/hooks/firebase/types/profileTypes";
const googleConfig = {
androidClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
iosClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
webClientId:
"406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
androidClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
iosClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
webClientId: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com",
scopes: [
"email",
"profile",
@ -39,18 +37,32 @@ const microsoftConfig = {
"Calendars.ReadWrite",
"User.Read",
],
authorizationEndpoint:
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
authorizationEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
};
interface SyncResponse {
success: boolean;
eventCount?: number;
error?: string;
}
interface CalendarSyncResult {
data: {
success: boolean;
eventCount: number;
message?: string;
error?: string;
}
}
export const useCalSync = () => {
const {profileData} = useAuthContext();
const queryClient = useQueryClient();
const {mutateAsync: updateUserData} = useUpdateUserData();
const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = useFetchAndSaveGoogleEvents();
const {mutateAsync: fetchAndSaveOutlookEvents, isLoading: isSyncingOutlook} = useFetchAndSaveOutlookEvents();
const {mutateAsync: fetchAndSaveOutlookEvents, isLoading: isSyncingOutlook} = useFetchAndSaveMicrosoftEvents();
const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = useFetchAndSaveAppleEvents();
WebBrowser.maybeCompleteAuthSession();
@ -72,134 +84,106 @@ export const useCalSync = () => {
}
);
console.log(response)
const userInfo = await userInfoResponse.json();
const googleMail = userInfo.email;
let googleAccounts = profileData?.googleAccounts || {};
const updatedGoogleAccounts = {
...googleAccounts,
[googleMail]: {accessToken, refreshToken},
const googleAccount: GoogleAccount = {
accessToken,
refreshToken,
email: googleMail,
expiresAt: new Date(Date.now() + 3600 * 1000),
scope: googleConfig.scopes.join(' ')
};
console.log({refreshToken})
await updateUserData({
newUserData: {googleAccounts: updatedGoogleAccounts},
newUserData: {
googleAccounts: {
...profileData?.googleAccounts,
[googleMail]: googleAccount
}
},
});
await fetchAndSaveGoogleEvents({
token: accessToken,
refreshToken: refreshToken,
email: googleMail,
});
await fetchAndSaveGoogleEvents({email: googleMail});
}
} catch (error) {
console.error("Error during Google sign-in:", error);
throw error;
}
};
const handleMicrosoftSignIn = async () => {
try {
console.log("Starting Microsoft sign-in...");
const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true, // Enable PKCE
usePKCE: true,
});
console.log("Auth request created:", authRequest);
const authResult = await authRequest.promptAsync({
authorizationEndpoint: microsoftConfig.authorizationEndpoint,
});
console.log("Auth result:", authResult);
if (authResult.type === "success" && authResult.params?.code) {
const code = authResult.params.code;
console.log("Authorization code received:", code);
// Exchange authorization code for tokens
const tokenResponse = await fetch(microsoftConfig.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `client_id=${
microsoftConfig.clientId
}&redirect_uri=${encodeURIComponent(
body: `client_id=${microsoftConfig.clientId}&redirect_uri=${encodeURIComponent(
microsoftConfig.redirectUri
)}&grant_type=authorization_code&code=${code}&code_verifier=${
authRequest.codeVerifier
}&scope=${encodeURIComponent(
"https://graph.microsoft.com/Calendars.ReadWrite offline_access User.Read"
)}`,
}&scope=${encodeURIComponent(microsoftConfig.scopes.join(' '))}`,
});
console.log("Token response status:", tokenResponse.status);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error("Token exchange failed:", errorText);
return;
throw new Error(await tokenResponse.text());
}
const tokenData = await tokenResponse.json();
console.log("Token data received:", tokenData);
if (tokenData?.access_token) {
console.log("Access token received, fetching user info...");
// Fetch user info from Microsoft Graph API to get the email
const userInfoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me",
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
);
const userInfo = await userInfoResponse.json();
console.log("User info received:", userInfo);
if (userInfo.error) {
console.error("Error fetching user info:", userInfo.error);
} else {
const outlookMail = userInfo.mail || userInfo.userPrincipalName;
let microsoftAccounts = profileData?.microsoftAccounts;
const updatedMicrosoftAccounts = microsoftAccounts
? {...microsoftAccounts, [outlookMail]: tokenData.access_token}
: {[outlookMail]: tokenData.access_token};
await updateUserData({
newUserData: {microsoftAccounts: updatedMicrosoftAccounts},
});
await fetchAndSaveOutlookEvents(
tokenData.access_token,
outlookMail
);
console.log("User data updated successfully.");
const userInfoResponse = await fetch(
"https://graph.microsoft.com/v1.0/me",
{
headers: {
Authorization: `Bearer ${tokenData.access_token}`,
},
}
}
} else {
console.warn("Authentication was not successful:", authResult);
);
const userInfo = await userInfoResponse.json();
const outlookMail = userInfo.mail || userInfo.userPrincipalName;
const microsoftAccount: MicrosoftAccount = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
email: outlookMail,
expiresAt: new Date(Date.now() + tokenData.expires_in * 1000),
};
await updateUserData({
newUserData: {
microsoftAccounts: {
...profileData?.microsoftAccounts,
[outlookMail]: microsoftAccount
}
},
});
await fetchAndSaveOutlookEvents({email: outlookMail});
}
} catch (error) {
console.error("Error during Microsoft sign-in:", error);
throw error;
}
};
const handleAppleSignIn = async () => {
try {
console.log("Starting Apple Sign-in...");
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.EMAIL,
@ -207,117 +191,124 @@ export const useCalSync = () => {
],
});
console.log("Apple sign-in result:", credential);
alert(JSON.stringify(credential))
const appleToken = credential.identityToken;
const appleMail = credential.email!;
if (appleToken) {
console.log("Apple ID token received. Fetch user info if needed...");
const appleAccount: AppleAccount = {
accessToken: appleToken,
email: appleMail,
identityToken: credential.identityToken!,
expiresAt: new Date(Date.now() + 3600 * 1000)
};
let appleAcounts = profileData?.appleAccounts;
const updatedAppleAccounts = appleAcounts
? {...appleAcounts, [appleMail]: appleToken}
: {[appleMail]: appleToken};
const updatedAppleAccounts = {
...profileData?.appleAccounts,
[appleMail]: appleAccount
};
await updateUserData({
newUserData: {appleAccounts: updatedAppleAccounts},
});
console.log("User data updated with Apple ID token.");
await fetchAndSaveAppleEvents({token: appleToken, email: appleMail!});
} else {
console.warn(
"Apple authentication was not successful or email was hidden."
);
await fetchAndSaveAppleEvents({email: appleMail});
}
} catch (error) {
console.error("Error during Apple Sign-in:", error);
}
};
const resyncAllCalendars = async (): Promise<void> => {
try {
const syncPromises: Promise<void>[] = [];
const results: SyncResponse[] = [];
if (profileData?.googleAccounts) {
console.log(profileData.googleAccounts)
for (const [email, emailAcc] of Object.entries(profileData.googleAccounts)) {
if(emailAcc?.accessToken) {
syncPromises.push(fetchAndSaveGoogleEvents({ token: emailAcc?.accessToken, refreshToken: emailAcc?.refreshToken, email }));
for (const email of Object.keys(profileData.googleAccounts)) {
try {
const result = await fetchAndSaveGoogleEvents({email});
results.push({
success: result.success,
eventCount: result.eventCount
});
} catch (error: any) {
console.error(`Failed to sync Google calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
}
}
}
if (profileData?.microsoftAccounts) {
for (const [email, accessToken] of Object.entries(profileData.microsoftAccounts)) {
syncPromises.push(fetchAndSaveOutlookEvents(accessToken, email));
for (const email of Object.keys(profileData.microsoftAccounts)) {
try {
const result = await fetchAndSaveOutlookEvents({email});
results.push({
success: result.success,
eventCount: result.eventCount
});
} catch (error: any) {
console.error(`Failed to sync Microsoft calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
}
}
}
if (profileData?.appleAccounts) {
for (const [email, token] of Object.entries(profileData.appleAccounts)) {
syncPromises.push(fetchAndSaveAppleEvents({ token, email }));
for (const email of Object.keys(profileData.appleAccounts)) {
try {
const result = await fetchAndSaveAppleEvents({email});
results.push({
success: true,
});
} catch (error: any) {
console.error(`Failed to sync Apple calendar ${email}:`, error);
results.push({
success: false,
error: error.message
});
}
}
}
await Promise.all(syncPromises);
console.log("All calendars have been resynced.");
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
const totalEvents = results.reduce((sum, r) => sum + (r.eventCount || 0), 0);
if (failCount > 0) {
console.error(`${failCount} calendar syncs failed, ${successCount} succeeded`);
results.filter(r => !r.success).forEach(r => {
console.error('Sync failed:', r.error);
});
} else if (successCount > 0) {
console.log(`Successfully synced ${successCount} calendars with ${totalEvents} total events`);
} else {
console.log("No calendars to sync");
}
} catch (error) {
console.error("Error resyncing calendars:", error);
console.error("Error in resyncAllCalendars:", error);
throw error;
}
};
let isConnectedToGoogle = false;
if (profileData?.googleAccounts) {
Object.values(profileData?.googleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToGoogle = true;
return;
}
});
}
let isConnectedToMicrosoft = false;
const microsoftAccounts = profileData?.microsoftAccounts;
if (microsoftAccounts) {
Object.values(profileData?.microsoftAccounts).forEach((item) => {
if (item !== null) {
isConnectedToMicrosoft = true;
return;
}
});
}
let isConnectedToApple = false;
if (profileData?.appleAccounts) {
Object.values(profileData?.appleAccounts).forEach((item) => {
if (item !== null) {
isConnectedToApple = true;
return;
}
});
}
const isConnectedToGoogle = Object.values(profileData?.googleAccounts || {}).some(account => !!account);
const isConnectedToMicrosoft = Object.values(profileData?.microsoftAccounts || {}).some(account => !!account);
const isConnectedToApple = Object.values(profileData?.appleAccounts || {}).some(account => !!account);
useEffect(() => {
const handleNotification = async (notification: Notifications.Notification) => {
const eventId = notification?.request?.content?.data?.eventId;
// await resyncAllCalendars();
queryClient.invalidateQueries(["events"]);
};
const sub = Notifications.addNotificationReceivedListener(handleNotification);
return () => sub.remove();
}, []);
return {
handleAppleSignIn,
handleMicrosoftSignIn,
@ -334,5 +325,5 @@ export const useCalSync = () => {
isSyncingApple,
resyncAllCalendars,
isSyncing: isSyncingApple || isSyncingOutlook || isSyncingGoogle
}
}
};
};

View File

@ -1,107 +1,37 @@
import { useMutation, useQueryClient } from "react-query";
import { fetchGoogleCalendarEvents } from "@/calendar-integration/google-calendar-utils";
import { useAuthContext } from "@/contexts/AuthContext";
import { useCreateEventsFromProvider } from "@/hooks/firebase/useCreateEvent";
import { useClearTokens } from "@/hooks/firebase/useClearTokens";
import { useUpdateUserData } from "@/hooks/firebase/useUpdateUserData";
import {useMutation, useQueryClient} from "react-query";
import {useAuthContext} from "@/contexts/AuthContext";
import functions from "@react-native-firebase/functions";
interface SyncResponse {
success: boolean;
eventCount: number;
message?: string;
}
export const useFetchAndSaveGoogleEvents = () => {
const queryClient = useQueryClient();
const { profileData } = useAuthContext();
const { mutateAsync: createEventsFromProvider } = useCreateEventsFromProvider();
const { mutateAsync: clearToken } = useClearTokens();
const { mutateAsync: updateUserData } = useUpdateUserData();
return useMutation({
mutationKey: ["fetchAndSaveGoogleEvents", "sync"],
mutationFn: async ({ token, email, date, refreshToken }: { token?: string; refreshToken?: string; email?: string; date?: Date }) => {
const baseDate = date || new Date();
const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString().slice(0, -5) + "Z";
const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString().slice(0, -5) + "Z";
mutationFn: async ({ email }: { email?: string }) => {
if (!email || !profileData?.googleAccounts?.[email]) {
throw new Error("No valid Google account found");
}
console.log("Token: ", token);
try {
const response = await functions()
.httpsCallable('triggerGoogleSync')({ email });
const tryFetchEvents = async (isRetry = false) => {
try {
const response = await fetchGoogleCalendarEvents(
token,
email,
profileData?.familyId,
timeMin,
timeMax
);
if (!response.success) {
await clearToken({ email: email!, provider: "google" });
return; // Stop refetching if clearing the token
}
console.log("Google Calendar events fetched:", response);
const items = response?.googleEvents?.map((item) => {
if (item.allDay) {
item.startDate = new Date(item.startDate.setHours(0, 0, 0, 0));
item.endDate = item.startDate;
}
return item;
}) || [];
await createEventsFromProvider(items);
} catch (error) {
console.error("Error fetching Google Calendar events:", error);
if (!isRetry) {
const refreshedToken = await handleRefreshToken(email, refreshToken);
if (refreshedToken) {
await updateUserData({
newUserData: {
googleAccounts: {
...profileData.googleAccounts,
[email!]: { ...profileData.googleAccounts[email!], accessToken: refreshedToken },
},
},
});
return tryFetchEvents(true); // Retry once after refreshing
} else {
await clearToken({ email: email!, provider: "google" });
console.error(`Token refresh failed; token cleared for ${email}`);
throw error;
}
} else {
console.error(`Retry failed after refreshing token for user ${profileData?.email}:`, error.message);
throw error;
}
}
};
return tryFetchEvents();
return response.data as SyncResponse;
} catch (error: any) {
console.error("Error initiating Google Calendar sync:", error);
throw new Error(error.details?.message || error.message || "Failed to sync calendar");
}
},
onSuccess: () => {
onSuccess: (data) => {
queryClient.invalidateQueries(["events"]);
},
console.log(`Successfully synced ${data.eventCount} events`);
}
});
};
async function handleRefreshToken(email: string, refreshToken: string) {
if (!refreshToken) return null;
try {
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",
}),
});
const data = await response.json();
return data.access_token;
} catch (error) {
console.error("Error refreshing Google token:", error);
return null;
}
}

View File

@ -1,41 +1,144 @@
import {useMutation, useQueryClient} from "react-query";
import {useAuthContext} from "@/contexts/AuthContext";
import {useCreateEventsFromProvider} from "@/hooks/firebase/useCreateEvent";
import {fetchMicrosoftCalendarEvents} from "@/calendar-integration/microsoft-calendar-utils";
import { useMutation, useQueryClient } from "react-query";
import { useAuthContext } from "@/contexts/AuthContext";
import { useSetUserData } from "@/hooks/firebase/useSetUserData";
import functions from '@react-native-firebase/functions';
import * as AuthSession from 'expo-auth-session';
export const useFetchAndSaveOutlookEvents = () => {
const queryClient = useQueryClient()
const {profileData} = useAuthContext();
const {mutateAsync: createEventsFromProvider} = useCreateEventsFromProvider();
interface SyncResponse {
success: boolean;
eventCount: number;
message?: string;
}
return useMutation({
interface SyncError extends Error {
code?: string;
details?: {
requiresReauth?: boolean;
message?: string;
};
}
const microsoftConfig = {
clientId: "13c79071-1066-40a9-9f71-b8c4b138b4af",
scopes: [
"openid",
"profile",
"email",
"offline_access",
"Calendars.ReadWrite",
"User.Read",
],
redirectUri: AuthSession.makeRedirectUri({path: "settings"})
};
export const useFetchAndSaveMicrosoftEvents = () => {
const queryClient = useQueryClient();
const { profileData } = useAuthContext();
const { mutateAsync: setUserData } = useSetUserData();
const handleReauth = async (email: string) => {
try {
const authRequest = new AuthSession.AuthRequest({
clientId: microsoftConfig.clientId,
scopes: microsoftConfig.scopes,
redirectUri: microsoftConfig.redirectUri,
responseType: AuthSession.ResponseType.Code,
usePKCE: true,
});
const result = await authRequest.promptAsync({
authorizationEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
});
if (result.type === 'success' && result.params?.code) {
const tokenResponse = 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: microsoftConfig.clientId,
scope: microsoftConfig.scopes.join(' '),
code: result.params.code,
redirect_uri: microsoftConfig.redirectUri,
grant_type: 'authorization_code',
code_verifier: authRequest.codeVerifier || '',
}),
});
const tokens = await tokenResponse.json();
await setUserData({
newUserData: {
microsoftAccounts: {
...profileData?.microsoftAccounts,
[email]: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
email,
}
}
}
});
return true;
}
return false;
} catch (error) {
console.error('Microsoft reauth error:', error);
return false;
}
};
return useMutation<SyncResponse, SyncError, { email?: string }>({
mutationKey: ["fetchAndSaveOutlookEvents", "sync"],
mutationFn: async ({token, email, date}: { token?: string; email?: string, date?: Date }) => {
const baseDate = date || new Date();
const timeMin = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() - 1));
const timeMax = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() + 1));
mutationFn: async ({ email }: { email?: string }) => {
if (!email) {
throw new Error("Email is required");
}
console.log("Token: ", token ?? profileData?.microsoftToken);
if (!profileData?.microsoftAccounts?.[email]) {
throw new Error("No valid Microsoft account found");
}
try {
const response = await fetchMicrosoftCalendarEvents(
token ?? profileData?.microsoftToken,
email ?? profileData?.outlookMail,
profileData?.familyId,
timeMin.toISOString().slice(0, -5) + "Z",
timeMax.toISOString().slice(0, -5) + "Z"
);
const response = await functions()
.httpsCallable('triggerMicrosoftSync')({ email });
return response.data as SyncResponse;
} catch (error: any) {
console.error("Microsoft sync error:", error);
// Check if we need to reauthenticate
if (error.details?.requiresReauth ||
error.code === 'functions/failed-precondition' ||
error.code === 'functions/unauthenticated') {
console.log('Attempting Microsoft reauth...');
const reauthSuccessful = await handleReauth(email);
if (reauthSuccessful) {
// Retry the sync with new tokens
console.log('Retrying sync after reauth...');
const retryResponse = await functions()
.httpsCallable('triggerMicrosoftSync')({ email });
return retryResponse.data as SyncResponse;
}
}
console.log(response);
const items = response ?? [];
await createEventsFromProvider(items);
} catch (error) {
console.error("Error fetching and saving Outlook events: ", error);
throw error;
}
},
onSuccess: () => {
queryClient.invalidateQueries(["events"])
onSuccess: (data) => {
queryClient.invalidateQueries(["events"]);
console.log(`Successfully synced ${data.eventCount} Microsoft events`);
},
onError: (error) => {
console.error('Microsoft sync failed:', {
message: error.message,
code: error.code,
details: error.details
});
}
});
};

View File

@ -3,10 +3,16 @@ import { useAuthContext } from "@/contexts/AuthContext";
import { useAtomValue } from "jotai";
import { useFetchAndSaveGoogleEvents } from "./useFetchAndSaveGoogleEvents";
import { useFetchAndSaveAppleEvents } from "./useFetchAndSaveAppleEvents";
import { useFetchAndSaveOutlookEvents } from "./useFetchAndSaveOutlookEvents";
import { useFetchAndSaveMicrosoftEvents } from "./useFetchAndSaveOutlookEvents";
import { selectedDateAtom } from "@/components/pages/calendar/atoms";
import { addDays, subDays, isBefore, isAfter, format } from "date-fns";
interface SyncResponse {
success: boolean;
eventCount?: number;
error?: string;
}
export const useSyncEvents = () => {
const { profileData } = useAuthContext();
const selectedDate = useAtomValue(selectedDateAtom);
@ -15,12 +21,18 @@ export const useSyncEvents = () => {
const [lowerBoundDate, setLowerBoundDate] = useState<Date>(subDays(selectedDate, 6 * 30));
const [upperBoundDate, setUpperBoundDate] = useState<Date>(addDays(selectedDate, 6 * 30));
const [isSyncing, setIsSyncing] = useState(false);
const [error, setError] = useState(null);
const [error, setError] = useState<Error | null>(null);
const [syncStats, setSyncStats] = useState<{
total: number;
success: number;
failed: number;
events: number;
}>({ total: 0, success: 0, failed: 0, events: 0 });
const syncedRanges = useState<Set<string>>(new Set())[0];
const { mutateAsync: fetchAndSaveGoogleEvents } = useFetchAndSaveGoogleEvents();
const { mutateAsync: fetchAndSaveOutlookEvents } = useFetchAndSaveOutlookEvents();
const { mutateAsync: fetchAndSaveOutlookEvents } = useFetchAndSaveMicrosoftEvents();
const { mutateAsync: fetchAndSaveAppleEvents } = useFetchAndSaveAppleEvents();
const generateRangeKey = (startDate: Date, endDate: Date) => {
@ -41,26 +53,71 @@ export const useSyncEvents = () => {
}
if (isBefore(selectedDate, lowerBoundDate) || isAfter(selectedDate, upperBoundDate)) {
const results: SyncResponse[] = [];
const stats = { total: 0, success: 0, failed: 0, events: 0 };
try {
const googleEvents = Object.entries(profileData?.googleAccounts || {}).map(([email, { accessToken }]) =>
fetchAndSaveGoogleEvents({ token: accessToken, email, date: selectedDate })
);
if (profileData?.googleAccounts) {
for (const [email] of Object.entries(profileData.googleAccounts)) {
try {
stats.total++;
const result = await fetchAndSaveGoogleEvents({ email }) as SyncResponse;
if (result.success) {
stats.success++;
stats.events += result.eventCount || 0;
} else {
stats.failed++;
}
results.push(result);
} catch (err) {
stats.failed++;
console.error(`Failed to sync Google calendar for ${email}:`, err);
}
}
}
const outlookEvents = Object.entries(profileData?.microsoftAccounts || {}).map(([email, token]) =>
fetchAndSaveOutlookEvents({ token, email, date: selectedDate })
);
if (profileData?.microsoftAccounts) {
for (const [email] of Object.entries(profileData.microsoftAccounts)) {
try {
stats.total++;
const result = await fetchAndSaveOutlookEvents({ email });
if (result.success) {
stats.success++;
stats.events += result.eventCount || 0;
} else {
stats.failed++;
}
results.push(result);
} catch (err) {
stats.failed++;
console.error(`Failed to sync Microsoft calendar for ${email}:`, err);
}
}
}
const appleEvents = Object.entries(profileData?.appleAccounts || {}).map(([email, token]) =>
fetchAndSaveAppleEvents({ token, email, date: selectedDate })
);
await Promise.all([...googleEvents, ...outlookEvents, ...appleEvents]);
if (profileData?.appleAccounts) {
for (const [email] of Object.entries(profileData.appleAccounts)) {
try {
stats.total++;
const result = await fetchAndSaveAppleEvents({ email });
} catch (err) {
stats.failed++;
console.error(`Failed to sync Apple calendar for ${email}:`, err);
}
}
}
setSyncStats(stats);
setLastSyncDate(selectedDate);
setLowerBoundDate(newLowerBound);
setUpperBoundDate(newUpperBound);
syncedRanges.add(rangeKey);
} catch (err) {
if (stats.failed > 0) {
throw new Error(`Failed to sync ${stats.failed} calendars`);
}
} catch (err: any) {
console.error("Error syncing events:", err);
setError(err);
} finally {
@ -69,7 +126,16 @@ export const useSyncEvents = () => {
} else {
setIsSyncing(false);
}
}, [selectedDate, lowerBoundDate, upperBoundDate, profileData, fetchAndSaveGoogleEvents, fetchAndSaveOutlookEvents, fetchAndSaveAppleEvents, syncedRanges]);
}, [
selectedDate,
lowerBoundDate,
upperBoundDate,
profileData,
fetchAndSaveGoogleEvents,
fetchAndSaveOutlookEvents,
fetchAndSaveAppleEvents,
syncedRanges
]);
useEffect(() => {
syncEvents();
@ -81,5 +147,6 @@ export const useSyncEvents = () => {
lastSyncDate,
lowerBoundDate,
upperBoundDate,
syncStats,
};
};