import { onRequest } from "firebase-functions/v2/https"; 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 * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; 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'); const twilio = require('twilio'); if (!admin.apps.length) { admin.initializeApp(); } const corsHandler = cors({ origin: true }); export const sendEmailWithAttachment = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { try { const { toAddress, subject, message, fileUrl, fileName } = request.body; if (!toAddress || !subject || !message || !fileUrl) { response.status(400).json({ error: 'Missing required fields (toAddress, subject, message, fileUrl)' }); return; } const tempFilePath = path.join(os.tmpdir(), fileName || 'attachment.pdf'); await new Promise((resolve, reject) => { const file = fs.createWriteStream(tempFilePath); https.get(fileUrl, (res) => { res.pipe(file); file.on('finish', () => { file.close(); resolve(); }); }).on('error', (err) => { fs.unlink(tempFilePath, () => { }); reject(err); }); }); const mailgun = new Mailgun(formData); const client = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY }); const options = { wordwrap: 130, }; const textMessage = convert(message, options); const fileBuffer = fs.readFileSync(tempFilePath); const attachmentFilename = fileName || path.basename(fileUrl.split('?')[0]); const data = { from: process.env.MAILGUN_FROM_ADDRESS, to: toAddress, subject: subject, text: textMessage, html: message, attachment: { data: fileBuffer, filename: attachmentFilename, contentType: 'application/pdf', } }; const result = await client.messages.create(process.env.MAILGUN_SERVER, data); fs.unlinkSync(tempFilePath); logger.info('Email with attachment from URL sent successfully'); response.json({ success: true, result }); } catch (error) { logger.error('Error sending email with attachment from URL:', error); response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) }); } }); }); export const accessFile = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { try { const filePath = request.query.path as string; if (!filePath) { response.status(400).send('File path is required'); return; } const expirationMs = 60 * 60 * 1000; const bucket = getStorage().bucket(); const file = bucket.file(filePath); const [exists] = await file.exists(); if (!exists) { response.status(404).send('File not found'); return; } const [signedUrl] = await file.getSignedUrl({ action: 'read', expires: Date.now() + expirationMs, responseDisposition: `attachment; filename="${path.basename(filePath)}"`, }); response.redirect(signedUrl); logger.info(`File access redirect for ${filePath}`); } catch (error) { logger.error('Error accessing file:', error); response.status(500).send('Error accessing file'); } }); }); export const sendEmailMessage = onRequest({ region: '#{SERVICES_RGN}#' }, (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { const mailgun = new Mailgun(formData); const mailGunClient = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY }); const toAddress = request.body.toAddress; const subject = request.body.subject; const message = request.body.message; const options = { wordwrap: 130, }; const textMessage = convert(message, options); mailGunClient.messages.create(process.env.MAILGUN_SERVER, { from: process.env.MAILGUN_FROM_ADDRESS, to: toAddress, subject: subject, text: textMessage, html: message }).then((res: any) => { logger.info(res); response.send(res); }).catch((err: any) => { logger.error(err); response.send(err); }); }); }); export const sendSMSMessage = onRequest({ region: '#{SERVICES_RGN}#' }, (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); const { to, body } = request.body; client.messages .create({ body: body, from: process.env.TWILIO_PHONE_NUMBER, to: to }) .then((message: any) => { logger.info('SMS sent successfully:', message.sid); response.json({ success: true, messageId: message.sid }); }) .catch((error: any) => { logger.error('Error sending SMS:', error); response.status(500).json({ success: false, error: error.message }); }); }); }); export const processNotificationOnCreate = onDocumentCreated({ region: '#{SERVICES_RGN}#', document: 'notifications/{notificationId}' }, async (event) => { try { const notification = event.data?.data(); const notificationId = event.params.notificationId; if (!notification) { logger.error(`No data found for notification ${notificationId}`); return; } 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) { 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: title, body: body, }, data: data, android: { priority: 'high', notification: { channelId: 'notifications_channel', priority: 'high', defaultSound: true, defaultVibrateTimings: true, icon: '@mipmap/ic_launcher', clickAction: 'FLUTTER_NOTIFICATION_CLICK', }, }, apns: { payload: { aps: { sound: 'default', badge: 1, }, }, }, token: fcmToken, }; 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) { logger.error('Error processing notification:', error); } }); export const createCashfreeOrder = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { try { const authHeader = request.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { response.status(401).json({ error: 'Unauthorized' }); return; } const idToken = authHeader.split('Bearer ')[1]; try { const decodedToken = await admin.auth().verifyIdToken(idToken); const uid = decodedToken.uid; const { amount, customerName, customerEmail, customerPhone, productInfo, userId, gymId, orderId, webHostUrl, } = 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; if (!clientId || !clientSecret) { logger.error('Cashfree credentials not configured'); response.status(500).json({ error: 'Payment gateway configuration error' }); return; } const hashKey = `hash_${Date.now()}_${uid.substring(0, 1)}_${orderId}`; let apiUrl = process.env.CASHFREE_URL; 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://${webHostUrl}?order_id=${orderId}&hash_key=${hashKey}&user_id=${userId}&gym_id=${gymId}#/payment-status-screen`, }, 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.message }); } }); }); export const verifyCashfreePayment = onRequest({ region: '#{SERVICES_RGN}#' }, async (request: Request, response: express.Response) => { return corsHandler(request, response, async () => { try { const orderId = request.body.order_id || request.query.order_id; if (!orderId) { response.status(400).json({ error: 'Order ID is required' }); return; } const clientId = process.env.CASHFREE_CLIENT_ID; const clientSecret = process.env.CASHFREE_CLIENT_SECRET; const apiUrl = process.env.CASHFREE_URL; const cashfreeResponse = await axios.get( apiUrl!, { headers: { 'x-api-version': '2022-09-01', 'x-client-id': clientId, 'x-client-secret': clientSecret } } ); await admin.firestore().collection('payment_orders').doc(orderId).update({ orderStatus: cashfreeResponse.data.order_status, paymentDetails: cashfreeResponse.data, updatedAt: new Date() }); if (request.headers['x-webhook-source'] === 'cashfree') { response.status(200).send('OK'); return; } response.json({ status: cashfreeResponse.data.order_status, paymentDetails: cashfreeResponse.data }); logger.info(`Cashfree payment verified: ${orderId}`); } catch (error: any) { logger.error('Cashfree payment verification error:', error); response.status(500).json({ error: 'Failed to verify payment status', details: error.response?.data || error.message }); } }); }); 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) }); } }); });