diff --git a/functions/src/email/index.ts b/functions/src/email/index.ts new file mode 100644 index 0000000..13a1701 --- /dev/null +++ b/functions/src/email/index.ts @@ -0,0 +1,2 @@ +export { sendEmailMessage } from './sendEmail'; +export { sendEmailWithAttachment } from './sendEmailWithAttachment'; diff --git a/functions/src/email/sendEmail.ts b/functions/src/email/sendEmail.ts new file mode 100644 index 0000000..d7b366f --- /dev/null +++ b/functions/src/email/sendEmail.ts @@ -0,0 +1,39 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { corsHandler } from "../shared/middleware"; +import { logger } from "../shared/config"; +import formData from 'form-data'; +import Mailgun from 'mailgun.js'; +const { convert } = require('html-to-text'); + +const mailgun = new Mailgun(formData); + +export const sendEmailMessage = onRequest({ + region: '#{SERVICES_RGN}#' +}, (request: Request, response) => { + return corsHandler(request, response, async () => { + 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); + }); + }); +}); \ No newline at end of file diff --git a/functions/src/email/sendEmailWithAttachment.ts b/functions/src/email/sendEmailWithAttachment.ts new file mode 100644 index 0000000..545112a --- /dev/null +++ b/functions/src/email/sendEmailWithAttachment.ts @@ -0,0 +1,80 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as https from 'https'; +import { corsHandler } from "../shared/middleware"; +import { logger } from "../shared/config"; +import formData from 'form-data'; +import Mailgun from 'mailgun.js'; +const { convert } = require('html-to-text'); + +const mailgun = new Mailgun(formData); + +export const sendEmailWithAttachment = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, 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); + }); + }); + + try { + 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 (e) { + console.error(`Error while sending E-mail. Error: ${e}`); + } + } 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) }); + } + }); +}); \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts index e1101f6..cf82272 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,782 +1,7 @@ -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'); -const { v4: uuidv4 } = require('uuid'); - -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); - }); - }); - - try { - - 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 (e) { - console.error(`Error while sending E-mail. Error: ${e}`); - } - } 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 createCashfreeLink = 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 - } = request.body; - - if (!amount || !customerEmail || !customerPhone) { - response.status(400).json({ error: 'Missing required fields' }); - return; - } - - const expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000); - const expirationString = expirationDate.toISOString(); - const clientId = process.env.CASHFREE_CLIENT_ID; - const clientSecret = process.env.CASHFREE_CLIENT_SECRET; - let apiUrl = process.env.CASHFREE_LINK_URL; - console.log(`API URL: ${apiUrl}`); - - if (!clientId || !clientSecret) { - logger.error('Cashfree credentials not configured'); - response.status(500).json({ error: 'Payment gateway configuration error' }); - return; - } - - const linkId = uuidv4(); - try { - const requestHeqaders = { - 'x-client-id': clientId, - 'x-client-secret': clientSecret, - 'x-api-version': '2025-01-01', - 'Content-Type': 'application/json' - }; - console.log(`Header: ${JSON.stringify(requestHeqaders)}`); - const requestBody = { - "link_id": linkId, - "link_amount": amount, - "link_currency": "INR", - "link_purpose": productInfo, - "customer_details": { - "customer_phone": customerPhone, - "customer_email": customerEmail, - "customer_name": customerName, - }, - "link_partial_payments": false, - "link_notify": { - "send_sms": true, - "send_email": true - }, - "link_expiry_time": expirationString, - "link_notes": { - "order_id": orderId, - "gym_id": gymId, - "user_id": userId - } - }; - console.log(`Body: ${JSON.stringify(requestBody)}`); - const cashfreeResponse = await axios.post(apiUrl!, requestBody, { - headers: requestHeqaders - }); - - try { - await admin.firestore().collection('payment_links').doc(orderId).set({ - requestUserId: uid, - amount: amount, - customerEmail: customerEmail, - customerPhone: customerPhone, - userId: userId, - gymId: gymId, - orderId: orderId, - ...cashfreeResponse.data, - createdAt: new Date(), - }); - } catch (firestoreError) { - logger.error('Error storing order in Firestore:', firestoreError); - } - - response.json({ - success: true, - linkId: linkId, - linkUrl: cashfreeResponse.data.link_url, - linkExpiryTime: cashfreeResponse.data.link_expiry_time, - linkStatus: cashfreeResponse.data.link_status, - linkQRCode: cashfreeResponse.data.link_qrcode - }); - - } 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) - }); - } - }); -}); +export * from './shared/config'; +export { sendEmailMessage, sendEmailWithAttachment } from './email'; +export { accessFile } from './storage'; +export { sendSMSMessage } from './sms'; +export { processNotificationOnCreate } from './notifications'; +export { createCashfreeLink, createCashfreeOrder, verifyCashfreePayment } from './payments'; +export { getPlaceDetails, getPlacesAutocomplete } from './places'; diff --git a/functions/src/notifications/index.ts b/functions/src/notifications/index.ts new file mode 100644 index 0000000..c9fed3e --- /dev/null +++ b/functions/src/notifications/index.ts @@ -0,0 +1 @@ +export { processNotificationOnCreate } from './processNotification'; diff --git a/functions/src/notifications/processNotification.ts b/functions/src/notifications/processNotification.ts new file mode 100644 index 0000000..b995b87 --- /dev/null +++ b/functions/src/notifications/processNotification.ts @@ -0,0 +1,222 @@ +import { onDocumentCreated } from "firebase-functions/v2/firestore"; +import { getLogger } from "../shared/config"; +import { getAdmin } from "../shared/config"; +import * as admin from 'firebase-admin'; + +const app = getAdmin(); +const logger = getLogger(); + +interface NotificationData { + notificationSent?: boolean; + userId?: string; + clientId?: string; + invitorId?: string; + phoneNumber?: string; + message?: string; + type?: string; + status?: string; + gymName?: string; + trainerName?: string; + membershipId?: string; + subscriptionName?: string; + name?: string; + clientEmail?: string; + invitationId?: string; + [key: string]: any; +} + +export const processNotificationOnCreate = onDocumentCreated({ + region: '#{SERVICES_RGN}#', + document: 'notifications/{notificationId}' +}, async (event) => { + try { + const notificationSnapshot = event.data; + const notificationId = event.params.notificationId; + + if (!notificationSnapshot) { + logger.error(`No data found for notification ${notificationId}`); + return; + } + + const notification = notificationSnapshot.data() as NotificationData; + if (notification.notificationSent === true) { + logger.info(`Notification ${notificationId} already sent, skipping.`); + return; + } + + const { fcmToken } = await getUserAndFCMToken(notification); + if (!fcmToken) { + logger.error(`FCM token not found for notification ${notificationId}`); + await updateNotificationWithError(notificationId, 'FCM token not found for user'); + return; + } + + const message = prepareNotificationMessage(notification, fcmToken); + try { + const fcmResponse = await app.messaging().send({ + ...message, + token: fcmToken + }); + + logger.info(`FCM notification sent successfully: ${fcmResponse}`); + await markNotificationAsSent(notificationId); + + } catch (error) { + logger.error(`Error sending notification ${notificationId}:`, error); + await updateNotificationWithError(notificationId, error instanceof Error ? error.message : String(error)); + } + } catch (error) { + logger.error('Error processing notification:', error); + } +}); + +async function getUserAndFCMToken(notification: NotificationData): Promise<{ userId: string | null; fcmToken: string | null }> { + let userId: string | null = null; + let fcmToken: string | null = null; + + if (notification.userId) { + userId = notification.userId; + fcmToken = await getFCMTokenFromUserDoc(userId); + } else if (notification.clientId) { + userId = notification.clientId; + fcmToken = await getFCMTokenFromUserDoc(userId); + } else if (notification.invitorId) { + userId = notification.invitorId; + fcmToken = await getFCMTokenFromUserDoc(userId); + } else if (notification.phoneNumber) { + const userQuery = await app + .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; + } + } + + return { userId, fcmToken }; +} + +async function getFCMTokenFromUserDoc(userId: string): Promise { + const userDoc = await app.firestore().collection('users').doc(userId).get(); + return userDoc.exists ? userDoc.data()?.fcmToken : null; +} + +function prepareNotificationMessage(notification: NotificationData, fcmToken: string): admin.messaging.Message { + let title = 'New Notification'; + let body = notification.message || 'You have a new notification'; + let data: Record = { + type: notification.type || 'general', + }; + + 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) { + const invitationStatus = getInvitationStatus(notification.status); + title = getInvitationTitle(invitationStatus); + body = notification.message || getInvitationBody(invitationStatus, notification.name); + 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 notificationMessage: admin.messaging.Message = { + notification: { title, body }, + 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, + }; + return notificationMessage; +} + +function getInvitationStatus(status?: string): string { + if (status === 'ACCEPTED') return 'accepted'; + if (status === 'REJECTED') return 'rejected'; + if (status === 'PENDING') return 'pending'; + return 'unknown'; +} + +function getInvitationTitle(status: string): string { + switch (status) { + case 'accepted': return 'Invitation Accepted'; + case 'rejected': return 'Invitation Rejected'; + case 'pending': return 'New Invitation'; + default: return 'Invitation Update'; + } +} + +function getInvitationBody(status: string, name?: string): string { + switch (status) { + case 'accepted': return `You have accepted the invitation from ${name}`; + case 'rejected': return `You have rejected the invitation from ${name}`; + case 'pending': return `You have a new invitation pending from ${name}`; + default: return 'There is an update to your invitation'; + } +} + +async function markNotificationAsSent(notificationId: string): Promise { + await app.firestore().collection('notifications').doc(notificationId).update({ + notificationSent: true, + sentAt: app.firestore.FieldValue.serverTimestamp() + }); +} + +async function updateNotificationWithError(notificationId: string, error: string): Promise { + await app.firestore().collection('notifications').doc(notificationId).update({ + notificationError: error, + updatedAt: app.firestore.FieldValue.serverTimestamp() + }); +} diff --git a/functions/src/payments/cashfree/createLink.ts b/functions/src/payments/cashfree/createLink.ts new file mode 100644 index 0000000..ba7e69c --- /dev/null +++ b/functions/src/payments/cashfree/createLink.ts @@ -0,0 +1,133 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../../shared/middleware"; +import { getAdmin, getLogger } from "../../shared/config"; +import axios from "axios"; +const { v4: uuidv4 } = require('uuid'); + +const corsHandler = getCorsHandler(); +const admin = getAdmin(); +const logger = getLogger(); + +interface CashfreeLinkRequest { + amount: number; + customerName?: string; + customerEmail: string; + customerPhone: string; + productInfo?: string; + userId?: string; + gymId?: string; + orderId: string; +} + +interface CashfreeLinkResponse { + link_id: string; + link_url: string; + link_expiry_time: string; + link_status: string; + link_qrcode: string; + [key: string]: any; +} + +export const createCashfreeLink = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, 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]; + const decodedToken = await admin.auth().verifyIdToken(idToken); + const uid = decodedToken.uid; + + const linkRequest = request.body as CashfreeLinkRequest; + if (!linkRequest.amount || !linkRequest.customerEmail || !linkRequest.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 expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000); + const expirationString = expirationDate.toISOString(); + const apiUrl = process.env.CASHFREE_LINK_URL; + const linkId = uuidv4(); + + const requestHeaders = { + 'x-client-id': clientId, + 'x-client-secret': clientSecret, + 'x-api-version': '2025-01-01', + 'Content-Type': 'application/json' + }; + + const requestBody = { + link_id: linkId, + link_amount: linkRequest.amount, + link_currency: "INR", + link_purpose: linkRequest.productInfo, + customer_details: { + customer_phone: linkRequest.customerPhone, + customer_email: linkRequest.customerEmail, + customer_name: linkRequest.customerName, + }, + link_partial_payments: false, + link_notify: { + send_sms: true, + send_email: true + }, + link_expiry_time: expirationString, + link_notes: { + order_id: linkRequest.orderId, + gym_id: linkRequest.gymId, + user_id: linkRequest.userId + } + }; + + const cashfreeResponse = await axios.post( + apiUrl!, + requestBody, + { headers: requestHeaders } + ); + + await admin.firestore().collection('payment_links').doc(linkRequest.orderId).set({ + requestUserId: uid, + amount: linkRequest.amount, + customerEmail: linkRequest.customerEmail, + customerPhone: linkRequest.customerPhone, + userId: linkRequest.userId, + gymId: linkRequest.gymId, + orderId: linkRequest.orderId, + ...cashfreeResponse.data, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + }); + + response.json({ + success: true, + linkId: linkId, + linkUrl: cashfreeResponse.data.link_url, + linkExpiryTime: cashfreeResponse.data.link_expiry_time, + linkStatus: cashfreeResponse.data.link_status, + linkQRCode: cashfreeResponse.data.link_qrcode + }); + + } catch (error: any) { + logger.error('Cashfree link creation error:', error); + const statusCode = error.response?.status || 500; + response.status(statusCode).json({ + success: false, + error: error.response?.data?.message || 'Failed to create payment link', + details: error.response?.data || error.message + }); + } + }); +}); diff --git a/functions/src/payments/cashfree/createOrder.ts b/functions/src/payments/cashfree/createOrder.ts new file mode 100644 index 0000000..ef29814 --- /dev/null +++ b/functions/src/payments/cashfree/createOrder.ts @@ -0,0 +1,116 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { corsHandler } from "../../shared/middleware"; +import { admin, logger } from "../../shared/config"; +import axios from "axios"; + +interface CashfreeOrderRequest { + amount: number; + customerName?: string; + customerEmail: string; + customerPhone: string; + productInfo?: string; + userId?: string; + gymId?: string; + orderId: string; + webHostUrl: string; +} + +interface CashfreeOrderResponse { + order_id: string; + payment_session_id: string; + [key: string]: any; +} + +export const createCashfreeOrder = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, 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]; + const decodedToken = await admin.auth().verifyIdToken(idToken); + const uid = decodedToken.uid; + + const orderRequest = request.body as CashfreeOrderRequest; + if (!orderRequest.amount || !orderRequest.customerEmail || !orderRequest.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)}_${orderRequest.orderId}`; + const apiUrl = process.env.CASHFREE_URL; + + const cashfreeResponse = await axios.post( + apiUrl!, + { + order_id: orderRequest.orderId, + hash_key: hashKey, + order_amount: orderRequest.amount, + order_currency: 'INR', + customer_details: { + customer_id: uid, + customer_name: orderRequest.customerName || 'Fitlien User', + customer_email: orderRequest.customerEmail, + customer_phone: orderRequest.customerPhone + }, + order_meta: { + return_url: `https://${orderRequest.webHostUrl}?order_id=${orderRequest.orderId}&hash_key=${hashKey}&user_id=${orderRequest.userId}&gym_id=${orderRequest.gymId}#/payment-status-screen`, + }, + order_note: orderRequest.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(orderRequest.orderId).set({ + userId: uid, + amount: orderRequest.amount, + customerEmail: orderRequest.customerEmail, + customerPhone: orderRequest.customerPhone, + orderStatus: 'CREATED', + paymentGateway: 'Cashfree', + createdAt: admin.firestore.FieldValue.serverTimestamp(), + hashKey: hashKey, + clientId: orderRequest.userId, + gymId: orderRequest.gymId, + orderId: orderRequest.orderId, + ...cashfreeResponse.data + }); + + response.json({ + success: true, + order_id: cashfreeResponse.data.order_id, + payment_session_id: cashfreeResponse.data.payment_session_id + }); + + } catch (error: any) { + logger.error('Cashfree order creation error:', error); + const statusCode = error.response?.status || 500; + response.status(statusCode).json({ + success: false, + error: error.response?.data?.message || 'Failed to create payment order', + details: error.response?.data || error.message + }); + } + }); +}); diff --git a/functions/src/payments/cashfree/index.ts b/functions/src/payments/cashfree/index.ts new file mode 100644 index 0000000..b75cf11 --- /dev/null +++ b/functions/src/payments/cashfree/index.ts @@ -0,0 +1,3 @@ +export { createCashfreeLink } from './createLink'; +export { verifyCashfreePayment } from './verifyPayment'; +export { createCashfreeOrder } from './createOrder'; diff --git a/functions/src/payments/cashfree/verifyPayment.ts b/functions/src/payments/cashfree/verifyPayment.ts new file mode 100644 index 0000000..1218665 --- /dev/null +++ b/functions/src/payments/cashfree/verifyPayment.ts @@ -0,0 +1,63 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { corsHandler } from "../../shared/middleware"; +import { admin, logger } from "../../shared/config"; +import axios from "axios"; + +interface CashfreePaymentResponse { + order_status: string; + [key: string]: any; +} + +export const verifyCashfreePayment = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, 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}/${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: admin.firestore.FieldValue.serverTimestamp() + }); + + if (request.headers['x-webhook-source'] === 'cashfree') { + response.status(200).send('OK'); + return; + } + + response.json({ + status: cashfreeResponse.data.order_status, + paymentDetails: cashfreeResponse.data + }); + + } catch (error: any) { + logger.error('Cashfree payment verification error:', error); + const statusCode = error.response?.status || 500; + response.status(statusCode).json({ + error: 'Failed to verify payment status', + details: error.response?.data || error.message + }); + } + }); +}); diff --git a/functions/src/payments/index.ts b/functions/src/payments/index.ts new file mode 100644 index 0000000..8d52c66 --- /dev/null +++ b/functions/src/payments/index.ts @@ -0,0 +1 @@ +export * from './cashfree'; diff --git a/functions/src/places/autocomplete.ts b/functions/src/places/autocomplete.ts new file mode 100644 index 0000000..c846d3e --- /dev/null +++ b/functions/src/places/autocomplete.ts @@ -0,0 +1,67 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import * as express from "express"; +import { getLogger } from "../shared/config"; +import { getCorsHandler } from "../shared/middleware"; +import axios from "axios"; + +const logger = getLogger(); +const corsHandler = getCorsHandler(); + +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) + }); + } + }); +}); diff --git a/functions/src/places/details.ts b/functions/src/places/details.ts new file mode 100644 index 0000000..41dbf64 --- /dev/null +++ b/functions/src/places/details.ts @@ -0,0 +1,49 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import * as express from "express"; +import axios from "axios"; + +const corsHandler = require('../shared/middleware').corsHandler; +const logger = require('../shared/config').getLogger(); + + +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) + }); + } + }); +}); \ No newline at end of file diff --git a/functions/src/places/index.ts b/functions/src/places/index.ts new file mode 100644 index 0000000..a280b3c --- /dev/null +++ b/functions/src/places/index.ts @@ -0,0 +1,2 @@ +export { getPlaceDetails } from './details'; +export { getPlacesAutocomplete } from './autocomplete'; diff --git a/functions/src/shared/config.ts b/functions/src/shared/config.ts new file mode 100644 index 0000000..b25c8fa --- /dev/null +++ b/functions/src/shared/config.ts @@ -0,0 +1,9 @@ +import * as admin from 'firebase-admin'; +import * as logger from 'firebase-functions/logger'; + +if (!admin.apps.length) { + admin.initializeApp(); +} + +export const getAdmin = () => admin; +export const getLogger = () => logger; \ No newline at end of file diff --git a/functions/src/shared/middleware.ts b/functions/src/shared/middleware.ts new file mode 100644 index 0000000..e89ea6d --- /dev/null +++ b/functions/src/shared/middleware.ts @@ -0,0 +1,3 @@ +import cors from 'cors'; + +export const getCorsHandler = () => cors({ origin: true }); diff --git a/functions/src/sms/index.ts b/functions/src/sms/index.ts new file mode 100644 index 0000000..f1afb2c --- /dev/null +++ b/functions/src/sms/index.ts @@ -0,0 +1 @@ +export { sendSMSMessage } from './sendSMS'; \ No newline at end of file diff --git a/functions/src/sms/sendSMS.ts b/functions/src/sms/sendSMS.ts new file mode 100644 index 0000000..5887e9a --- /dev/null +++ b/functions/src/sms/sendSMS.ts @@ -0,0 +1,77 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import { getCorsHandler } from "../shared/middleware"; +import { getLogger } from "../shared/config"; +import twilio from 'twilio'; + +const corsHandler = getCorsHandler(); +const logger = getLogger(); + +// Initialize Twilio client +const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN); + +interface SMSRequest { + to: string; + body: string; +} + +export const sendSMSMessage = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, response) => { + return corsHandler(request, response, async () => { + try { + const { to, body } = request.body as SMSRequest; + + // Input validation + if (!to || !body) { + logger.error('Missing required SMS parameters'); + response.status(400).json({ + success: false, + error: 'Both "to" and "body" parameters are required' + }); + return; + } + + // Validate phone number format (basic check) + if (!/^\+?[1-9]\d{1,14}$/.test(to)) { + logger.error('Invalid phone number format', { to }); + response.status(400).json({ + success: false, + error: 'Invalid phone number format' + }); + return; + } + + // Send SMS + const message = await twilioClient.messages.create({ + body: body, + from: process.env.TWILIO_PHONE_NUMBER, + to: to + }); + + logger.info('SMS sent successfully', { + messageId: message.sid, + to: to, + length: body.length + }); + + response.json({ + success: true, + messageId: message.sid, + timestamp: message.dateCreated + }); + + } catch (error: any) { + logger.error('Error sending SMS:', error); + + const statusCode = error.status === 401 ? 401 : 500; + + response.status(statusCode).json({ + success: false, + error: error.message, + code: error.code, + moreInfo: error.moreInfo + }); + } + }); +}); \ No newline at end of file diff --git a/functions/src/storage/accessFile.ts b/functions/src/storage/accessFile.ts new file mode 100644 index 0000000..7fe1e61 --- /dev/null +++ b/functions/src/storage/accessFile.ts @@ -0,0 +1,45 @@ +import { onRequest } from "firebase-functions/v2/https"; +import { Request } from "firebase-functions/v2/https"; +import * as path from 'path'; +import { getCorsHandler } from "../shared/middleware"; +import { getLogger, getAdmin } from "../shared/config"; + +const corsHandler = getCorsHandler(); +const admin = getAdmin(); +const logger = getLogger(); + +export const accessFile = onRequest({ + region: '#{SERVICES_RGN}#' +}, async (request: Request, 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 = admin.storage().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'); + } + }); +}); diff --git a/functions/src/storage/index.ts b/functions/src/storage/index.ts new file mode 100644 index 0000000..582151a --- /dev/null +++ b/functions/src/storage/index.ts @@ -0,0 +1 @@ +export { accessFile } from './accessFile'; \ No newline at end of file