Compare commits
29 Commits
feature/fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 61f0d29f37 | |||
| 209c7c65b0 | |||
| 5ca05c6490 | |||
| 7ec65e4ab1 | |||
| 4f71ca273b | |||
| 4d52d1c7f8 | |||
| aee40521d3 | |||
| 36015d2b83 | |||
| 51fa0825ca | |||
| 3e455fc83a | |||
| f6b1545cf6 | |||
| 195262a6de | |||
| d3c9e86c7c | |||
| 8b308fb9a6 | |||
| 9d51393aa5 | |||
| cc5b6d6987 | |||
| ef2cd80226 | |||
| f19e3b012d | |||
| f2e37e88ed | |||
| a0134466ee | |||
| ecbe9d184b | |||
| 7a796243b0 | |||
| 7db9e479ad | |||
| cf6f4625ad | |||
| 6434f6e3fa | |||
| 2147963523 | |||
| 18569d38d3 | |||
| 5bc3d6dfff | |||
| e8ca80df48 |
@ -19,9 +19,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Clean install
|
|
||||||
run: npm clean-install
|
|
||||||
|
|
||||||
- name: Copy .env.example to .env
|
- name: Copy .env.example to .env
|
||||||
run: cp functions/.env.example functions/.env
|
run: cp functions/.env.example functions/.env
|
||||||
|
|
||||||
|
|||||||
@ -19,9 +19,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
- name: Clean install
|
|
||||||
run: npm clean-install
|
|
||||||
|
|
||||||
- name: Copy .env.example to .env
|
- name: Copy .env.example to .env
|
||||||
run: cp functions/.env.example functions/.env
|
run: cp functions/.env.example functions/.env
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}#
|
||||||
|
TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}#
|
||||||
|
TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}#
|
||||||
SERVICES_RGN=#{SERVICES_RGN}#
|
SERVICES_RGN=#{SERVICES_RGN}#
|
||||||
CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}#
|
CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}#
|
||||||
CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}#
|
CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}#
|
||||||
|
|||||||
1
functions/src/email/index.ts
Normal file
1
functions/src/email/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { sendEmailSES } from './sendEmailSES';
|
||||||
209
functions/src/email/sendEmailSES.ts
Normal file
209
functions/src/email/sendEmailSES.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -6,21 +6,18 @@ setGlobalOptions({
|
|||||||
timeoutSeconds: 540,
|
timeoutSeconds: 540,
|
||||||
minInstances: 0,
|
minInstances: 0,
|
||||||
maxInstances: 10,
|
maxInstances: 10,
|
||||||
concurrency: 80,
|
concurrency: 80
|
||||||
});
|
});
|
||||||
|
|
||||||
export * from "./shared/config";
|
export * from './shared/config';
|
||||||
export { accessFile } from "./storage";
|
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 {
|
export {
|
||||||
processNotificationOnCreate,
|
esslGetUserDetails, esslUpdateUser,
|
||||||
checkExpiredMemberships,
|
esslDeleteUser, esslGetEmployeePunchLogs
|
||||||
} from "./notifications";
|
} from './dooraccess';
|
||||||
export * from "./payments";
|
|
||||||
export {
|
|
||||||
esslGetUserDetails,
|
|
||||||
esslUpdateUser,
|
|
||||||
esslDeleteUser,
|
|
||||||
esslGetEmployeePunchLogs,
|
|
||||||
} from "./dooraccess";
|
|
||||||
|
|
||||||
export { getMemberCache, updateMemberCache } from "./memberCache";
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
export { getMemberCache, updateMemberCache } from "./memberCache";
|
|
||||||
@ -1,446 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,40 +6,6 @@ const app = getAdmin();
|
|||||||
const logger = getLogger();
|
const logger = getLogger();
|
||||||
const kTrainerRole = "Trainer";
|
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 {
|
interface MembershipData {
|
||||||
id?: string;
|
id?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -184,7 +150,6 @@ async function findExpiredMembershipsWithoutExpiryDate(): Promise<
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateExpiryDateForExpiredMembership(
|
async function updateExpiryDateForExpiredMembership(
|
||||||
membershipId: string,
|
membershipId: string,
|
||||||
membershipData: MembershipData
|
membershipData: MembershipData
|
||||||
@ -219,8 +184,6 @@ async function updateExpiryDateForExpiredMembership(
|
|||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateCacheForMembership(membershipData.gymId, membershipId);
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Updated expiry date for expired membership ${membershipId}: ${expiryDate.toISOString()}`
|
`Updated expiry date for expired membership ${membershipId}: ${expiryDate.toISOString()}`
|
||||||
);
|
);
|
||||||
@ -550,8 +513,6 @@ async function updateDaysUntilExpiryForAllMemberships(): Promise<void> {
|
|||||||
.doc(doc.id)
|
.doc(doc.id)
|
||||||
.update(updateData);
|
.update(updateData);
|
||||||
|
|
||||||
await updateCacheForMembership(data.gymId, doc.id);
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Updated membership ${doc.id} with daysUntilExpiry: ${daysUntilExpiry}`
|
`Updated membership ${doc.id} with daysUntilExpiry: ${daysUntilExpiry}`
|
||||||
);
|
);
|
||||||
@ -574,6 +535,7 @@ async function updateDaysUntilExpiryForAllMemberships(): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function calculateDaysUntilExpiry(
|
async function calculateDaysUntilExpiry(
|
||||||
membershipId: string,
|
membershipId: string,
|
||||||
data: MembershipData
|
data: MembershipData
|
||||||
@ -703,8 +665,6 @@ async function processExpiredMembership(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateCacheForMembership(membershipData.gymId, membershipId);
|
|
||||||
|
|
||||||
logger.info(`Marked membership ${membershipId} as EXPIRED`);
|
logger.info(`Marked membership ${membershipId} as EXPIRED`);
|
||||||
|
|
||||||
await sendPlanExpiredNotification(membershipId, membershipData);
|
await sendPlanExpiredNotification(membershipId, membershipData);
|
||||||
@ -737,8 +697,6 @@ async function processExpiringMembership(
|
|||||||
expirationDate: admin.firestore.Timestamp.fromDate(expiryDate),
|
expirationDate: admin.firestore.Timestamp.fromDate(expiryDate),
|
||||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateCacheForMembership(membershipData.gymId, membershipId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendPlanExpiringNotification(membershipId, membershipData);
|
await sendPlanExpiringNotification(membershipId, membershipData);
|
||||||
|
|||||||
67
functions/src/places/autocomplete.ts
Normal file
67
functions/src/places/autocomplete.ts
Normal file
@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
51
functions/src/places/details.ts
Normal file
51
functions/src/places/details.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
2
functions/src/places/index.ts
Normal file
2
functions/src/places/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { getPlaceDetails } from './details';
|
||||||
|
export { getPlacesAutocomplete } from './autocomplete';
|
||||||
1
functions/src/sms/index.ts
Normal file
1
functions/src/sms/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { sendSMSMessage } from './sendSMS';
|
||||||
77
functions/src/sms/sendSMS.ts
Normal file
77
functions/src/sms/sendSMS.ts
Normal file
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
117
functions/src/users/clientRegistration.ts
Normal file
117
functions/src/users/clientRegistration.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1
functions/src/users/index.ts
Normal file
1
functions/src/users/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { registerClient } from './clientRegistration';
|
||||||
Loading…
Reference in New Issue
Block a user