diff --git a/firebase.json b/firebase.json index 076f09a..abf2c13 100644 --- a/firebase.json +++ b/firebase.json @@ -26,7 +26,7 @@ "port": 5001 }, "firestore": { - "port": 8084 + "port": 8079 }, "storage": { "port": 9199 diff --git a/functions/.env.example b/functions/.env.example index 381bfad..7eb924a 100644 --- a/functions/.env.example +++ b/functions/.env.example @@ -7,4 +7,5 @@ TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}# SERVICES_RGN=#{SERVICES_RGN}# CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}# CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}# - +GOOGLE_MAPS_API_KEY=#{GOOGLE_MAPS_API_KEY}# +FITLIENHOST=#{FITLIENHOST}# diff --git a/functions/src/index.ts b/functions/src/index.ts index 4c98cab..05ae00a 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -3,7 +3,6 @@ import { Request } from "firebase-functions/v2/https"; import * as admin from 'firebase-admin'; import * as express from "express"; import * as logger from "firebase-functions/logger"; -import { onDocumentCreated } from "firebase-functions/firestore"; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; @@ -11,6 +10,7 @@ import * as https from 'https'; import cors from 'cors'; import axios from "axios"; import { getStorage } from 'firebase-admin/storage'; +import { onDocumentCreated } from "firebase-functions/firestore"; const formData = require('form-data'); const Mailgun = require('mailgun.js'); const { convert } = require('html-to-text'); @@ -180,65 +180,146 @@ export const sendSMSMessage = onRequest({ }); }); -interface Invitation { - email: string; - phoneNumber: string; - gymName: string; - invitedByName: string; -} - -export const notifyInvitation = onDocumentCreated({ - document: 'notifications/{notificationId}', - region: '#{SERVICES_RGN}#' -}, async (event: any) => { - - const invitation = event.data?.data() as Invitation; - const invitationId = event.params.invitationId; - - if (!invitation) { - console.error('Invitation data is missing.'); - return null; - } - +export const processNotificationOnCreate = onDocumentCreated({ + region: '#{SERVICES_RGN}#', + document: 'notifications/{notificationId}' +}, async (event) => { try { - const userQuery = await admin - .firestore() - .collection('users') - .where('email', '==', invitation.email) - .where('phoneNumber', '==', invitation.phoneNumber) - .limit(1) - .get(); + const notification = event.data?.data(); + const notificationId = event.params.notificationId; - if (userQuery.empty) { - console.log( - `User not found for email: ${invitation.email} and phone: ${invitation.phoneNumber}.` - ); - return null; + if (!notification) { + logger.error(`No data found for notification ${notificationId}`); + return; } - const user = userQuery.docs[0].data(); - const fcmToken = user.fcmToken; + if (notification.notificationSent === true) { + logger.info(`Notification ${notificationId} already sent, skipping.`); + return; + } + + let userId = null; + let fcmToken = null; + + if (notification.userId) { + userId = notification.userId; + const userDoc = await admin.firestore().collection('users').doc(userId).get(); + if (userDoc.exists) { + fcmToken = userDoc.data()?.fcmToken; + } + } else if (notification.clientId) { + userId = notification.clientId; + const userDoc = await admin.firestore().collection('users').doc(userId).get(); + if (userDoc.exists) { + fcmToken = userDoc.data()?.fcmToken; + } + } else if (notification.invitorId) { + userId = notification.invitorId; + const userDoc = await admin.firestore().collection('users').doc(userId).get(); + if (userDoc.exists) { + fcmToken = userDoc.data()?.fcmToken; + } + } else if (notification.phoneNumber) { + const userQuery = await admin + .firestore() + .collection('users') + .where('phoneNumber', '==', notification.phoneNumber) + .limit(1) + .get(); + + if (!userQuery.empty) { + const userDoc = userQuery.docs[0]; + userId = userDoc.id; + fcmToken = userDoc.data()?.fcmToken; + } + } if (!fcmToken) { - console.log(`FCM token not found for user: ${invitation.email}.`); - return null; + logger.error(`FCM token not found for notification ${notificationId}`); + await admin.firestore().collection('notifications').doc(notificationId).update({ + notificationError: 'FCM token not found for user', + updatedAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() + }); + return; + } + + let title = 'New Notification'; + let body = notification.message || 'You have a new notification'; + let data: Record = { + type: notification.type, + }; + + switch (notification.type) { + case 'day_pass_entry': + const isAccepted = notification.status === 'ACCEPTED'; + title = isAccepted ? 'Day Pass Approved' : 'Day Pass Denied'; + body = notification.message || (isAccepted ? + `Your day pass has been approved` : + `Your day pass has been denied`); + data.gymName = notification.gymName || ''; + break; + + case 'trainer_assigned_to_client': + title = 'Trainer Assigned'; + body = notification.message || `${notification.trainerName} has been assigned as your trainer`; + data.trainerName = notification.trainerName || ''; + data.membershipId = notification.membershipId || ''; + break; + + case 'client_invitations': + if (notification.userId || notification.invitorId) { + const isAccept = notification.status === 'ACCEPTED'; + title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected'; + body = notification.message || (isAccept ? + `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been accepted` : + `The invitation for ${notification.subscriptionName} you shared with ${notification.name} has been rejected`); + } else if (notification.phoneNumber) { + let invitationStatus; + if (notification.status === 'ACCEPTED') { + invitationStatus = 'accepted'; + title = 'Invitation Accepted'; + body = notification.message || + `You have accepted the invitation from ${notification.name}`; + } else if (notification.status === 'REJECTED') { + invitationStatus = 'rejected'; + title = 'Invitation Rejected'; + body = notification.message || + `You have rejected the invitation from ${notification.name}`; + } else if (notification.status === 'PENDING') { + invitationStatus = 'pending'; + title = 'New Invitation'; + body = notification.message || + `You have a new invitation pending from ${notification.name}`; + } else { + invitationStatus = 'unknown'; + title = 'Invitation Update'; + body = notification.message || 'There is an update to your invitation'; + } + data.status = invitationStatus; + } + + data.gymName = notification.gymName || ''; + data.clientEmail = notification.clientEmail || ''; + data.clientName = notification.name || ''; + data.invitationId = notification.invitationId || ''; + data.subscriptionName = notification.subscriptionName || ''; + break; + + default: + logger.info(`Using default handling for notification type: ${notification.type}`); + break; } const message: admin.messaging.Message = { notification: { - title: 'New Gym Invitation', - body: `${invitation.invitedByName} has invited you to join ${invitation.gymName}`, - }, - data: { - type: 'invitation', - invitationId: invitationId, - gymName: invitation.gymName, - senderName: invitation.invitedByName, + title: title, + body: body, }, + data: data, android: { priority: 'high', notification: { - channelId: 'invitations_channel', + channelId: 'notifications_channel', priority: 'high', defaultSound: true, defaultVibrateTimings: true, @@ -246,15 +327,35 @@ export const notifyInvitation = onDocumentCreated({ clickAction: 'FLUTTER_NOTIFICATION_CLICK', }, }, + apns: { + payload: { + aps: { + sound: 'default', + badge: 1, + }, + }, + }, token: fcmToken, }; - await admin.messaging().send(message); - console.log(`Invitation notification sent to ${invitation.email}.`); - return null; + try { + const fcmResponse = await admin.messaging().send(message); + logger.info(`FCM notification sent successfully: ${fcmResponse}`); + + await admin.firestore().collection('notifications').doc(notificationId).update({ + notificationSent: true, + sentAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() + }); + } catch (error) { + logger.error(`Error sending notification ${notificationId}:`, error); + + await admin.firestore().collection('notifications').doc(notificationId).update({ + notificationError: error instanceof Error ? error.message : String(error), + updatedAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() + }); + } } catch (error) { - console.error('Error sending invitation notification:', error); - return null; + logger.error('Error processing notification:', error); } }); @@ -271,87 +372,125 @@ export const createCashfreeOrder = onRequest({ } const idToken = authHeader.split('Bearer ')[1]; - const decodedToken = await admin.auth().verifyIdToken(idToken); - const uid = decodedToken.uid; + try { + const decodedToken = await admin.auth().verifyIdToken(idToken); + const uid = decodedToken.uid; - const { - amount, - customerName, - customerEmail, - customerPhone, - productInfo - } = request.body; + const { + amount, + customerName, + customerEmail, + customerPhone, + productInfo, + userId, + gymId, + orderId + } = request.body; - if (!amount || !customerEmail || !customerPhone) { - response.status(400).json({ error: 'Missing required fields' }); - return; - } - - const clientId = process.env.CASHFREE_CLIENT_ID; - const clientSecret = process.env.CASHFREE_CLIENT_SECRET; - const isTest = true; - - const apiUrl = isTest - ? 'https://sandbox.cashfree.com/pg/orders' - : 'https://api.cashfree.com/pg/orders'; - - const orderId = `order_${Date.now()}_${uid.substring(0, 6)}`; - - const cashfreeResponse = await axios.post( - apiUrl, - { - order_id: orderId, - order_amount: amount, - order_currency: 'INR', - customer_details: { - customer_id: uid, - customer_name: customerName || 'Fitlien User', - customer_email: customerEmail, - customer_phone: customerPhone - }, - order_meta: { - return_url: `https://fitlien.com/payment/status?order_id={order_id}`, - // notify_url: `https://$filien.web.app/verifyCashfreePayment` - }, - order_note: productInfo || 'Fitlien Membership' - }, - { - headers: { - 'x-api-version': '2022-09-01', - 'x-client-id': clientId, - 'x-client-secret': clientSecret, - 'Content-Type': 'application/json' - } + if (!amount || !customerEmail || !customerPhone) { + response.status(400).json({ error: 'Missing required fields' }); + return; } - ); - await admin.firestore().collection('payment_orders').doc(orderId).set({ - userId: uid, - amount: amount, - customerEmail: customerEmail, - customerPhone: customerPhone, - orderStatus: 'CREATED', - paymentGateway: 'Cashfree', - createdAt: new Date(), - ...cashfreeResponse.data - }); + const clientId = process.env.CASHFREE_CLIENT_ID; + const clientSecret = process.env.CASHFREE_CLIENT_SECRET; - response.json({ - order_id: cashfreeResponse.data.order_id, - payment_session_id: cashfreeResponse.data.payment_session_id - }); + if (!clientId || !clientSecret) { + logger.error('Cashfree credentials not configured'); + response.status(500).json({ error: 'Payment gateway configuration error' }); + return; + } - logger.info(`Cashfree order created: ${orderId}`); + const isTest = true; + const hashKey = `hash_${Date.now()}_${uid.substring(0, 1)}_${orderId}`; + const apiUrl = isTest + ? 'https://sandbox.cashfree.com/pg/orders' + : 'https://api.cashfree.com/pg/orders'; + + try { + const cashfreeResponse = await axios.post( + apiUrl, + { + order_id: orderId, + hash_key: hashKey, + order_amount: amount, + order_currency: 'INR', + customer_details: { + customer_id: uid, + customer_name: customerName || 'Fitlien User', + customer_email: customerEmail, + customer_phone: customerPhone + }, + order_meta: { + return_url: `https://${process.env.FITLIENHOST}/payment-bridge?order_id=${orderId}&hash_key=${hashKey}&user_id=${userId}&gym_id=${gymId}`, + // notify_url: `https://$filien.web.app/verifyCashfreePayment` + }, + order_note: productInfo || 'Fitlien Membership' + }, + { + headers: { + 'x-api-version': '2022-09-01', + 'x-client-id': clientId, + 'x-client-secret': clientSecret, + 'Content-Type': 'application/json' + } + } + ); + + try { + await admin.firestore().collection('payment_orders').doc(orderId).set({ + userId: uid, + amount: amount, + customerEmail: customerEmail, + customerPhone: customerPhone, + orderStatus: 'CREATED', + paymentGateway: 'Cashfree', + createdAt: new Date(), + hashKey: hashKey, + clientId: userId, + gymId: gymId, + orderId: orderId, + ...cashfreeResponse.data + }); + } catch (firestoreError) { + logger.error('Error storing order in Firestore:', firestoreError); + } + + response.json({ + success: true, + order_id: cashfreeResponse.data.order_id, + payment_session_id: cashfreeResponse.data.payment_session_id + }); + + logger.info(`Cashfree order created: ${orderId}`); + } catch (axiosError: any) { + logger.error('Cashfree API error:', axiosError); + response.status(axiosError.response?.status || 500).json({ + success: false, + error: 'Payment gateway error', + details: axiosError.response?.data || axiosError.message, + code: axiosError.code + }); + } + } catch (authError) { + logger.error('Authentication error:', authError); + response.status(401).json({ + success: false, + error: 'Invalid authentication token' + }); + } } catch (error: any) { logger.error('Cashfree order creation error:', error); response.status(500).json({ + success: false, error: 'Failed to create payment order', - details: error.response?.data || error.message + details: error.message }); } }); }); + export const verifyCashfreePayment = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response: express.Response) => { @@ -410,3 +549,102 @@ export const verifyCashfreePayment = onRequest({ } }); }); + +export const getPlacesAutocomplete = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: express.Response) => { + return corsHandler(request, response, async () => { + try { + const { input, location, radius, types, components, sessiontoken } = request.query; + + if (!input) { + response.status(400).json({ + error: 'Input parameter is required for autocomplete' + }); + return; + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + logger.error('Google Places API key is not configured'); + response.status(500).json({ error: 'Server configuration error' }); + return; + } + + const url = 'https://maps.googleapis.com/maps/api/place/autocomplete/json'; + const params: any = { + key: apiKey, + input: input + }; + + if (location && radius) { + params.location = location; + params.radius = radius; + } + + if (types) { + params.types = types; + } + + if (components) { + params.components = components; + } + + if (sessiontoken) { + params.sessiontoken = sessiontoken; + } + + const result = await axios.get(url, { params }); + + logger.info('Google Places Autocomplete API request completed successfully'); + response.json(result.data); + } catch (error) { + logger.error('Error fetching place autocomplete suggestions:', error); + response.status(500).json({ + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } + }); +}); + +export const getPlaceDetails = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response: express.Response) => { + return corsHandler(request, response, async () => { + try { + const { place_id, fields } = request.query; + + if (!place_id) { + response.status(400).json({ + error: 'place_id parameter is required' + }); + return; + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + logger.error('Google Places API key is not configured'); + response.status(500).json({ error: 'Server configuration error' }); + return; + } + + const url = 'https://maps.googleapis.com/maps/api/place/details/json'; + const params: any = { + key: apiKey, + place_id: place_id, + fields: fields || 'geometry' + }; + + const result = await axios.get(url, { params }); + logger.info('Google Places Details API request completed successfully'); + response.json(result.data); + } catch (error) { + logger.error('Error fetching place details:', error); + response.status(500).json({ + success: false, + error: error instanceof Error ? error.message : String(error) + }); + } + }); +});