diff --git a/.gitea/workflows/deploy-dev.yaml b/.gitea/workflows/deploy-dev.yaml
index f0f0a82..cbb62b4 100644
--- a/.gitea/workflows/deploy-dev.yaml
+++ b/.gitea/workflows/deploy-dev.yaml
@@ -25,6 +25,12 @@ jobs:
- name: Copy .env.example to .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
run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
diff --git a/.gitea/workflows/deploy-qa.yaml b/.gitea/workflows/deploy-qa.yaml
index c868dcb..e57c4bc 100644
--- a/.gitea/workflows/deploy-qa.yaml
+++ b/.gitea/workflows/deploy-qa.yaml
@@ -25,6 +25,12 @@ jobs:
- name: Copy .env.example to .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
run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
index dbc0dd5..d01f7eb 100644
--- a/.gitea/workflows/deploy.yaml
+++ b/.gitea/workflows/deploy.yaml
@@ -25,6 +25,12 @@ jobs:
- name: Copy .env.example to .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
run: |
sed -i "s/#{TWILIO_ACCOUNT_SID}#/${{ secrets.TWILIO_ACCOUNT_SID }}/" functions/.env
diff --git a/.gitignore b/.gitignore
index b17f631..b848764 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
+# Private key
+/functions/assets/keys/fitLien_private.pem
+
# Coverage directory used by tools like istanbul
coverage
@@ -67,3 +70,6 @@ node_modules/
# dataconnect generated files
.dataconnect
+
+.DS_Store
+**/.DS_Store
\ No newline at end of file
diff --git a/functions/package-lock.json b/functions/package-lock.json
index 853a3bf..b349a53 100644
--- a/functions/package-lock.json
+++ b/functions/package-lock.json
@@ -25,13 +25,15 @@
"node-fetch": "^2.7.0",
"pdfjs-dist": "^5.0.375",
"pdfmake": "^0.2.20",
- "twilio": "^5.4.0"
+ "twilio": "^5.4.0",
+ "xmldom": "^0.6.0"
},
"devDependencies": {
"@types/long": "^5.0.0",
"@types/mime-types": "^2.1.4",
- "@types/node": "^22.13.14",
+ "@types/node": "^22.15.31",
"@types/pdfmake": "^0.2.11",
+ "@types/xmldom": "^0.1.34",
"firebase-functions-test": "^3.1.0",
"typescript": "^5.8.2"
},
@@ -2829,9 +2831,10 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": {
- "version": "22.15.2",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.2.tgz",
- "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==",
+ "version": "22.15.31",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.31.tgz",
+ "integrity": "sha512-jnVe5ULKl6tijxUhvQeNbQG/84fHfg+yMak02cT8QVhBx/F05rAVxCGBYYTh2EKz22D6JF5ktXuNwdx7b9iEGw==",
+ "license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2946,6 +2949,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"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": {
"version": "17.0.33",
"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",
"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": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/functions/package.json b/functions/package.json
index b29002f..334660d 100644
--- a/functions/package.json
+++ b/functions/package.json
@@ -32,13 +32,15 @@
"node-fetch": "^2.7.0",
"pdfjs-dist": "^5.0.375",
"pdfmake": "^0.2.20",
- "twilio": "^5.4.0"
+ "twilio": "^5.4.0",
+ "xmldom": "^0.6.0"
},
"devDependencies": {
"@types/long": "^5.0.0",
"@types/mime-types": "^2.1.4",
- "@types/node": "^22.13.14",
+ "@types/node": "^22.15.31",
"@types/pdfmake": "^0.2.11",
+ "@types/xmldom": "^0.1.34",
"firebase-functions-test": "^3.1.0",
"typescript": "^5.8.2"
},
diff --git a/functions/src/dooraccess/doorAccessUser.ts b/functions/src/dooraccess/doorAccessUser.ts
new file mode 100644
index 0000000..58fcd2c
--- /dev/null
+++ b/functions/src/dooraccess/doorAccessUser.ts
@@ -0,0 +1,7 @@
+export type DoorAccessUser = {
+ name: string;
+ location: string;
+ role: string;
+ expireFrom: Date | null;
+ expireTo: Date | null;
+};
diff --git a/functions/src/dooraccess/essl.ts b/functions/src/dooraccess/essl.ts
new file mode 100644
index 0000000..ff26ad3
--- /dev/null
+++ b/functions/src/dooraccess/essl.ts
@@ -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, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+};
+
+function createGetEmployeeDetailsRequest(username: string, password: string, employeeCode: string): string | null {
+ const soapRequest = `
+
+
+
+ ${escapeXml(username)}
+ ${escapeXml(password)}
+ ${escapeXml(employeeCode)}
+
+
+`;
+
+ 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 = `
+
+
+
+ ${escapeXml(username)}
+ ${escapeXml(password)}
+ ${escapeXml(request.employeeCode)}
+ ${escapeXml(request.employeeName)}
+ ${escapeXml(request.employeeLocation)}
+ ${escapeXml(request.employeeRole)}
+ Card
+ ${escapeXml(request.employeeExpiryFrom)}
+ ${escapeXml(request.employeeExpiryTo)}
+ ${escapeXml(request.employeeCardNumber)}
+
+
+
+
+
+`;
+ 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 = `
+
+
+
+ ${escapeXml(username)}
+ ${escapeXml(password)}
+ ${escapeXml(employeeCode)}
+
+
+`;
+ 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 = `
+
+
+
+ cosqclient
+ 3bbb58d5
+ 1
+ 2025-05-24
+
+
+`;
+ 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 {
+ 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 {
+ 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 });
+ }
+ });
+});
\ No newline at end of file
diff --git a/functions/src/dooraccess/index.ts b/functions/src/dooraccess/index.ts
new file mode 100644
index 0000000..ad7da6e
--- /dev/null
+++ b/functions/src/dooraccess/index.ts
@@ -0,0 +1,4 @@
+export {
+ esslGetUserDetails, esslUpdateUser,
+ esslDeleteUser, esslGetEmployeePunchLogs
+} from './essl';
\ No newline at end of file
diff --git a/functions/src/email/sendEmailSES.ts b/functions/src/email/sendEmailSES.ts
index 78e6875..5efc40a 100644
--- a/functions/src/email/sendEmailSES.ts
+++ b/functions/src/email/sendEmailSES.ts
@@ -2,6 +2,7 @@ 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";
@@ -19,8 +20,8 @@ interface EmailRequest {
from: string;
replyTo?: string;
attachments?: Attachment[];
- fileUrl?: string;
- fileName?: string;
+ fileUrl?: string;
+ fileName?: string;
}
interface Attachment {
@@ -62,7 +63,7 @@ async function sendSimpleEmail(data: EmailRequest, recipients: string[]) {
async function sendEmailWithAttachments(data: EmailRequest, recipients: string[]) {
const ses = new SESClient({
- region: 'ap-south-1',
+ region: '#{SERVICES_RGN}#',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || ''
@@ -135,14 +136,14 @@ async function downloadFileFromUrl(url: string): Promise {
}
export const sendEmailSES = onRequest({
- region: 'asia-south1'
-}, (request: Request, response) => {
+ 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,
@@ -153,42 +154,42 @@ export const sendEmailSES = onRequest({
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);
diff --git a/functions/src/index.ts b/functions/src/index.ts
index 432a73c..1156209 100644
--- a/functions/src/index.ts
+++ b/functions/src/index.ts
@@ -16,4 +16,8 @@ export { accessFile } from './storage';
export { processNotificationOnCreate } from './notifications';
export * from './payments';
export { getPlaceDetails, getPlacesAutocomplete } from './places';
-export { registerClient } from './clientRegistration';
+export { registerClient } from './users';
+export {
+ esslGetUserDetails, esslUpdateUser,
+ esslDeleteUser, esslGetEmployeePunchLogs
+} from './dooraccess';
diff --git a/functions/src/shared/decrypt.ts b/functions/src/shared/decrypt.ts
new file mode 100644
index 0000000..63e9b9f
--- /dev/null
+++ b/functions/src/shared/decrypt.ts
@@ -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'}`);
+ }
+ }
+}
diff --git a/functions/src/clientRegistration/clientRegistration.ts b/functions/src/users/clientRegistration.ts
similarity index 88%
rename from functions/src/clientRegistration/clientRegistration.ts
rename to functions/src/users/clientRegistration.ts
index fe2fafc..f39b827 100644
--- a/functions/src/clientRegistration/clientRegistration.ts
+++ b/functions/src/users/clientRegistration.ts
@@ -1,6 +1,9 @@
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();
@@ -8,7 +11,7 @@ const logger = getLogger();
export const registerClient = onRequest({
region: '#{SERVICES_RGN}#'
-}, async (req, res) => {
+}, async (req: Request, res: Response) => {
return corsHandler(req, res, async () => {
try {
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.' });
}
const gymUser = req.body;
- if (!gymUser.phoneNumber) {
+ if (!gymUser.fields["phone-number"]) {
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;
try {
- const userRecord = await admin.auth().getUserByPhoneNumber(formattedPhoneNumber)
+ 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: formattedPhoneNumber,
- displayName: gymUser.name || '',
- email: gymUser.email || null,
+ phoneNumber: gymUser.fields["phone-number"],
+ displayName: gymUser.fields["first-name"] || '',
});
clientUid = newUser.uid;
}
@@ -79,7 +76,6 @@ export const registerClient = onRequest({
const clientData = {
...gymUser,
- phoneNumber: formattedPhoneNumber,
};
await admin.firestore().collection('client_profiles').doc(clientUid).set(clientData);
diff --git a/functions/src/clientRegistration/index.ts b/functions/src/users/index.ts
similarity index 100%
rename from functions/src/clientRegistration/index.ts
rename to functions/src/users/index.ts