const {onRequest} = require("firebase-functions/v2/https"); const {getAuth} = require("firebase-admin/auth"); const {getFirestore} = require("firebase-admin/firestore"); const logger = require("firebase-functions/logger"); const functions = require('firebase-functions'); const admin = require('firebase-admin'); const {Expo} = require('expo-server-sdk'); admin.initializeApp(); const db = admin.firestore(); let expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN }); let notificationTimeout = null; let eventCount = 0; let pushTokens = []; exports.sendNotificationOnEventCreation = functions.firestore .document('Events/{eventId}') .onCreate(async (snapshot, context) => { const eventData = snapshot.data(); const {familyId, creatorId} = eventData; if (!familyId || !creatorId) { console.error('Missing familyId or creatorId in event data'); return; } if (!pushTokens.length) { pushTokens = await getPushTokensForFamilyExcludingCreator(familyId, creatorId); if (!pushTokens.length) { console.log('No push tokens available for the event.'); return; } } // Increment event count for debouncing eventCount++; if (notificationTimeout) { clearTimeout(notificationTimeout); // Reset the timer if events keep coming } // Set a debounce time (e.g., 5 seconds) notificationTimeout = setTimeout(async () => { const eventMessage = eventCount === 1 ? `An event "${eventData.title}" has been added. Check it out!` : `${eventCount} new events have been added.`; let messages = []; for (let pushToken of pushTokens) { if (!Expo.isExpoPushToken(pushToken)) { console.error(`Push token ${pushToken} is not a valid Expo push token`); continue; } messages.push({ to: pushToken, sound: 'default', title: 'New Events Added!', body: eventMessage, data: {eventId: context.params.eventId}, }); } let chunks = expo.chunkPushNotifications(messages); let tickets = []; for (let chunk of chunks) { try { let ticketChunk = await expo.sendPushNotificationsAsync(chunk); tickets.push(...ticketChunk); for (let ticket of ticketChunk) { if (ticket.status === 'ok') { console.log('Notification successfully sent:', ticket.id); } else if (ticket.status === 'error') { console.error(`Notification error: ${ticket.message}`); if (ticket.details && ticket.details.error === 'DeviceNotRegistered') { await removeInvalidPushToken(ticket.to); } } } } catch (error) { console.error('Error sending notification:', error); } } eventCount = 0; // Reset the event count after sending notification pushTokens = []; // Reset push tokens for the next round }, 5000); // Debounce time (5 seconds) }); exports.createSubUser = onRequest(async (request, response) => { const authHeader = request.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { logger.warn("Missing or incorrect Authorization header", {authHeader}); response.status(401).json({error: 'Unauthorized'}); return; } try { const token = authHeader.split('Bearer ')[1]; logger.info("Verifying ID token", {token}); let decodedToken; try { decodedToken = await getAuth().verifyIdToken(token); logger.info("ID token verified successfully", {uid: decodedToken.uid}); } catch (verifyError) { logger.error("ID token verification failed", {error: verifyError.message}); response.status(401).json({error: 'Unauthorized: Invalid token'}); return; } logger.info("Processing user creation", {requestBody: request.body.data}); const {userType, firstName, lastName, email, password, familyId} = request.body.data; if (!email || !password || !firstName || !userType || !familyId) { logger.warn("Missing required fields in request body", {requestBody: request.body.data}); response.status(400).json({error: "Missing required fields"}); return; } let userRecord; try { userRecord = await getAuth().createUser({ email, password, displayName: `${firstName} ${lastName}`, }); logger.info("User record created", {userId: userRecord.uid}); } catch (createUserError) { logger.error("User creation failed", {error: createUserError.message}); response.status(500).json({error: "Failed to create user"}); return; } const userProfile = { userType, firstName, lastName, familyId, email, uid: userRecord.uid }; try { await getFirestore().collection("Profiles").doc(userRecord.uid).set(userProfile); logger.info("User profile saved to Firestore", {userId: userRecord.uid}); } catch (firestoreError) { logger.error("Failed to save user profile to Firestore", {error: firestoreError.message}); response.status(500).json({error: "Failed to save user profile"}); return; } response.status(200).json({ data: { message: "User created successfully", userId: userRecord.uid, } }); } catch (error) { logger.error("Error in createSubUser function", {error: error.message}); response.status(500).json({data: {error: error.message}}); } }); exports.generateCustomToken = onRequest(async (request, response) => { try { const {userId} = request.body.data; console.log("Generating custom token for userId", {userId}); if (!userId) { response.status(400).json({error: 'Missing userId'}); return; } const customToken = await getAuth().createCustomToken(userId); response.status(200).json({data: {token: customToken}}); } catch (error) { console.error("Error generating custom token", {error: error.message}); response.status(500).json({error: "Failed to generate custom token"}); } }); exports.refreshTokens = functions.pubsub.schedule('every 12 hours').onRun(async (context) => { console.log('Running token refresh job...'); const profilesSnapshot = await db.collection('Profiles').get(); profilesSnapshot.forEach(async (profileDoc) => { const profileData = profileDoc.data(); if (profileData.googleAccounts) { try { for (const googleEmail of Object.keys(profileData?.googleAccounts)) { const googleToken = profileData?.googleAccounts?.[googleEmail]; if (googleToken) { const refreshedGoogleToken = await refreshGoogleToken(googleToken); const updatedGoogleAccounts = {...profileData.googleAccounts, [googleEmail]: refreshedGoogleToken}; await profileDoc.ref.update({googleAccounts: updatedGoogleAccounts}); console.log(`Google token updated for user ${profileDoc.id}`); } } } catch (error) { console.error(`Error refreshing Google token for user ${profileDoc.id}:`, error.message); } } if (profileData.microsoftToken) { try { const refreshedMicrosoftToken = await refreshMicrosoftToken(profileData.microsoftToken); await profileDoc.ref.update({microsoftToken: refreshedMicrosoftToken}); console.log(`Microsoft token updated for user ${profileDoc.id}`); } catch (error) { console.error(`Error refreshing Microsoft token for user ${profileDoc.id}:`, error.message); } } if (profileData.appleToken) { try { const refreshedAppleToken = await refreshAppleToken(profileData.appleToken); await profileDoc.ref.update({appleToken: refreshedAppleToken}); console.log(`Apple token updated for user ${profileDoc.id}`); } catch (error) { console.error(`Error refreshing Apple token for user ${profileDoc.id}:`, error.message); } } }); return null; }); // Function to refresh Google token async function refreshGoogleToken(token) { // Assuming you use OAuth2 token refresh flow const response = await axios.post('https://oauth2.googleapis.com/token', { grant_type: 'refresh_token', refresh_token: token, // Add refresh token stored previously client_id: 'YOUR_GOOGLE_CLIENT_ID', client_secret: 'YOUR_GOOGLE_CLIENT_SECRET', }); return response.data.access_token; // Return new access token } async function refreshMicrosoftToken(token) { const response = await axios.post('https://login.microsoftonline.com/common/oauth2/v2.0/token', { grant_type: 'refresh_token', refresh_token: token, // Add refresh token stored previously client_id: 'YOUR_MICROSOFT_CLIENT_ID', client_secret: 'YOUR_MICROSOFT_CLIENT_SECRET', scope: 'https://graph.microsoft.com/Calendars.ReadWrite offline_access', }); return response.data.access_token; // Return new access token } async function getPushTokensForEvent() { const usersRef = db.collection('Profiles'); const snapshot = await usersRef.get(); let pushTokens = []; snapshot.forEach(doc => { const data = doc.data(); if (data.pushToken) { pushTokens.push(data.pushToken); } }); console.log('Push Tokens:', pushTokens); return pushTokens; } async function getPushTokensForFamilyExcludingCreator(familyId, creatorId) { const usersRef = db.collection('Profiles'); const snapshot = await usersRef.where('familyId', '==', familyId).get(); let pushTokens = []; snapshot.forEach(doc => { const data = doc.data(); // Exclude the creator if (data.uid !== creatorId && data.pushToken) { pushTokens.push(data.pushToken); } }); return pushTokens; } async function removeInvalidPushToken(pushToken) { // TODO }