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 axios from "axios"; 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(); } export const sendEmailWithAttachment = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { 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 sendEmailMessage = onRequest({ region: process.env.SERVICES_RGN }, (request: Request, response: express.Response) => { 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: process.env.SERVICES_RGN }, (request: Request, response: express.Response) => { 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 createCashfreeOrder = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { try { const authHeader = request.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { response.status(401).json({ error: 'Unauthorized' }); return; } const idToken = authHeader.split('Bearer ')[1]; const decodedToken = await admin.auth().verifyIdToken(idToken); const uid = decodedToken.uid; const { amount, customerName, customerEmail, customerPhone, productInfo } = 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' } } ); 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 }); response.json({ order_id: cashfreeResponse.data.order_id, payment_session_id: cashfreeResponse.data.payment_session_id }); logger.info(`Cashfree order created: ${orderId}`); } catch (error: any) { logger.error('Cashfree order creation error:', error); response.status(500).json({ error: 'Failed to create payment order', details: error.response?.data || error.message }); } }); export const verifyCashfreePayment = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { 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 isTest = process.env.CASHFREE_ENVIRONMENT !== 'production'; const apiUrl = isTest ? `https://sandbox.cashfree.com/pg/orders/${orderId}` : `https://api.cashfree.com/pg/orders/${orderId}`; 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 sendFCMNotificationByType = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { try { const notificationType = request.body.type || request.query.type; const userId = request.body.userId || request.body.clientId || request.query.userId || request.query.clientId || request.body.invitorId || request.query.invitorId; if (!notificationType) { response.status(400).json({ error: 'Notification type is required' }); return; } if (!userId) { response.status(400).json({ error: 'User ID is required' }); return; } const userDoc = await admin.firestore().collection('users').doc(userId).get(); if (!userDoc.exists) { response.status(404).json({ error: `User not found for ID: ${userId}` }); return; } const user = userDoc.data(); const fcmToken = user?.fcmToken; if (!fcmToken) { response.status(400).json({ error: `FCM token not found for user: ${userId}` }); return; } const userIdQuery = admin.firestore().collection('notifications') .where('userId', '==', userId) .where('type', '==', notificationType); const clientIdQuery = admin.firestore().collection('notifications') .where('clientId', '==', userId) .where('type', '==', notificationType); const invitorIdQuery = admin.firestore().collection('notifications') .where('invitorId', '==', userId) .where('type', '==', notificationType); const [userIdResults, clientIdResults, invitorIdResults] = await Promise.all([ userIdQuery.get(), clientIdQuery.get(), invitorIdQuery.get() ]); const notificationDocs = [...userIdResults.docs, ...clientIdResults.docs, ...invitorIdResults.docs] .filter(doc => { const data = doc.data(); return data.notificationSent !== true; }); if (notificationDocs.length === 0) { response.status(404).json({ error: `No unsent notifications of type '${notificationType}' found for user/client ${userId}` }); return; } const results = []; for (const doc of notificationDocs) { const notification = doc.data(); const docId = doc.id; if (notification.notificationSent) { logger.info(`Notification ${notificationType} already sent, skipping.`); continue; } 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': const isAccept = notification.status === 'ACCEPTED'; title = isAccept ? 'Invitation Accepted' : 'Invitation Rejected'; body = notification.message || (isAccept ? `The invitation for ${notification.subcriptionName} you shared with ${notification.name} has been accepted` : `The invitation for ${notification.subcriptionName} you shared with ${notification.name} has been rejected`); data.gymName = notification.gymName || ''; data.clientEmail = notification.clientEmail || ''; data.clientName = notification.name || ''; data.invitationId = notification.invitationId || ''; data.subcriptionname= notification.subcriptionname || ''; 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 to user ${userId} for type: ${notification.type}`); await admin.firestore().collection('notifications').doc(docId).update({ notificationSent: true, sentAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() }); results.push({ success: true, messageId: fcmResponse }); } catch (error) { logger.error(`Error sending notification ${notificationType}:`, error); results.push({ success: false, type: notificationType, error: error instanceof Error ? error.message : String(error) }); } } response.json({ success: true, processed: results.length, results: results }); } catch (error) { logger.error('Error processing notifications:', error); response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) }); } }); export const sendFCMNotificationByPhone = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { try { const notificationType = request.body.type || request.query.type; const phoneNumber = request.body.phoneNumber || request.query.phoneNumber; if (!notificationType) { response.status(400).json({ error: 'Notification type is required' }); return; } if (!phoneNumber) { response.status(400).json({ error: 'Phone number is required' }); return; } const userQuery = await admin .firestore() .collection('users') .where('phoneNumber', '==', phoneNumber) .get(); if (userQuery.empty) { response.status(404).json({ error: `User not found for phone number: ${phoneNumber}` }); return; } const userDoc = userQuery.docs[0]; const user = userDoc.data(); const fcmToken = user?.fcmToken; if (!fcmToken) { response.status(400).json({ error: `FCM token not found for user with phone: ${phoneNumber}` }); return; } const invitorIdQuery = admin.firestore().collection('notifications') .where('phoneNumber', '==', phoneNumber) .where('type', '==', notificationType); const [userIdResults] = await Promise.all([ invitorIdQuery.get() ]); const notificationDocs = [...userIdResults.docs] .filter(doc => { const data = doc.data(); return data.notificationSent !== true; }); if (notificationDocs.length === 0) { response.status(404).json({ error: `No unsent notifications of type '${notificationType}' found for user with phone ${phoneNumber}` }); return; } const results = []; for (const doc of notificationDocs) { const notification = doc.data(); const docId = doc.id; let title = 'New Notification'; let body = notification.message || 'You have a new notification'; let data: Record = { type: notification.type, }; switch (notification.type) { case 'client_invitations': let invitationStatus; if (notification.status === 'ACCEPTED') { invitationStatus = 'accepted'; title = 'Invitation Accepted'; body = notification.message || `You have rejected 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.gymName = notification.gymName || ''; data.clientEmail = notification.clientEmail || ''; data.clientName = notification.name || ''; data.invitationId = notification.invitationId || ''; data.subscriptionName = notification.subscriptionName || ''; data.status = invitationStatus; 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 to user with phone ${phoneNumber} for type: ${notification.type}`); await admin.firestore().collection('notifications').doc(docId).update({ notificationSent: true, sentAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() }); results.push({ success: true, messageId: fcmResponse }); } catch (error) { logger.error(`Error sending notification ${notification.type}:`, error); results.push({ success: false, type: notification.type, error: error instanceof Error ? error.message : String(error) }); } } response.json({ success: true, processed: results.length, results: results }); } catch (error) { logger.error('Error processing notifications by phone:', error); response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) }); } }); export const sendTrainerAssignmentNotification = onRequest({ region: process.env.SERVICES_RGN }, async (request: Request, response: express.Response) => { try { const notificationType = request.body.type || request.query.type || 'trainer_assigned_to_client'; const trainerName = request.body.trainerName || request.query.trainerName; const clientId = request.body.clientId || request.query.clientId; const gymId = request.body.gymId || request.query.gymId; if (!trainerName) { response.status(400).json({ error: 'Trainer name is required' }); return; } if (!clientId) { response.status(400).json({ error: 'Client ID is required' }); return; } if (!gymId) { response.status(400).json({ error: 'Gym ID is required' }); return; } // Find the trainer by name const trainerQuery = await admin .firestore() .collection('trainer_profiles') .where('fullName', '==', trainerName) .limit(1) .get(); if (trainerQuery.empty) { response.status(404).json({ error: `Trainer not found with name: ${trainerName}` }); return; } const trainerDoc = trainerQuery.docs[0]; const trainer = trainerDoc.data(); const trainerId = trainerDoc.id; if (!trainer.phoneNumber) { response.status(400).json({ error: `Phone number not found for trainer: ${trainerName}` }); return; } // Get client name from client collection const clientDoc = await admin.firestore().collection('clients').doc(clientId).get(); if (!clientDoc.exists) { response.status(404).json({ error: `Client not found with ID: ${clientId}` }); return; } const client = clientDoc.data(); const clientName = client?.name || client?.fullName || client?.displayName || 'A new client'; // Get gym name from gym ID const gymDoc = await admin.firestore().collection('gyms').doc(gymId).get(); if (!gymDoc.exists) { response.status(404).json({ error: `Gym not found with ID: ${gymId}` }); return; } const gym = gymDoc.data(); const gymName = gym?.name || gym?.gymName || 'your gym'; // Find the user document for the trainer using their phone number const userQuery = await admin .firestore() .collection('users') .where('phoneNumber', '==', trainer.phoneNumber) .limit(1) .get(); if (userQuery.empty) { response.status(404).json({ error: `User not found for trainer with phone: ${trainer.phoneNumber}` }); return; } const userDoc = userQuery.docs[0]; const user = userDoc.data(); const fcmToken = user?.fcmToken; if (!fcmToken) { response.status(400).json({ error: `FCM token not found for trainer: ${trainerName}` }); return; } const notificationQuery = admin.firestore().collection('notifications') .where('type', '==', notificationType) .where('clientId', '==', clientId) .where('gymId', '==', gymId) .where('trainerName', '==', trainerName); const notificationSnapshot = await notificationQuery.get(); const unsentNotifications = notificationSnapshot.docs.filter(doc => { const data = doc.data(); return data.notificationSent !== true; }); if (unsentNotifications.length === 0) { response.status(404).json({ error: `No unsent notifications found for trainer: ${trainerName}, client: ${clientId}, gym: ${gymId}` }); return; } const results = []; for (const doc of unsentNotifications) { const docId = doc.id; const title = 'New Client Assignment'; const body = `You have been assigned to ${clientName} at ${gymName}`; const data: Record = { type: notificationType, trainerId: trainerId, clientId: clientId, clientName: clientName, gymId: gymId, gymName: gymName }; 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(`Trainer assignment notification sent to ${trainerName}`); await admin.firestore().collection('notifications').doc(docId).update({ notificationSent: true, sentAt: admin.firestore.FieldValue?.serverTimestamp?.() || new Date() }); results.push({ success: true, messageId: fcmResponse }); } catch (error) { logger.error(`Error sending trainer assignment notification:`, error); results.push({ success: false, type: notificationType, error: error instanceof Error ? error.message : String(error) }); } } response.json({ success: true, processed: results.length, results: results }); } catch (error) { logger.error('Error processing trainer assignment notification:', error); response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) }); } });