Merge branch 'dev'
All checks were successful
Deploy FitLien services / Deploy (push) Successful in 4m24s

This commit is contained in:
Benoy Bose 2025-06-12 19:47:27 +05:30
commit f2e37e88ed
14 changed files with 699 additions and 33 deletions

View File

@ -25,6 +25,12 @@ jobs:
- 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
- name: Create private key file
run: |
mkdir -p functions/assets/keys
echo "${{ secrets.FITLIEN_PRIVATEKEY_DEV }}" > functions/assets/keys/fitLien_private.pem
chmod 600 functions/assets/keys/fitLien_private.pem
- name: Replace variables in .env - name: Replace variables in .env
run: | run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env

View File

@ -25,6 +25,12 @@ jobs:
- 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
- name: Create private key file
run: |
mkdir -p functions/assets/keys
echo "${{ secrets.FITLIEN_PRIVATEKEY_DEV }}" > functions/assets/keys/fitLien_private.pem
chmod 600 functions/assets/keys/fitLien_private.pem
- name: Replace variables in .env - name: Replace variables in .env
run: | run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env

View File

@ -25,6 +25,12 @@ jobs:
- 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
- name: Create private key file
run: |
mkdir -p functions/assets/keys
echo "${{ secrets.FITLIEN_PRIVATEKEY }}" > functions/assets/keys/fitLien_private.pem
chmod 600 functions/assets/keys/fitLien_private.pem
- name: Replace variables in .env - name: Replace variables in .env
run: | run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env

6
.gitignore vendored
View File

@ -26,6 +26,9 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Private key
/functions/assets/keys/fitLien_private.pem
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
@ -67,3 +70,6 @@ node_modules/
# dataconnect generated files # dataconnect generated files
.dataconnect .dataconnect
.DS_Store
**/.DS_Store

View File

@ -25,13 +25,15 @@
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"pdfjs-dist": "^5.0.375", "pdfjs-dist": "^5.0.375",
"pdfmake": "^0.2.20", "pdfmake": "^0.2.20",
"twilio": "^5.4.0" "twilio": "^5.4.0",
"xmldom": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/long": "^5.0.0", "@types/long": "^5.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.13.14", "@types/node": "^22.15.31",
"@types/pdfmake": "^0.2.11", "@types/pdfmake": "^0.2.11",
"@types/xmldom": "^0.1.34",
"firebase-functions-test": "^3.1.0", "firebase-functions-test": "^3.1.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },
@ -2829,9 +2831,10 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.15.2", "version": "22.15.31",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz",
"integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", "integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==",
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -2946,6 +2949,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true "optional": true
}, },
"node_modules/@types/xmldom": {
"version": "0.1.34",
"resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.34.tgz",
"integrity": "sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA==",
"dev": true
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.33", "version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@ -8324,6 +8333,14 @@
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="
}, },
"node_modules/xmldom": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz",
"integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -32,13 +32,15 @@
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"pdfjs-dist": "^5.0.375", "pdfjs-dist": "^5.0.375",
"pdfmake": "^0.2.20", "pdfmake": "^0.2.20",
"twilio": "^5.4.0" "twilio": "^5.4.0",
"xmldom": "^0.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/long": "^5.0.0", "@types/long": "^5.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.13.14", "@types/node": "^22.15.31",
"@types/pdfmake": "^0.2.11", "@types/pdfmake": "^0.2.11",
"@types/xmldom": "^0.1.34",
"firebase-functions-test": "^3.1.0", "firebase-functions-test": "^3.1.0",
"typescript": "^5.8.2" "typescript": "^5.8.2"
}, },

View File

@ -0,0 +1,7 @@
export type DoorAccessUser = {
name: string;
location: string;
role: string;
expireFrom: Date | null;
expireTo: Date | null;
};

View File

@ -0,0 +1,560 @@
import { onRequest } from "firebase-functions/https";
import { DoorAccessUser } from "./doorAccessUser";
import { Request } from "firebase-functions/v2/https";
import { Response } from "express";
import { getCorsHandler } from "../shared/middleware";
import { getLogger } from "../shared/config";
import { DOMParser } from 'xmldom';
import { RSADecryption } from "../shared/decrypt";
const logger = getLogger();
const corsHandler = getCorsHandler();
export interface EmployeeCodeRequest {
employeeCode: string;
}
export interface PushLogRequest extends EmployeeCodeRequest {
attendanceDate: string;
}
export interface UpdateEmployeeExRequest {
employeeCode: string;
employeeName: string;
employeeLocation: string;
employeeRole: string;
employeeVerificationType: string;
employeeExpiryFrom: string;
employeeExpiryTo: string;
employeeCardNumber: string;
groupId: string;
employeePhoto: string;
}
function getDecryptedPassword(password: string | null): string {
if (!password) {
throw new Error('Password is required');
}
return RSADecryption.decryptPassword(password);
}
const escapeXml = (str: string) => {
return str
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
function createGetEmployeeDetailsRequest(username: string, password: string, employeeCode: string): string | null {
const soapRequest = `<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<GetEmployeeDetails xmlns="http://tempuri.org/">
<UserName>${escapeXml(username)}</UserName>
<Password>${escapeXml(password)}</Password>
<EmployeeCode>${escapeXml(employeeCode)}</EmployeeCode>
</GetEmployeeDetails>
</soap12:Body>
</soap12:Envelope>`;
return soapRequest;
}
function parseGetEmployeeDetailsResponse(soapResponse: string): DoorAccessUser {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(soapResponse, "text/xml");
if (xmlDoc.documentElement.tagName !== 'soap:Envelope') {
throw new Error("Invalid SOAP response");
}
if (null == xmlDoc.documentElement.firstChild) {
throw new Error("Invalid SOAP response");
}
let currentElement = xmlDoc.documentElement.firstChild as HTMLElement;
if (currentElement.tagName !== 'soap:Body') {
throw new Error("Invalid SOAP response");
}
currentElement = currentElement.firstChild as HTMLElement;
if (currentElement.tagName !== 'GetEmployeeDetailsResponse') {
throw new Error("Invalid SOAP response");
}
currentElement = currentElement.firstChild as HTMLElement;
if (currentElement.tagName !== 'GetEmployeeDetailsResult') {
throw new Error("Invalid SOAP response");
}
const resultText = currentElement.textContent;
if (!resultText) {
throw new Error("GetEmployeeDetailsResult is empty");
}
const userDetails: DoorAccessUser =
{
name: '',
location: '',
role: '',
expireFrom: null,
expireTo: null
};
const pairs = resultText.split(',');
pairs.forEach(pair => {
const [key, value] = pair.split('=');
if (key && value !== undefined) {
const cleanKey = key.trim();
const cleanValue = value.trim();
switch (cleanKey) {
case 'EmployeeName':
userDetails.name = cleanValue;
break;
case 'EmployeeLocation':
userDetails.location = cleanValue;
break;
case 'EmployeeRole':
userDetails.role = cleanValue;
break;
case 'EmployeeExpiryFrom':
userDetails.expireFrom = new Date(cleanValue);
break;
case 'EmployeeExpiryTo':
userDetails.expireTo = new Date(cleanValue);
break;
default:
break;
}
}
});
return userDetails;
}
function isValidDateString(dateString: string): boolean {
const dateRegex = /^\d{4}\-\d{2}\-\d{2}$/;
return dateRegex.test(dateString);
}
function createUpdateEmployeeExRequest(username: string, password: string, request: UpdateEmployeeExRequest): string | null {
if (!username || !password || !request.employeeCode || !request.employeeName || !request.employeeLocation) {
throw new Error('Missing required fields');
}
if (!isValidDateString(request.employeeExpiryFrom) || !isValidDateString(request.employeeExpiryTo)) {
throw new Error('Invalid date format');
}
const soapRequest = `<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<UpdateEmployeeEx xmlns="http://tempuri.org/">
<UserName>${escapeXml(username)}</UserName>
<Password>${escapeXml(password)}</Password>
<EmployeeCode>${escapeXml(request.employeeCode)}</EmployeeCode>
<EmployeeName>${escapeXml(request.employeeName)}</EmployeeName>
<EmployeeLocation>${escapeXml(request.employeeLocation)}</EmployeeLocation>
<EmployeeRole>${escapeXml(request.employeeRole)}</EmployeeRole>
<EmployeeVerificationType>Card</EmployeeVerificationType>
<EmployeeExpiryFrom>${escapeXml(request.employeeExpiryFrom)}</EmployeeExpiryFrom>
<EmployeeExpiryTo>${escapeXml(request.employeeExpiryTo)}</EmployeeExpiryTo>
<EmployeeCardNumber>${escapeXml(request.employeeCardNumber)}</EmployeeCardNumber>
<GroupId></GroupId>
<EmployeePhoto></EmployeePhoto>
</UpdateEmployeeEx>
</soap12:Body>
</soap12:Envelope>
`;
return soapRequest;
}
function parseUpdateEmployeeExResponse(soapResponse: string): string | null {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(soapResponse, "text/xml");
const currentElement = xmlDoc.documentElement.firstChild as HTMLElement;
const resultText = currentElement.textContent;
return resultText;
}
function createDeleteEmployeeRequest(username: string, password: string, employeeCode: string): string | null {
if (!username || !password || !employeeCode) {
throw new Error('Missing required fields');
}
const soapRequst = `<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<DeleteEmployee xmlns="http://tempuri.org/">
<UserName>${escapeXml(username)}</UserName>
<Password>${escapeXml(password)}</Password>
<EmployeeCode>${escapeXml(employeeCode)}</EmployeeCode>
</DeleteEmployee>
</soap12:Body>
</soap12:Envelope>`;
return soapRequst;
}
function parseDeleteEmployeeResponse(soapResponse: string): string | null {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(soapResponse, "text/xml");
const currentElement = xmlDoc.documentElement.firstChild as HTMLElement;
const resultText = currentElement.textContent;
return resultText;
}
function createGetEmployeePunchLogsRequest(username: string, password: string,
employeeCode: string, attendanceDate: string): string | null {
const soapRequest = `<?xml version="1.0" encoding="utf-8"?>
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<GetEmployeePunchLogs xmlns="http://tempuri.org/">
<UserName>cosqclient</UserName>
<Password>3bbb58d5</Password>
<EmployeeCode>1</EmployeeCode>
<AttendanceDate>2025-05-24</AttendanceDate>
</GetEmployeePunchLogs>
</soap12:Body>
</soap12:Envelope>`;
return soapRequest;
}
function createDateFromTime(date: Date, timeString: string): Date {
const [hour, minute, second] = timeString.split(':').map(str => parseInt(str, 10));
const newDate = new Date(date.getTime());
newDate.setHours(hour);
newDate.setMinutes(minute);
newDate.setSeconds(second);
return newDate;
}
function parseGetEmployeePunchLogsResponse(soapResponse: string, attendanceDate: string): Date[] {
const rootDate = new Date(attendanceDate);
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(soapResponse, "text/xml");
const currentElement = xmlDoc.documentElement.firstChild as HTMLElement;
const resultText = currentElement.textContent;
const punchLogs: Date[] = [];
const parts = resultText!.split(';');
for (const part of parts) {
try {
const logDateTime = new Date(part);
if (isNaN(logDateTime.getTime())) {
throw new Error('Invalid date format');
}
punchLogs.push(logDateTime);
} catch {
try {
const timeParts = part.split(',');
for (const timePart of timeParts) {
try {
const logDateTime = createDateFromTime(rootDate, timePart);
punchLogs.push(logDateTime);
} catch {
continue;
}
}
} catch {
continue;
}
}
}
const sortedLogs = punchLogs.sort((a, b) => b.getTime() - a.getTime());
return sortedLogs;
}
async function sendSoapRequest(soapRequest: string, endpoint: string) {
try {
const headers: any = {
'Content-Type': 'application/soap+xml; charset=utf-8',
'Content-Length': soapRequest.length.toString()
};
const response = await fetch(endpoint, {
method: 'POST',
headers: headers,
body: soapRequest
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.text();
} catch (error: any) {
throw new Error(`SOAP request failed: ${error.message}`);
}
}
async function getUserDetails(username: string,
password: string,
employeeCode: string, endpoint: string): Promise<DoorAccessUser> {
const soapRequest = createGetEmployeeDetailsRequest(username, password, employeeCode);
const soapResponse = await sendSoapRequest(soapRequest!, endpoint);
const parsedResponse = parseGetEmployeeDetailsResponse(soapResponse);
return parsedResponse;
}
async function updateUserEx(username: string,
password: string,
request: UpdateEmployeeExRequest,
endpoint: string) {
const soapRequest = createUpdateEmployeeExRequest(username, password, request);
const soapResponse = await sendSoapRequest(soapRequest!, endpoint);
const parsedResponse = parseUpdateEmployeeExResponse(soapResponse);
return parsedResponse;
}
async function deleteEmplyee(username: string,
password: string,
employeeCode: string, endpoint: string) {
const soapRequest = createDeleteEmployeeRequest(username, password, employeeCode);
const soapResponse = await sendSoapRequest(soapRequest!, endpoint);
const parsedResponse = parseDeleteEmployeeResponse(soapResponse);
return parsedResponse;
}
async function getEmployeePunchLogs(username: string,
password: string,
employeeCode: string,
attendanceDate: string, endpoint: string): Promise<Date[]> {
const soapRequest = createGetEmployeePunchLogsRequest(username, password, employeeCode, attendanceDate);
const soapResponse = await sendSoapRequest(soapRequest!, endpoint);
const parsedResponse = parseGetEmployeePunchLogsResponse(soapResponse, attendanceDate);
return parsedResponse;
}
export const esslGetUserDetails = onRequest({
region: '#{SERVICES_RGN}#'
}, async (request: Request, response: Response) => {
return corsHandler(request, response, async () => {
try {
let username: string | null = request.body.username as string;
let password: string | null = request.body.password as string;
let endpoint: string | null = request.body.endpoint as string;
let gymId: string | null = request.body.gymId as string;
const getEmployeeDetailsRequest = request.body.params as EmployeeCodeRequest;
if (!username) {
throw new Error('Missing username or password');
}
username = username.trim();
if (!password) {
if (!gymId) {
throw new Error('Missing password or gymId');
}
// todo: Get password from gym configuration by decrypting with private key
throw new Error('Gym-based password retrieval not implemented yet');
}
password = getDecryptedPassword(password);
if (!getEmployeeDetailsRequest) {
throw new Error('Missing request params');
}
const employeeCode = getEmployeeDetailsRequest.employeeCode;
if (!employeeCode) {
throw new Error('Missing employeeCode');
}
if (!endpoint) {
throw new Error('Missing endpoint');
}
if (!endpoint || endpoint.trim() === '') {
throw new Error('Missing endpoint');
}
try {
new URL(endpoint);
} catch (_) {
throw new Error('Endpoint is not a valid URI or URL');
}
if (!endpoint.endsWith('/webservice.asmx')) {
if (endpoint.endsWith('/')) {
endpoint = endpoint.substring(0, endpoint.length - 1);
}
endpoint += '/webservice.asmx';
}
const userDetails = await getUserDetails(username, password, employeeCode, endpoint);
response.send(userDetails);
} catch (error: any) {
logger.error(error);
response.status(500).send({ error: error.message });
}
})
});
export const esslUpdateUser = onRequest({
region: '#{SERVICES_RGN}#'
}, async (request: Request, response: Response) => {
return corsHandler(request, response, async () => {
try {
let username: string | null = request.body.username as string;
let password: string | null = request.body.password as string;
let endpoint: string | null = request.body.endpoint as string;
let gymId: string | null = request.body.gymId as string;
const updateEmployeeExRequest = request.body.params as UpdateEmployeeExRequest;
if (!username) {
throw new Error('Missing username');
}
username = username.trim();
if (!password) {
if (!gymId) {
throw new Error('Missing password or gymId');
}
// TODO: Get password from gym configuration by decrypting with private key
throw new Error('Gym-based password retrieval not implemented yet');
}
password = getDecryptedPassword(password);
if (!endpoint) {
throw new Error('Missing endpoint');
}
endpoint = endpoint.trim();
if (!endpoint || endpoint.trim() === '') {
throw new Error('Missing endpoint');
}
try {
new URL(endpoint);
} catch (_) {
throw new Error('Endpoint is not a valid URI or URL');
}
if (!endpoint.endsWith('/webservice.asmx')) {
if (endpoint.endsWith('/')) {
endpoint = endpoint.substring(0, endpoint.length - 1);
}
endpoint += '/webservice.asmx';
}
if (!updateEmployeeExRequest) {
throw new Error('Missing request params');
}
const result = await updateUserEx(username, password, updateEmployeeExRequest, endpoint);
response.send(result);
} catch (error: any) {
logger.error(error);
response.status(500).send({ error: error.message });
}
});
});
export const esslDeleteUser = onRequest({
region: '#{SERVICES_RGN}#'
}, async (request: Request, response: Response) => {
return corsHandler(request, response, async () => {
try {
let username: string | null = request.body.username as string;
let password: string | null = request.body.password as string;
let endpoint: string | null = request.body.endpoint as string;
let gymId: string | null = request.body.gymId as string;
const getEmployeeDetailsRequest = request.body.params as EmployeeCodeRequest;
if (!username) {
throw new Error('Missing username');
}
username = username.trim();
if (!password) {
if (!gymId) {
throw new Error('Missing password or gymId');
}
// TODO: Get password from gym configuration by decrypting with private key
throw new Error('Gym-based password retrieval not implemented yet');
}
password = getDecryptedPassword(password);
if (!getEmployeeDetailsRequest) {
throw new Error('Missing request params');
}
const employeeCode = getEmployeeDetailsRequest.employeeCode;
if (!employeeCode) {
throw new Error('Missing employeeCode');
}
if (!endpoint) {
throw new Error('Missing endpoint');
}
if (!endpoint || endpoint.trim() === '') {
throw new Error('Missing endpoint');
}
try {
new URL(endpoint);
} catch (_) {
throw new Error('Endpoint is not a valid URI or URL');
}
if (!endpoint.endsWith('/webservice.asmx')) {
if (endpoint.endsWith('/')) {
endpoint = endpoint.substring(0, endpoint.length - 1);
}
endpoint += '/webservice.asmx';
}
const result = await deleteEmplyee(username, password, employeeCode, endpoint);
response.send(result);
} catch (error: any) {
logger.error(error);
response.status(500).send({ error: error.message });
}
});
});
export const esslGetEmployeePunchLogs = onRequest({
region: '#{SERVICES_RGN}#'
}, async (request: Request, response: Response) => {
return corsHandler(request, response, async () => {
try {
let username: string | null = request.body.username as string;
let password: string | null = request.body.password as string;
let endpoint: string | null = request.body.endpoint as string;
let gymId: string | null = request.body.gymId as string;
const pushLogRequst = request.body.params as PushLogRequest;
if (!username) {
throw new Error('Missing username');
}
username = username.trim();
if (!password) {
if (!gymId) {
throw new Error('Missing password or gymId');
}
// TODO: Get password from gym configuration by decrypting with private key
throw new Error('Gym-based password retrieval not implemented yet');
}
password = getDecryptedPassword(password);
if (!pushLogRequst) {
throw new Error('Missing request params');
}
const employeeCode = pushLogRequst.employeeCode;
if (!employeeCode) {
throw new Error('Missing employeeCode');
}
const attendanceDate = pushLogRequst.attendanceDate;
if (!attendanceDate) {
throw new Error('Missing attendanceDate');
}
const isValidDate = /^\d{4}-\d{2}-\d{2}$/;
if (!attendanceDate.match(isValidDate)) {
throw new Error('attendanceDate is not in the valid format YYYY-MM-DD');
}
if (!endpoint) {
throw new Error('Missing endpoint');
}
if (!endpoint || endpoint.trim() === '') {
throw new Error('Missing endpoint');
}
try {
new URL(endpoint);
} catch (_) {
throw new Error('Endpoint is not a valid URI or URL');
}
if (!endpoint.endsWith('/webservice.asmx')) {
if (endpoint.endsWith('/')) {
endpoint = endpoint.substring(0, endpoint.length - 1);
}
endpoint += '/webservice.asmx';
}
const result = await getEmployeePunchLogs(username,
password,
employeeCode,
attendanceDate,
endpoint);
response.send(result);
} catch (error: any) {
logger.error(error);
response.status(500).send({ error: error.message });
}
});
});

View File

@ -0,0 +1,4 @@
export {
esslGetUserDetails, esslUpdateUser,
esslDeleteUser, esslGetEmployeePunchLogs
} from './essl';

View File

@ -2,6 +2,7 @@ import { getLogger } from "../shared/config";
import { getCorsHandler } from "../shared/middleware"; import { getCorsHandler } from "../shared/middleware";
import { onRequest } from "firebase-functions/v2/https"; import { onRequest } from "firebase-functions/v2/https";
import { Request } from "firebase-functions/v2/https"; import { Request } from "firebase-functions/v2/https";
import { Response } from "express";
import { SESClient } from "@aws-sdk/client-ses"; import { SESClient } from "@aws-sdk/client-ses";
import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses"; import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses";
import { HttpsError } from "firebase-functions/v2/https"; import { HttpsError } from "firebase-functions/v2/https";
@ -19,8 +20,8 @@ interface EmailRequest {
from: string; from: string;
replyTo?: string; replyTo?: string;
attachments?: Attachment[]; attachments?: Attachment[];
fileUrl?: string; fileUrl?: string;
fileName?: string; fileName?: string;
} }
interface Attachment { interface Attachment {
@ -62,7 +63,7 @@ async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) { async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({ const ses = new SESClient({
region: 'ap-south-1', region: '#{SERVICES_RGN}#',
credentials: { credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '' secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
@ -135,14 +136,14 @@ async function downloadFileFromUrl(url: string): Promise<Buffer> {
} }
export const sendEmailSES = onRequest({ export const sendEmailSES = onRequest({
region: 'asia-south1' region: '#{SERVICES_RGN}#'
}, (request: Request, response) => { }, (request: Request, response: Response) => {
return corsHandler(request, response, async () => { return corsHandler(request, response, async () => {
try { try {
const toAddress = request.body.toAddress; const toAddress = request.body.toAddress;
const subject = request.body.subject; const subject = request.body.subject;
const message = request.body.message; const message = request.body.message;
// Initialize data with basic fields // Initialize data with basic fields
const data: EmailRequest = { const data: EmailRequest = {
to: toAddress, to: toAddress,
@ -153,42 +154,42 @@ export const sendEmailSES = onRequest({
replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com', replyTo: process.env.SES_REPLY_TO_EMAIL || 'support@fitlien.com',
attachments: request.body.attachments as Attachment[] || [] attachments: request.body.attachments as Attachment[] || []
}; };
// Handle file URL if provided // Handle file URL if provided
if (request.body.fileUrl && request.body.fileName) { if (request.body.fileUrl && request.body.fileName) {
logger.info(`Downloading attachment from URL: ${request.body.fileUrl}`); logger.info(`Downloading attachment from URL: ${request.body.fileUrl}`);
try { try {
const fileContent = await downloadFileFromUrl(request.body.fileUrl); const fileContent = await downloadFileFromUrl(request.body.fileUrl);
// If attachments array doesn't exist, create it // If attachments array doesn't exist, create it
if (!data.attachments) { if (!data.attachments) {
data.attachments = []; data.attachments = [];
} }
// Add the downloaded file as an attachment // Add the downloaded file as an attachment
data.attachments.push({ data.attachments.push({
filename: request.body.fileName, filename: request.body.fileName,
content: fileContent, content: fileContent,
contentType: mime.lookup(request.body.fileName) || 'application/octet-stream' contentType: mime.lookup(request.body.fileName) || 'application/octet-stream'
}); });
logger.info(`Successfully downloaded attachment: ${request.body.fileName}`); logger.info(`Successfully downloaded attachment: ${request.body.fileName}`);
} catch (downloadError) { } catch (downloadError) {
logger.error(`Failed to download attachment: ${downloadError}`); logger.error(`Failed to download attachment: ${downloadError}`);
throw new Error(`Failed to process attachment: ${downloadError}`); throw new Error(`Failed to process attachment: ${downloadError}`);
} }
} }
if (!data.to || !data.subject || !data.html || !data.from) { if (!data.to || !data.subject || !data.html || !data.from) {
throw new HttpsError( throw new HttpsError(
'invalid-argument', 'invalid-argument',
'Missing required email fields' 'Missing required email fields'
); );
} }
logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`); logger.info(`Sending Email '${data.subject}' to '${data.to}' from '${data.from}'`);
const recipients = Array.isArray(data.to) ? data.to : [data.to]; const recipients = Array.isArray(data.to) ? data.to : [data.to];
if (data.attachments && data.attachments.length > 0) { if (data.attachments && data.attachments.length > 0) {
const messageResult = await sendEmailWithAttachments(data, recipients); const messageResult = await sendEmailWithAttachments(data, recipients);
response.status(200).json(messageResult); response.status(200).json(messageResult);

View File

@ -16,4 +16,8 @@ export { accessFile } from './storage';
export { processNotificationOnCreate } from './notifications'; export { processNotificationOnCreate } from './notifications';
export * from './payments'; export * from './payments';
export { getPlaceDetails, getPlacesAutocomplete } from './places'; export { getPlaceDetails, getPlacesAutocomplete } from './places';
export { registerClient } from './clientRegistration'; export { registerClient } from './users';
export {
esslGetUserDetails, esslUpdateUser,
esslDeleteUser, esslGetEmployeePunchLogs
} from './dooraccess';

View File

@ -0,0 +1,51 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
export class RSADecryption {
private static privateKeyObject: crypto.KeyObject | null = null;
private static getPrivateKeyObject(): crypto.KeyObject {
if (!this.privateKeyObject) {
const keyPath = path.join(__dirname, '../../assets/keys/fitLien_private.pem');
const keyContent = fs.readFileSync(keyPath, 'utf8');
this.privateKeyObject = crypto.createPrivateKey({
key: keyContent,
format: 'pem'
});
}
return this.privateKeyObject;
}
public static decryptPassword(encryptedPassword: string): string {
try {
if (!encryptedPassword || encryptedPassword.trim() === '') {
throw new Error('Encrypted password cannot be empty');
}
const privateKeyObject = this.getPrivateKeyObject();
const encryptedBuffer = Buffer.from(encryptedPassword, 'base64');
if (encryptedBuffer.length === 0) {
throw new Error('Encrypted password buffer is empty');
}
const decryptedBuffer = crypto.privateDecrypt(
{
key: privateKeyObject,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
oaepHash: 'sha1'
},
encryptedBuffer
);
return decryptedBuffer.toString('utf8');
} catch (error) {
console.error('Decryption error details:', {
message: error instanceof Error ? error.message : 'Unknown error',
encryptedPasswordLength: encryptedPassword?.length || 0
});
throw new Error(`Failed to decrypt password: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
}

View File

@ -1,6 +1,9 @@
import { onRequest } from "firebase-functions/v2/https"; import { onRequest } from "firebase-functions/v2/https";
import { getCorsHandler } from "../shared/middleware"; import { getCorsHandler } from "../shared/middleware";
import { getAdmin, getLogger } from "../shared/config"; import { getAdmin, getLogger } from "../shared/config";
import { Request } from "firebase-functions/v2/https";
import { Response } from "express";
const corsHandler = getCorsHandler(); const corsHandler = getCorsHandler();
const admin = getAdmin(); const admin = getAdmin();
@ -8,7 +11,7 @@ const logger = getLogger();
export const registerClient = onRequest({ export const registerClient = onRequest({
region: '#{SERVICES_RGN}#' region: '#{SERVICES_RGN}#'
}, async (req, res) => { }, async (req: Request, res: Response) => {
return corsHandler(req, res, async () => { return corsHandler(req, res, async () => {
try { try {
if (req.method !== 'POST') { if (req.method !== 'POST') {
@ -33,27 +36,21 @@ export const registerClient = onRequest({
return res.status(403).json({ error: 'Forbidden. Only gym owners can register clients.' }); return res.status(403).json({ error: 'Forbidden. Only gym owners can register clients.' });
} }
const gymUser = req.body; const gymUser = req.body;
if (!gymUser.phoneNumber) { if (!gymUser.fields["phone-number"]) {
return res.status(400).json({ error: 'Phone number is required' }); return res.status(400).json({ error: 'Phone number is required' });
} }
const isdCode = gymUser.isdCode || '91';
const formattedPhoneNumber = gymUser.phoneNumber.startsWith('+')
? gymUser.phoneNumber
: `${isdCode}${gymUser.phoneNumber}`;
let clientUid; let clientUid;
try { try {
const userRecord = await admin.auth().getUserByPhoneNumber(formattedPhoneNumber) const userRecord = await admin.auth().getUserByPhoneNumber(gymUser.fields["phone-number"])
.catch(() => null); .catch(() => null);
if (userRecord) { if (userRecord) {
clientUid = userRecord.uid; clientUid = userRecord.uid;
} else { } else {
const newUser = await admin.auth().createUser({ const newUser = await admin.auth().createUser({
phoneNumber: formattedPhoneNumber, phoneNumber: gymUser.fields["phone-number"],
displayName: gymUser.name || '', displayName: gymUser.fields["first-name"] || '',
email: gymUser.email || null,
}); });
clientUid = newUser.uid; clientUid = newUser.uid;
} }
@ -79,7 +76,6 @@ export const registerClient = onRequest({
const clientData = { const clientData = {
...gymUser, ...gymUser,
phoneNumber: formattedPhoneNumber,
}; };
await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData); await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData);