Compare commits

...

11 Commits

Author SHA1 Message Date
b0aaab0e81 fitlien-service-881 Removed the moved functions and the sendEmailSMS, sendSMSMessage 2025-10-20 16:20:37 +05:30
a69ed7078a feature/fitlien-828 (#124)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m53s
Reviewed-on: #124
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-23 07:37:15 +00:00
3baa70b0a0 feature/fitlien-828 (#123)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m39s
Reviewed-on: #123
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-23 05:38:43 +00:00
f08bd7648b feature/fitlien-828 (#122)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m12s
Reviewed-on: #122
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-22 08:30:39 +00:00
a8cb7dce8c feature/fitlien-828 (#121)
Some checks failed
Deploy FitLien services to Dev / Deploy to Dev (push) Failing after 42s
Reviewed-on: #121
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-22 08:26:16 +00:00
b6d222f0c3 feature/fitlien-828 (#120)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m26s
Reviewed-on: #120
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-22 06:26:10 +00:00
bf94866fbf feature/fitlien-828 (#119)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m29s
Reviewed-on: #119
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-19 11:08:57 +00:00
d492f660f5 feature/fitlien-828 (#118)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m24s
Reviewed-on: #118
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-19 06:55:06 +00:00
2963b23b61 ClientsLoadingFix (#117)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 3m52s
Reviewed-on: #117
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-18 12:21:32 +00:00
22b2f2adce ClientsLoadingFix (#116)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 4m15s
Reviewed-on: #116
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-18 10:34:42 +00:00
3ce4a9bdcb Changes Updated (#115)
All checks were successful
Deploy FitLien services to Dev / Deploy to Dev (push) Successful in 5m0s
Reviewed-on: #115
Reviewed-by: Dhansh A S <dhanshas@cosq.net>
Co-authored-by: Sharon Dcruz <sharondcruz@cosq.net>
Co-committed-by: Sharon Dcruz <sharondcruz@cosq.net>
2025-09-18 08:42:01 +00:00
14 changed files with 506 additions and 543 deletions

View File

@ -1,6 +1,3 @@
TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}#
TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}#
TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}#
SERVICES_RGN=#{SERVICES_RGN}#
CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}#
CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}#

View File

@ -1 +0,0 @@
export { sendEmailSES } from './sendEmailSES';

View File

@ -1,209 +0,0 @@
import { getLogger } from "../shared/config";
import { getCorsHandler } from "../shared/middleware";
import { onRequest } from "firebase-functions/v2/https";
import { Request } from "firebase-functions/v2/https";
import { Response } from "express";
import { SESClient } from "@aws-sdk/client-ses";
import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses";
import { HttpsError } from "firebase-functions/v2/https";
import * as mime from 'mime-types';
import axios from 'axios';
const logger = getLogger();
const corsHandler = getCorsHandler();
interface EmailRequest {
to: string | string[];
subject: string;
html: string;
text?: string;
from: string;
replyTo?: string;
attachments?: Attachment[];
fileUrl?: string;
fileName?: string;
}
interface Attachment {
filename: string;
content: string | Buffer;
contentType?: string;
}
const stripHtml = (html: string): string => {
if (!html) return '';
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
}
});
const command = new SendEmailCommand({
Source: data.from,
Destination: { ToAddresses: recipients },
Message: {
Subject: { Data: data.subject },
Body: {
Html: { Data: data.html },
Text: { Data: data.text || stripHtml(data.html) }
}
},
ReplyToAddresses: data.replyTo ? [data.replyTo] : undefined,
});
const result = await ses.send(command);
return { messageId: result.MessageId };
}
async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
}
});
const boundary = `boundary_${Math.random().toString(16).substr(2)}`;
let rawMessage = `From: ${data.from}\n`;
rawMessage += `To: ${recipients.join(', ')}\n`;
rawMessage += `Subject: ${data.subject}\n`;
rawMessage += `MIME-Version: 1.0\n`;
rawMessage += `Content-Type: multipart/mixed; boundary="${boundary}"\n\n`;
// Add email body (multipart/alternative)
rawMessage += `--${boundary}\n`;
rawMessage += `Content-Type: multipart/alternative; boundary="alt_${boundary}"\n\n`;
// Text part
if (data.text) {
rawMessage += `--alt_${boundary}\n`;
rawMessage += `Content-Type: text/plain; charset=UTF-8\n\n`;
rawMessage += `${data.text}\n\n`;
}
// HTML part
rawMessage += `--alt_${boundary}\n`;
rawMessage += `Content-Type: text/html; charset=UTF-8\n\n`;
rawMessage += `${data.html}\n\n`;
// Close alternative part
rawMessage += `--alt_${boundary}--\n\n`;
// Add attachments
for (const attachment of data.attachments || []) {
const contentType = attachment.contentType ||
mime.lookup(attachment.filename) ||
'application/octet-stream';
rawMessage += `--${boundary}\n`;
rawMessage += `Content-Type: ${contentType}; name="${attachment.filename}"\n`;
rawMessage += `Content-Disposition: attachment; filename="${attachment.filename}"\n`;
rawMessage += `Content-Transfer-Encoding: base64\n\n`;
const contentBuffer = typeof attachment.content === 'string'
? Buffer.from(attachment.content, 'base64')
: attachment.content;
rawMessage += contentBuffer.toString('base64') + '\n\n';
}
// Close message
rawMessage += `--${boundary}--`;
const command = new SendRawEmailCommand({
RawMessage: { Data: Buffer.from(rawMessage) }
});
const result = await ses.send(command);
return { messageId: result.MessageId };
}
async function downloadFileFromUrl(url: string): Promise<Buffer> {
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
return Buffer.from(response.data);
} catch (error) {
logger.error(`Error downloading file from URL: ${error}`);
throw new Error(`Failed to download file: ${error}`);
}
}
export const sendEmailSES = onRequest({
region: '#{SERVICES_RGN}#'
}, (request: Request, response: Response) => {
return corsHandler(request, response, async () => {
try {
const toAddress = request.body.toAddress;
const subject = request.body.subject;
const message = request.body.message;
// Initialize data with basic fields
const data: EmailRequest = {
to: toAddress,
html: message,
subject: subject,
text: stripHtml(message),
from: process.env.SES_FROM_EMAIL || 'support@fitlien.com',
replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com',
attachments: request.body.attachments as Attachment[] || []
};
// Handle file URL if provided
if (request.body.fileUrl && request.body.fileName) {
logger.info(`Downloading attachment from URL: ${request.body.fileUrl}`);
try {
const fileContent = await downloadFileFromUrl(request.body.fileUrl);
// If attachments array doesn't exist, create it
if (!data.attachments) {
data.attachments = [];
}
// Add the downloaded file as an attachment
data.attachments.push({
filename: request.body.fileName,
content: fileContent,
contentType: mime.lookup(request.body.fileName) || 'application/octet-stream'
});
logger.info(`Successfully downloaded attachment: ${request.body.fileName}`);
} catch (downloadError) {
logger.error(`Failed to download attachment: ${downloadError}`);
throw new Error(`Failed to process attachment: ${downloadError}`);
}
}
if (!data.to || !data.subject || !data.html || !data.from) {
throw new HttpsError(
'invalid-argument',
'Missing required email fields'
);
}
logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`);
const recipients = Array.isArray(data.to) ? data.to : [data.to];
if (data.attachments && data.attachments.length > 0) {
const messageResult = await sendEmailWithAttachments(data, recipients);
response.status(200).json(messageResult);
} else {
const messageResult = await sendSimpleEmail(data, recipients);
response.status(200).json(messageResult);
}
} catch (e) {
logger.error(`Error while sending E-mail. Error: ${e}`);
console.error(`Error while sending E-mail. Error: ${e}`);
response.status(500).json({
success: false,
error: 'Error while sending E-mail'
});
}
});
});

View File

@ -6,18 +6,21 @@ setGlobalOptions({
timeoutSeconds: 540,
minInstances: 0,
maxInstances: 10,
concurrency: 80
});
concurrency: 80,
});
export * from './shared/config';
export { sendEmailSES } from './email';
export { sendSMSMessage } from './sms';
export { accessFile } from './storage';
export { processNotificationOnCreate,checkExpiredMemberships } from './notifications';
export * from './payments';
export { getPlaceDetails, getPlacesAutocomplete } from './places';
export { registerClient } from './users';
export * from "./shared/config";
export { accessFile } from "./storage";
export {
esslGetUserDetails, esslUpdateUser,
esslDeleteUser, esslGetEmployeePunchLogs
} from './dooraccess';
processNotificationOnCreate,
checkExpiredMemberships,
} from "./notifications";
export * from "./payments";
export {
esslGetUserDetails,
esslUpdateUser,
esslDeleteUser,
esslGetEmployeePunchLogs,
} from "./dooraccess";
export { getMemberCache, updateMemberCache } from "./memberCache";

View File

@ -0,0 +1 @@
export { getMemberCache, updateMemberCache } from "./memberCache";

View File

@ -0,0 +1,446 @@
import { onRequest } from "firebase-functions/v2/https";
import { getLogger, getAdmin } from "../shared/config";
import * as admin from "firebase-admin";
const app = getAdmin();
const logger = getLogger();
const CACHE_FOLDER = "gym_member_cache";
interface MembershipData {
gymId?: string;
userId?: string;
status?: string;
subscriptionId?: string;
subscription?: {
hasPersonalTraining?: boolean;
frequency?: string;
price?: number;
[key: string]: any;
};
isPartialPayment?: boolean;
remainingAmount?: number;
daysUntilExpiry?: number;
expirationDate?: admin.firestore.Timestamp;
createdAt?: admin.firestore.Timestamp;
updatedAt?: admin.firestore.Timestamp;
[key: string]: any;
}
interface ClientFields {
fields?: { [key: string]: any };
[key: string]: any;
}
interface MinimalCacheEntry {
membershipId: string;
userId: string;
status: string;
displayName: string;
email?: string | null;
phoneNumber?: string | null;
alternateContact?: string | null;
renewalDate?: string | null;
expirationDate?: string | null;
createdAt?: string | null;
daysUntilExpiry?: number | null;
hasPersonalTraining: boolean;
hasPartialPayment: boolean;
remainingAmount: number;
subscriptionId?: string | null;
lastUpdated: string;
}
interface MinimalJsonCacheData {
gymId: string;
members: MinimalCacheEntry[];
totalMembers: number;
lastUpdated: string;
cacheVersion: string;
}
export const getMemberCache = onRequest(
{
region: "#{SERVICES_RGN}#",
cors: true,
},
async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "GET, POST");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).send("");
return;
}
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
let requestData: any = {};
if (req.method === "GET") {
requestData = req.query;
} else if (req.method === "POST") {
requestData = req.body;
}
const { gymId } = requestData;
if (!gymId) {
res.status(400).json({ error: "gymId is required" });
return;
}
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
try {
const [fileBuffer] = await file.download();
const jsonData: MinimalJsonCacheData = JSON.parse(
fileBuffer.toString()
);
logger.info(
`Retrieved ${jsonData.totalMembers} members from cache for gym ${gymId}`
);
res.status(200).json(jsonData);
} catch (error) {
logger.error(`Error reading cache file for gym ${gymId}:`, error);
res.status(404).json({
error: "Cache not found for this gym. Please update cache first.",
gymId,
members: [],
totalMembers: 0,
lastUpdated: new Date().toISOString(),
cacheVersion: "3.1",
});
}
} catch (error) {
logger.error("Error getting member cache:", error);
res.status(500).json({ error: "Error retrieving cached data" });
}
}
);
export const updateMemberCache = onRequest(
{
region: "#{SERVICES_RGN}#",
cors: true,
timeoutSeconds: 540,
},
async (req, res) => {
try {
if (req.method === "OPTIONS") {
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Methods", "POST");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.status(204).send("");
return;
}
res.set("Access-Control-Allow-Origin", "*");
res.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method !== "POST") {
res.status(405).json({ error: "Method not allowed. Use POST." });
return;
}
const { gymId, membershipIds } = req.body;
let incrementalUpdate = req.body.incrementalUpdate || false;
if (!gymId) {
res.status(400).json({ error: "gymId is required" });
return;
}
logger.info(
`Starting cache ${
incrementalUpdate ? "incremental update" : "full refresh"
} for gym: ${gymId}`
);
let members: MinimalCacheEntry[] = [];
let existingData: MinimalJsonCacheData | null = null;
if (incrementalUpdate) {
try {
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
const [fileBuffer] = await file.download();
existingData = JSON.parse(fileBuffer.toString());
if (existingData) {
members = [...existingData.members];
logger.info(
`Loaded existing cache with ${members.length} members for incremental update`
);
} else {
logger.warn(
"Existing cache data is invalid, performing full refresh"
);
incrementalUpdate = false;
}
} catch (error) {
logger.warn(
`Could not load existing cache for incremental update, performing full refresh: ${error}`
);
incrementalUpdate = false;
}
}
if (incrementalUpdate && membershipIds && Array.isArray(membershipIds)) {
await updateSpecificMembers(gymId, membershipIds, members);
} else {
members = await fetchAllMinimalMembers(gymId);
}
const jsonData: MinimalJsonCacheData = {
gymId,
members,
totalMembers: members.length,
lastUpdated: new Date().toISOString(),
cacheVersion: "3.1",
};
await saveCacheToStorage(gymId, jsonData);
const updateType = incrementalUpdate
? "incremental update"
: "full refresh";
logger.info(
`Cache ${updateType} completed successfully for gym ${gymId} with ${members.length} members`
);
res.status(200).json({
success: true,
message: `Cache ${updateType} completed successfully for gym ${gymId}`,
totalMembers: members.length,
lastUpdated: jsonData.lastUpdated,
updateType: updateType,
});
} catch (error) {
logger.error("Error updating member cache:", error);
res.status(500).json({
error: "Error updating cache",
details: error instanceof Error ? error.message : String(error),
});
}
}
);
async function fetchAllMinimalMembers(
gymId: string
): Promise<MinimalCacheEntry[]> {
const members: MinimalCacheEntry[] = [];
const membershipsSnapshot = await app
.firestore()
.collection("memberships")
.where("gymId", "==", gymId)
.orderBy("createdAt", "desc")
.get();
const batchSize = 10;
for (let i = 0; i < membershipsSnapshot.docs.length; i += batchSize) {
const batch = membershipsSnapshot.docs.slice(i, i + batchSize);
const batchPromises = batch.map(async (doc) => {
try {
const membershipData = doc.data() as MembershipData;
const userId = membershipData.userId;
if (!userId) {
logger.warn(
`Skipping member with ID ${doc.id} due to missing userId`
);
return null;
}
return await generateMinimalCacheEntry(userId, doc.id, membershipData, gymId);
} catch (error) {
logger.error(`Error processing member ${doc.id}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
const validResults = batchResults.filter(
(member): member is MinimalCacheEntry => member !== null
);
members.push(...validResults);
}
return members;
}
async function updateSpecificMembers(
gymId: string,
membershipIds: string[],
existingMembers: MinimalCacheEntry[]
): Promise<void> {
logger.info(`Updating ${membershipIds.length} specific members`);
const existingMembersMap = new Map<string, number>();
existingMembers.forEach((member, index) => {
existingMembersMap.set(member.membershipId, index);
});
const batchSize = 10;
for (let i = 0; i < membershipIds.length; i += batchSize) {
const batch = membershipIds.slice(i, i + batchSize);
const membershipDocs = await Promise.all(
batch.map(async (membershipId) => {
try {
const doc = await app
.firestore()
.collection("memberships")
.doc(membershipId)
.get();
return doc.exists ? { id: doc.id, data: doc.data() } : null;
} catch (error) {
logger.error(`Error fetching membership ${membershipId}:`, error);
return null;
}
})
);
const batchPromises = membershipDocs
.filter((doc): doc is { id: string; data: any } => doc !== null)
.map(async (doc) => {
try {
const membershipData = doc.data as MembershipData;
const userId = membershipData.userId;
if (!userId) {
logger.warn(
`Skipping member with ID ${doc.id} due to missing userId`
);
return null;
}
return await generateMinimalCacheEntry(
userId,
doc.id,
membershipData,
gymId
);
} catch (error) {
logger.error(`Error processing member ${doc.id}:`, error);
return null;
}
});
const batchResults = await Promise.all(batchPromises);
batchResults.forEach((updatedMember) => {
if (updatedMember) {
const existingIndex = existingMembersMap.get(
updatedMember.membershipId
);
if (existingIndex !== undefined) {
existingMembers[existingIndex] = updatedMember;
} else {
existingMembers.push(updatedMember);
}
}
});
}
}
async function saveCacheToStorage(
gymId: string,
jsonData: MinimalJsonCacheData
): Promise<void> {
const fileName = `${CACHE_FOLDER}/${gymId}.json`;
const file = app.storage().bucket().file(fileName);
await file.save(JSON.stringify(jsonData, null, 2), {
metadata: {
contentType: "application/json",
metadata: {
gymId: gymId,
totalMembers: jsonData.totalMembers.toString(),
generatedAt: jsonData.lastUpdated,
cacheVersion: jsonData.cacheVersion,
},
},
});
}
async function generateMinimalCacheEntry(
userId: string,
membershipId: string,
membershipData: MembershipData,
gymId: string
): Promise<MinimalCacheEntry> {
try {
let firstName = "";
let lastName = "";
let email = "";
let phoneNumber = "";
let alternateContact = "";
try {
const clientFieldsSnapshot = await app
.firestore()
.collection("client_profiles")
.where("uid", "==", userId)
.get();
if (!clientFieldsSnapshot.empty) {
const fieldDoc = clientFieldsSnapshot.docs[0];
const fieldData = fieldDoc.data() as ClientFields;
const fields = fieldData.fields || {};
firstName = fields["first-name"] || fieldData["first-name"] || "";
lastName = fields["last-name"] || fieldData["last-name"] || "";
email = fields["email"] || fieldData["email"] || "";
phoneNumber = fields["phone-number"] || fieldData["phone-number"] || "";
alternateContact =
fields["alternate-contact"] || fieldData["alternate-contact"] || "";
}
} catch (error) {
logger.error(`Error getting fields for user ${userId}:`, error);
}
const daysUntilExpiry = membershipData.daysUntilExpiry || null;
const displayName =
firstName.length === 0
? "Unknown"
: lastName.length === 0
? firstName
: `${firstName} ${lastName}`;
const isPartial = membershipData.isPartialPayment === true;
const remaining = membershipData.remainingAmount || 0;
const hasPartialPayment = isPartial && remaining > 0;
const minimalEntry: MinimalCacheEntry = {
membershipId,
userId,
status: membershipData.status || "N/A",
displayName,
email: email || null,
phoneNumber: phoneNumber || null,
alternateContact: alternateContact || null,
renewalDate: null,
expirationDate: membershipData.expirationDate
? membershipData.expirationDate.toDate().toISOString()
: null,
createdAt: membershipData.createdAt
? membershipData.createdAt.toDate().toISOString()
: null,
daysUntilExpiry,
hasPersonalTraining:
membershipData.subscription?.hasPersonalTraining === true,
hasPartialPayment,
remainingAmount: remaining,
subscriptionId: membershipData.subscriptionId || null,
lastUpdated: new Date().toISOString(),
};
return minimalEntry;
} catch (error) {
logger.error("Error generating minimal cache entry:", error);
throw error;
}
}

View File

@ -6,6 +6,40 @@ const app = getAdmin();
const logger = getLogger();
const kTrainerRole = "Trainer";
async function updateCacheForMembership(
gymId: string,
membershipId: string
): Promise<void> {
try {
const response = await fetch(
`https://updatemembercache-2k7djjvd3q-el.a.run.app/updateMemberCache`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
gymId: gymId,
incrementalUpdate: true,
membershipIds: [membershipId],
}),
}
);
if (response.ok) {
logger.info(
`Cache updated successfully for membership ${membershipId} in gym ${gymId}`
);
} else {
logger.warn(
`Cache update failed for membership ${membershipId}: ${response.status}`
);
}
} catch (error) {
logger.error(`Error updating cache for membership ${membershipId}:`, error);
}
}
interface MembershipData {
id?: string;
userId: string;
@ -150,6 +184,7 @@ async function findExpiredMembershipsWithoutExpiryDate(): Promise<
throw error;
}
}
async function updateExpiryDateForExpiredMembership(
membershipId: string,
membershipData: MembershipData
@ -184,6 +219,8 @@ async function updateExpiryDateForExpiredMembership(
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await updateCacheForMembership(membershipData.gymId, membershipId);
logger.info(
`Updated expiry date for expired membership ${membershipId}: ${expiryDate.toISOString()}`
);
@ -513,6 +550,8 @@ async function updateDaysUntilExpiryForAllMemberships(): Promise<void> {
.doc(doc.id)
.update(updateData);
await updateCacheForMembership(data.gymId, doc.id);
logger.info(
`Updated membership ${doc.id} with daysUntilExpiry: ${daysUntilExpiry}`
);
@ -535,7 +574,6 @@ async function updateDaysUntilExpiryForAllMemberships(): Promise<void> {
throw error;
}
}
async function calculateDaysUntilExpiry(
membershipId: string,
data: MembershipData
@ -665,6 +703,8 @@ async function processExpiredMembership(
});
}
await updateCacheForMembership(membershipData.gymId, membershipId);
logger.info(`Marked membership ${membershipId} as EXPIRED`);
await sendPlanExpiredNotification(membershipId, membershipData);
@ -697,6 +737,8 @@ async function processExpiringMembership(
expirationDate: admin.firestore.Timestamp.fromDate(expiryDate),
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
});
await updateCacheForMembership(membershipData.gymId, membershipId);
}
await sendPlanExpiringNotification(membershipId, membershipData);

View File

@ -1,67 +0,0 @@
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)
});
}
});
});

View File

@ -1,51 +0,0 @@
import { onRequest } from "firebase-functions/v2/https";
import { Request } from "firebase-functions/v2/https";
import * as express from "express";
import axios from "axios";
const { getCorsHandler } = require('../shared/middleware');
const corsHandler = getCorsHandler();
const { getLogger } = require('../shared/config');
const logger = 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)
});
}
});
});

View File

@ -1,2 +0,0 @@
export { getPlaceDetails } from './details';
export { getPlacesAutocomplete } from './autocomplete';

View File

@ -1 +0,0 @@
export { sendSMSMessage } from './sendSMS';

View File

@ -1,77 +0,0 @@
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
});
}
});
});

View File

@ -1,117 +0,0 @@
import { onRequest } from "firebase-functions/v2/https";
import { getCorsHandler } from "../shared/middleware";
import { getAdmin, getLogger } from "../shared/config";
import { Request } from "firebase-functions/v2/https";
import { Response } from "express";
const corsHandler = getCorsHandler();
const admin = getAdmin();
const logger = getLogger();
export const registerClient = onRequest({
region: '#{SERVICES_RGN}#'
}, async (req: Request, res: Response) => {
return corsHandler(req, res, async () => {
try {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed. Please use POST.' });
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized. Missing or invalid authorization header.' });
}
const idToken = authHeader.split('Bearer ')[1];
try {
const decodedToken = await admin.auth().verifyIdToken(idToken);
const uid = decodedToken.uid;
const userDoc = await admin.firestore().collection('users').doc(uid).get();
if (!userDoc.exists) {
return res.status(403).json({ error: 'Forbidden. User not found.' });
}
const userData = userDoc.data();
if (!userData || !userData.roles || !userData.roles.includes('gym_owner')) {
return res.status(403).json({ error: 'Forbidden. Only gym owners can register clients.' });
}
const gymUser = req.body;
if (!gymUser.fields["phone-number"]) {
return res.status(400).json({ error: 'Phone number is required' });
}
let clientUid;
try {
const userRecord = await admin.auth().getUserByPhoneNumber(gymUser.fields["phone-number"])
.catch(() => null);
if (userRecord) {
clientUid = userRecord.uid;
} else {
const newUser = await admin.auth().createUser({
phoneNumber: gymUser.fields["phone-number"],
displayName: gymUser.fields["first-name"] || '',
});
clientUid = newUser.uid;
}
} catch (error) {
logger.error('Error creating authentication user:', error);
return res.status(500).json({
error: 'Failed to create authentication user',
details: error
});
}
try {
gymUser.uid = clientUid;
gymUser.registeredBy = uid;
if (gymUser.name) {
gymUser.normalizedName = gymUser.name.toLowerCase();
}
if (gymUser.dateOfBirth && !(typeof gymUser.dateOfBirth === 'string')) {
gymUser.dateOfBirth = new Date(gymUser.dateOfBirth).toISOString();
}
const clientData = {
...gymUser,
};
await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData);
return res.status(201).json({
success: true,
message: 'Client registered successfully',
clientId: clientUid
});
} catch (error) {
logger.error('Error creating client profile:', error);
try {
if (!gymUser.uid) {
await admin.auth().deleteUser(clientUid);
}
} catch (deleteError) {
logger.error('Error deleting auth user after failed profile creation:', deleteError);
}
return res.status(500).json({
error: 'Failed to create client profile',
details: error
});
}
} catch (authError) {
logger.error('Authentication error:', authError);
return res.status(401).json({ error: 'Unauthorized. Invalid token.' });
}
} catch (error) {
logger.error('Unexpected error in client registration:', error);
return res.status(500).json({
error: 'Internal server error',
details: error
});
}
});
});

View File

@ -1 +0,0 @@
export { registerClient } from './clientRegistration';