Compare commits
266 Commits
faeture/fi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 61f0d29f37 | |||
| 209c7c65b0 | |||
| ad31bc8e80 | |||
| f0d167a671 | |||
| e762d0fb79 | |||
| 5ca05c6490 | |||
| 7ec65e4ab1 | |||
| b41431b151 | |||
| 218ff1d02e | |||
| 4f71ca273b | |||
| 4d52d1c7f8 | |||
| aee40521d3 | |||
| a450e93e2b | |||
| 36015d2b83 | |||
| 51fa0825ca | |||
| 3e455fc83a | |||
| 0672a19a60 | |||
| 172fa2edae | |||
| d0c00d8172 | |||
| f93931867c | |||
| 70d76bab2e | |||
| 308cb0fab6 | |||
| 165cd74a17 | |||
| b190a371b6 | |||
| 5543ba5e7a | |||
| 5d6824a6f4 | |||
| abf7a04633 | |||
| 835f478665 | |||
| 5f43a86036 | |||
| 1e09f7a676 | |||
| 5e48f695f8 | |||
| e483b7ad46 | |||
| cd59b9890d | |||
| 939567f7c0 | |||
| 7c494154ba | |||
| 8f5956a825 | |||
| 5ff7b8bb84 | |||
| 7259e67833 | |||
| 81c5241e95 | |||
| 209354ec6b | |||
| 39ea6dcafb | |||
| f6b1545cf6 | |||
| 195262a6de | |||
| d8ae223cce | |||
| c2914b16bb | |||
| b66f1603cc | |||
| d3c9e86c7c | |||
| 4cf5692386 | |||
| 8b308fb9a6 | |||
| 237dd8a263 | |||
| fb23661080 | |||
| cba945c282 | |||
| ef166a209c | |||
| 5d47a78baa | |||
| b594579158 | |||
| 943cff74d5 | |||
| 1fc089a7cb | |||
| 76a75330c8 | |||
| ca08d83f98 | |||
| 0a32e15d05 | |||
| 3223efc392 | |||
| 9c2431fb7b | |||
| 7492cdedc1 | |||
| d8bf928da8 | |||
| 9d51393aa5 | |||
| cc5b6d6987 | |||
| cbe59ee4f1 | |||
| a3241afd45 | |||
| d49be73784 | |||
| fe328e2e64 | |||
| c80fb0c4b4 | |||
| ef2cd80226 | |||
| f19e3b012d | |||
| 9cbdcf3c37 | |||
| f518d4bc9d | |||
| 56f3f1e0bd | |||
| f2e37e88ed | |||
| a0134466ee | |||
| b9346de5e5 | |||
| 03f6941531 | |||
| e27e189f7e | |||
| 3e1a51c093 | |||
| 77d642eac1 | |||
| 5e680c3947 | |||
| cbe98e8cd0 | |||
| 72b6bb3cd6 | |||
| bb3e966daf | |||
| 09068fe731 | |||
| 47bd8610d2 | |||
| e517367967 | |||
| ee27b1b3cc | |||
| ecbe9d184b | |||
| 3f3b42e2ec | |||
| 866bdc1b77 | |||
| 0ccb7dc16f | |||
| 22beb38465 | |||
| 7a796243b0 | |||
| c43c9cf26c | |||
| 3f4b307431 | |||
| ee1619ae38 | |||
| 7482b20526 | |||
| d9cd772f6e | |||
| 7db9e479ad | |||
| 59c4f88ff0 | |||
| 3f3bf53e94 | |||
| be7470f1e4 | |||
| a89168bb74 | |||
| e12cbf4148 | |||
| fbacda6806 | |||
| 33b01e5e5b | |||
| a146315536 | |||
| fab7e618d4 | |||
| e2deb441e9 | |||
| 916293e8b1 | |||
| d3cc6b3710 | |||
| 4b054fac56 | |||
| e53310b4cf | |||
| e382ca3789 | |||
| fe7839b899 | |||
| 271858a4d3 | |||
| 038d85708f | |||
| ba54655b8c | |||
| 769311bb1b | |||
|
|
a4c3cbbba2 | ||
|
|
c6f8b7fc35 | ||
| 92320f166a | |||
|
|
9c002a4d61 | ||
| b372d81ff0 | |||
|
|
6b1d86afc2 | ||
|
|
06c5f018c3 | ||
| 815e15b5ae | |||
|
|
9d17bd39ce | ||
|
|
762e6b77e5 | ||
| 8bb10cba8c | |||
|
|
80b3829347 | ||
|
|
d8dfd8a6f2 | ||
| ae19a611b3 | |||
|
|
756b6fed83 | ||
|
|
3ef81f8273 | ||
|
|
b11cefbab4 | ||
| 91e2af5890 | |||
|
|
2d6b14663b | ||
| d335fb38e9 | |||
|
|
4fe2ff7abf | ||
|
|
2d55e1f461 | ||
| b75cb415e7 | |||
|
|
f62dfdfad2 | ||
|
|
e8710074c4 | ||
| aee28a6050 | |||
|
|
6d64f1e4d7 | ||
|
|
38d5092ee3 | ||
| 5f89a5cda4 | |||
| 1e040212e4 | |||
| ccd0ee61bc | |||
| 9b42fdcac1 | |||
| 4b745d587f | |||
| 3adf2a3aac | |||
| 868ae3dea4 | |||
| b8b518be9c | |||
| 0f86c468d2 | |||
| bef6b4085b | |||
|
|
c377b7243d | ||
| 9efa31b6cc | |||
|
|
54006c44cf | ||
|
|
c28597f3ee | ||
|
|
22fb060cb9 | ||
| 72ad257e07 | |||
| 6e15bd3034 | |||
| ade7cfe30a | |||
| 71d9c2e670 | |||
| da1430ebc3 | |||
| dcee3e6af9 | |||
| 853dd0e1e0 | |||
| 4e1724b1ec | |||
| e7d6fd54e8 | |||
| 2d3c967037 | |||
| b40201cf93 | |||
| 3d8e1c8db0 | |||
| b6c72adfb8 | |||
| 59a9932ca8 | |||
| bb1a33daa4 | |||
| c11f0447a6 | |||
| e41ecc0852 | |||
| bb0420ee0a | |||
| 47851da81b | |||
| 0fba4756c7 | |||
| 1f476a3071 | |||
| accf136896 | |||
| ba77cccd76 | |||
| d39b53357b | |||
| d241b09fd8 | |||
| cf6f4625ad | |||
| 6434f6e3fa | |||
| 59bc1fde6b | |||
| 370573de1a | |||
| f8b3930fcc | |||
| 2147963523 | |||
| 7642268606 | |||
| 103684dc4f | |||
| 18569d38d3 | |||
| 6351cb87ef | |||
| 0e60f34500 | |||
| 5bc3d6dfff | |||
| 66ba3b88ab | |||
| e8cec7e0f4 | |||
| e2d87fa095 | |||
| 1b5eb33859 | |||
| 07bb778244 | |||
| 9bd4a06dd9 | |||
| e8ca80df48 | |||
| f156c1936c | |||
| 0a8c2295bb | |||
| 26ae8aeaf2 | |||
| e5c636f8c5 | |||
| 0b699b203c | |||
| f642b744b4 | |||
| 564677d6c2 | |||
| 77efd40c86 | |||
| 90a196494a | |||
| 7887e87200 | |||
| f5770faf9f | |||
| a555223dbd | |||
| 9f259de4f6 | |||
| dc91c123bb | |||
| fcd5f5db47 | |||
| f6ab4fa510 | |||
| 8e37d87268 | |||
| 01c87ffcfa | |||
| 5a09e5a82f | |||
| 3c6e8ec583 | |||
| 0ecef0c051 | |||
| 7a346f62a8 | |||
| 094476072e | |||
| a9e4b31eb9 | |||
| 08f1243729 | |||
| 8cad670373 | |||
| a245ec7d7b | |||
| 50d23a21d2 | |||
| 0055896229 | |||
| ada17a85d9 | |||
| 4d77104c59 | |||
| dc78edbbf3 | |||
| 912706aee7 | |||
| b68bda68c2 | |||
| 0be5cfe30c | |||
| 7d37e295fe | |||
| a333b9520d | |||
| 1991da23e6 | |||
| 439353c593 | |||
| f6facf40f8 | |||
| 688619bd45 | |||
| 8d20162922 | |||
| 960fbb1e92 | |||
| d4090faa82 | |||
| 0f11768b65 | |||
| 7be90b6284 | |||
| fd7e7f528b | |||
| c93a4f4718 | |||
| 0eb5ca1baf | |||
| 27feed4711 | |||
| b4a75f4968 | |||
| f00ff4d1d6 | |||
| e553bd673a | |||
| f221f4850e | |||
| f9930b143c | |||
| 97a13bde56 |
@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"projects": {
|
"projects": {
|
||||||
"debug": "fitlien-dev",
|
"debug": "fitlien-dev",
|
||||||
"release": "fitlien"
|
"qa": "fitlien-qa",
|
||||||
|
"release": "fitlien",
|
||||||
|
"default": "fitlien-dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
.gitea/workflows/deploy-dev.yaml
Normal file
67
.gitea/workflows/deploy-dev.yaml
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy FitLien services to Dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Dev
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- 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
|
||||||
|
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||||
|
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||||
|
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||||
|
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||||
|
|
||||||
|
cat functions/.env
|
||||||
|
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||||
|
run: |
|
||||||
|
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm install -g typescript
|
||||||
|
cd functions
|
||||||
|
npm install
|
||||||
|
npx tsc
|
||||||
|
cd ..
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
curl -sL firebase.tools | upgrade=true bash
|
||||||
|
firebase use --token ${{ secrets.FIREBASE_TOKEN }} debug
|
||||||
|
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||||
67
.gitea/workflows/deploy-qa.yaml
Normal file
67
.gitea/workflows/deploy-qa.yaml
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy FitLien services to QA
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- qa
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to QA
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- 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
|
||||||
|
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||||
|
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||||
|
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||||
|
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||||
|
|
||||||
|
cat functions/.env
|
||||||
|
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||||
|
run: |
|
||||||
|
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm install -g typescript
|
||||||
|
cd functions
|
||||||
|
npm install
|
||||||
|
npx tsc
|
||||||
|
cd ..
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
curl -sL firebase.tools | upgrade=true bash
|
||||||
|
firebase use --token ${{ secrets.FIREBASE_TOKEN }} qa
|
||||||
|
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||||
67
.gitea/workflows/deploy.yaml
Normal file
67
.gitea/workflows/deploy.yaml
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
name: Deploy FitLien services
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- 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
|
||||||
|
sed -i "s/#{TWILIO_AUTH_TOKEN}#/${{ secrets.TWILIO_AUTH_TOKEN }}/" functions/.env
|
||||||
|
sed -i "s/#{TWILIO_PHONE_NUMBER}#/${{ secrets.TWILIO_PHONE_NUMBER }}/" functions/.env
|
||||||
|
sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/" functions/.env
|
||||||
|
sed -i "s/#{GOOGLE_MAPS_API_KEY}#/${{ secrets.GOOGLE_MAPS_API_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_FROM_EMAIL}#/${{ vars.SES_FROM_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{SES_REPLY_TO_EMAIL}#/${{ vars.SES_REPLY_TO_EMAIL }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_ACCESS_KEY_ID}#/${{ secrets.AWS_ACCESS_KEY_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_SECRET_ACCESS_KEY}#/${{ secrets.AWS_SECRET_ACCESS_KEY }}/" functions/.env
|
||||||
|
sed -i "s/#{AWS_REGION}#/${{ secrets.AWS_REGION }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_ID}#/${{ secrets.PHONEPE_CLIENT_ID }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_CLIENT_SECRET}#/${{ secrets.PHONEPE_CLIENT_SECRET }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_API_URL}#/${{ secrets.PHONEPE_API_URL }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_USERNAME}#/${{ secrets.PHONEPE_WEBHOOK_USERNAME }}/" functions/.env
|
||||||
|
sed -i "s/#{PHONEPE_WEBHOOK_PASSWORD}#/${{ secrets.PHONEPE_WEBHOOK_PASSWORD }}/" functions/.env
|
||||||
|
|
||||||
|
cat functions/.env
|
||||||
|
- name: "Replace #{SERVICES_RGN}# in all .ts files"
|
||||||
|
run: |
|
||||||
|
find . -type f -name "*.ts" -exec sed -i "s/#{SERVICES_RGN}#/${{ vars.SERVICES_RGN }}/g" {} +
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
npm install -g typescript
|
||||||
|
cd functions
|
||||||
|
npm install
|
||||||
|
npx tsc
|
||||||
|
cd ..
|
||||||
|
ls -la
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
curl -sL firebase.tools | upgrade=true bash
|
||||||
|
firebase use --token ${{ secrets.FIREBASE_TOKEN }} release
|
||||||
|
firebase deploy --token "${{ secrets.FIREBASE_TOKEN }}" --force --non-interactive
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -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
|
||||||
@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json",
|
||||||
|
"database": "(default)"
|
||||||
},
|
},
|
||||||
"functions": [
|
"functions": [
|
||||||
{
|
{
|
||||||
"source": "functions",
|
"source": "functions",
|
||||||
"codebase": "default",
|
"codebase": "default",
|
||||||
|
"timeoutSeconds": 540,
|
||||||
|
"memory": "1GiB",
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
".git",
|
".git",
|
||||||
@ -14,14 +17,31 @@
|
|||||||
"firebase-debug.*.log",
|
"firebase-debug.*.log",
|
||||||
"*.local"
|
"*.local"
|
||||||
],
|
],
|
||||||
"predeploy": [
|
"predeploy": []
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storage": {
|
"storage": {
|
||||||
"rules": "storage.rules"
|
"rules": "storage.rules"
|
||||||
},
|
},
|
||||||
|
"emulators": {
|
||||||
|
"functions": {
|
||||||
|
"port": 5005
|
||||||
|
},
|
||||||
|
"firestore": {
|
||||||
|
"port": 8086
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"port": 9199
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 4008
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"port": 9099
|
||||||
|
}
|
||||||
|
},
|
||||||
"remoteconfig": {
|
"remoteconfig": {
|
||||||
"template": "remoteconfig.template.json"
|
"template": "remoteconfig.template.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,33 @@
|
|||||||
{
|
{
|
||||||
"indexes": [
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "day_pass_bookings",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "day_pass_entries",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "bookingId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "entryDate",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"collectionGroup": "gyms",
|
"collectionGroup": "gyms",
|
||||||
"queryScope": "COLLECTION_GROUP",
|
"queryScope": "COLLECTION_GROUP",
|
||||||
@ -13,7 +41,155 @@
|
|||||||
"order": "ASCENDING"
|
"order": "ASCENDING"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "gyms",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "gyms",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "gyms",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "isApproved",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "memberships",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "gymId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "notifications",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "notifications",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "recipientId",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "workout_logs",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "user_id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"order": "DESCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "workout_logs",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "user_id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "workout_logs",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "user_id",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "start_time",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "date",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "terms_and_conditions",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "normalizedName",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "userUid",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fieldOverrides": []
|
"fieldOverrides": []
|
||||||
}
|
}
|
||||||
@ -1,19 +1,16 @@
|
|||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
|
|
||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
// This rule allows anyone with your Firestore database reference to view, edit,
|
|
||||||
// and delete all data in your Firestore database. It is useful for getting
|
|
||||||
// started, but it is configured to expire after 30 days because it
|
|
||||||
// leaves your app open to attackers. At that time, all client
|
|
||||||
// requests to your Firestore database will be denied.
|
|
||||||
//
|
|
||||||
// Make sure to write security rules for your app before that time, or else
|
|
||||||
// all client requests to your Firestore database will be denied until you Update
|
|
||||||
// your rules
|
|
||||||
match /{document=**} {
|
match /{document=**} {
|
||||||
allow read, write: if request.time < timestamp.date(2025, 1, 10);
|
allow read, write: if request.auth != null;
|
||||||
|
}
|
||||||
|
match /day_pass_bookings/{bookingId} {
|
||||||
|
allow read: if true;
|
||||||
|
allow write: if request.auth != null;
|
||||||
|
}
|
||||||
|
match /gyms/{gymId} {
|
||||||
|
allow read: if true;
|
||||||
|
allow write: if request.auth != null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
30
fitlien-services-qa-pipeline.yaml
Normal file
30
fitlien-services-qa-pipeline.yaml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
trigger:
|
||||||
|
- qa
|
||||||
|
|
||||||
|
pool:
|
||||||
|
vmImage: "ubuntu-latest"
|
||||||
|
|
||||||
|
variables:
|
||||||
|
major: $(VERSION_MAJOR)
|
||||||
|
minor: $(VERSION_MINOR)
|
||||||
|
prefix: $[format('{0}.{1}', variables['major'], variables['minor'])]
|
||||||
|
patch: $[counter(variables['prefix'], 100)]
|
||||||
|
buildNumber: $(major).$(minor).$(patch)
|
||||||
|
|
||||||
|
resources:
|
||||||
|
repositories:
|
||||||
|
- repository: templateRepo
|
||||||
|
endpoint: cosq-network
|
||||||
|
type: github
|
||||||
|
name: cosq-network/azure-build-pipeline-templates
|
||||||
|
|
||||||
|
extends:
|
||||||
|
template: firebase-functions-deploy.yaml@templateRepo
|
||||||
|
parameters:
|
||||||
|
nodeVersion: "20"
|
||||||
|
firebaseTokenSecret: $(FIREBASE_TOKEN)
|
||||||
|
functionsWorkingDirectory: "$(Build.SourcesDirectory)/functions"
|
||||||
|
envExamplePath: "$(Build.SourcesDirectory)/functions/.env.example"
|
||||||
|
envPath: "$(Build.SourcesDirectory)/functions/.env"
|
||||||
|
buildNumber: $(buildNumber)
|
||||||
|
buildType: qa
|
||||||
@ -1,7 +1,21 @@
|
|||||||
MAILGUN_API_KEY=#{MAILGUN_API_KEY}#
|
|
||||||
MAILGUN_SERVER=#{MAILGUN_SERVER}#
|
|
||||||
MAILGUN_FROM_ADDRESS=#{MAILGUN_FROM_ADDRESS}#
|
|
||||||
TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}#
|
TWILIO_ACCOUNT_SID=#{TWILIO_ACCOUNT_SID}#
|
||||||
TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}#
|
TWILIO_AUTH_TOKEN=#{TWILIO_AUTH_TOKEN}#
|
||||||
TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}#
|
TWILIO_PHONE_NUMBER=#{TWILIO_PHONE_NUMBER}#
|
||||||
SERVICES_RGN=#{SERVICES_RGN}#
|
SERVICES_RGN=#{SERVICES_RGN}#
|
||||||
|
CASHFREE_CLIENT_ID=#{CASHFREE_CLIENT_ID}#
|
||||||
|
CASHFREE_CLIENT_SECRET=#{CASHFREE_CLIENT_SECRET}#
|
||||||
|
GOOGLE_MAPS_API_KEY=#{GOOGLE_MAPS_API_KEY}#
|
||||||
|
CASHFREE_URL=#{CASHFREE_URL}#
|
||||||
|
CASHFREE_LINK_URL=#{CASHFREE_LINK_URL}#
|
||||||
|
CASHFREE_LINK_NOTIFY_URL=#{CASHFREE_LINK_NOTIFY_URL}#
|
||||||
|
PHONEPE_CLIENT_ID=#{PHONEPE_CLIENT_ID}#
|
||||||
|
PHONEPE_CLIENT_SECRET=#{PHONEPE_CLIENT_SECRET}#
|
||||||
|
PHONEPE_API_URL=#{PHONEPE_API_URL}#
|
||||||
|
PHONEPE_WEBHOOK_USERNAME=#{PHONEPE_WEBHOOK_USERNAME}#
|
||||||
|
PHONEPE_WEBHOOK_PASSWORD=#{PHONEPE_WEBHOOK_PASSWORD}#
|
||||||
|
|
||||||
|
SES_FROM_EMAIL=#{SES_FROM_EMAIL}#
|
||||||
|
SES_REPLY_TO_EMAIL=#{SES_REPLY_TO_EMAIL}#
|
||||||
|
AWS_ACCESS_KEY_ID=#{AWS_ACCESS_KEY_ID}#
|
||||||
|
AWS_SECRET_ACCESS_KEY=#{AWS_SECRET_ACCESS_KEY}#
|
||||||
|
AWS_REGION=#{AWS_REGION}#
|
||||||
|
|||||||
4865
functions/package-lock.json
generated
4865
functions/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,21 +15,32 @@
|
|||||||
},
|
},
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-ses": "^3.798.0",
|
||||||
"@types/node-fetch": "^2.6.12",
|
"@types/node-fetch": "^2.6.12",
|
||||||
"axios": "^1.8.4",
|
"aws-sdk": "^2.1692.0",
|
||||||
|
"axios": "^1.9.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
"firebase-functions": "^6.0.1",
|
"firebase-functions": "^6.4.0",
|
||||||
"form-data": "^4.0.1",
|
"form-data": "^4.0.1",
|
||||||
|
"functions": "file:",
|
||||||
"html-to-text": "^9.0.5",
|
"html-to-text": "^9.0.5",
|
||||||
"long": "^4.0.0",
|
"jspdf": "^3.0.1",
|
||||||
"mailgun.js": "^10.4.0",
|
"jspdf-autotable": "^5.0.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"pdfjs-dist": "^5.0.375",
|
"pdfjs-dist": "^5.0.375",
|
||||||
"twilio": "^5.4.0"
|
"pdfmake": "^0.2.20",
|
||||||
|
"twilio": "^5.4.0",
|
||||||
|
"xmldom": "^0.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/long": "^5.0.0",
|
"@types/long": "^5.0.0",
|
||||||
"@types/node": "^22.13.14",
|
"@types/mime-types": "^2.1.4",
|
||||||
|
"@types/node": "^22.15.31",
|
||||||
|
"@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"
|
||||||
},
|
},
|
||||||
|
|||||||
7
functions/src/dooraccess/doorAccessUser.ts
Normal file
7
functions/src/dooraccess/doorAccessUser.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export type DoorAccessUser = {
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
role: string;
|
||||||
|
expireFrom: Date | null;
|
||||||
|
expireTo: Date | null;
|
||||||
|
};
|
||||||
571
functions/src/dooraccess/essl.ts
Normal file
571
functions/src/dooraccess/essl.ts
Normal file
@ -0,0 +1,571 @@
|
|||||||
|
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, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
};
|
||||||
|
|
||||||
|
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>${escapeXml(username)}</UserName>
|
||||||
|
<Password>${escapeXml(password)}</Password>
|
||||||
|
<EmployeeCode>${escapeXml(employeeCode)}</EmployeeCode>
|
||||||
|
<AttendanceDate>${escapeXml(attendanceDate)}</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;
|
||||||
|
if (!resultText || resultText.trim() === '' || resultText.trim() === ';;' || resultText.trim() === ';') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const punchLogs: Date[] = [];
|
||||||
|
const parts = resultText.split(';');
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part || part.trim() === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
if (!timePart || timePart.trim() === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const logDateTime = createDateFromTime(rootDate, timePart.trim());
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
4
functions/src/dooraccess/index.ts
Normal file
4
functions/src/dooraccess/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export {
|
||||||
|
esslGetUserDetails, esslUpdateUser,
|
||||||
|
esslDeleteUser, esslGetEmployeePunchLogs
|
||||||
|
} from './essl';
|
||||||
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,208 +1,23 @@
|
|||||||
import { onRequest } from "firebase-functions/v2/https";
|
import { setGlobalOptions } from "firebase-functions/v2";
|
||||||
import { Request } from "firebase-functions/v2/https";
|
|
||||||
import * as admin from 'firebase-admin';
|
|
||||||
import * as express from "express";
|
|
||||||
import * as logger from "firebase-functions/logger";
|
|
||||||
import { onDocumentCreated } from "firebase-functions/firestore";
|
|
||||||
import * as os from 'os';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as https from 'https';
|
|
||||||
const formData = require('form-data');
|
|
||||||
const Mailgun = require('mailgun.js');
|
|
||||||
const { convert } = require('html-to-text');
|
|
||||||
const twilio = require('twilio');
|
|
||||||
|
|
||||||
|
|
||||||
if (!admin.apps.length) {
|
|
||||||
admin.initializeApp();
|
|
||||||
}
|
|
||||||
export const sendEmailWithAttachment = onRequest({
|
|
||||||
region: '#{SERVICES_RGN}#'
|
|
||||||
}, async (request: Request, response: express.Response) => {
|
|
||||||
try {
|
|
||||||
const { toAddress, subject, message, fileUrl, fileName } = request.body;
|
|
||||||
|
|
||||||
if (!toAddress || !subject || !message || !fileUrl) {
|
|
||||||
response.status(400).json({
|
|
||||||
error: 'Missing required fields (toAddress, subject, message, fileUrl)'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tempFilePath = path.join(os.tmpdir(), fileName || 'attachment.pdf');
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const file = fs.createWriteStream(tempFilePath);
|
|
||||||
https.get(fileUrl, (res) => {
|
|
||||||
res.pipe(file);
|
|
||||||
file.on('finish', () => {
|
|
||||||
file.close();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
}).on('error', (err) => {
|
|
||||||
fs.unlink(tempFilePath, () => { });
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const mailgun = new Mailgun(formData);
|
|
||||||
const client = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY });
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
wordwrap: 130,
|
|
||||||
};
|
|
||||||
const textMessage = convert(message, options);
|
|
||||||
const fileBuffer = fs.readFileSync(tempFilePath);
|
|
||||||
const attachmentFilename = fileName || path.basename(fileUrl.split('?')[0]);
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
from: process.env.MAILGUN_FROM_ADDRESS,
|
|
||||||
to: toAddress,
|
|
||||||
subject: subject,
|
|
||||||
text: textMessage,
|
|
||||||
html: message,
|
|
||||||
attachment: {
|
|
||||||
data: fileBuffer,
|
|
||||||
filename: attachmentFilename,
|
|
||||||
contentType: 'application/pdf',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await client.messages.create(process.env.MAILGUN_SERVER, data);
|
|
||||||
fs.unlinkSync(tempFilePath);
|
|
||||||
|
|
||||||
logger.info('Email with attachment from URL sent successfully');
|
|
||||||
response.json({ success: true, result });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error sending email with attachment from URL:', error);
|
|
||||||
response.status(500).json({ success: false, error: error instanceof Error ? error.message : String(error) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const sendEmailMessage = onRequest({
|
|
||||||
region: '#{SERVICES_RGN}#'
|
|
||||||
}, (request: Request, response: express.Response) => {
|
|
||||||
const mailgun = new Mailgun(formData);
|
|
||||||
const mailGunClient = mailgun.client({ username: 'api', key: process.env.MAILGUN_API_KEY });
|
|
||||||
|
|
||||||
const toAddress = request.body.toAddress;
|
|
||||||
const subject = request.body.subject;
|
|
||||||
const message = request.body.message;
|
|
||||||
const options = {
|
|
||||||
wordwrap: 130,
|
|
||||||
};
|
|
||||||
|
|
||||||
const textMessage = convert(message, options);
|
|
||||||
mailGunClient.messages.create(process.env.MAILGUN_SERVER, {
|
|
||||||
from: process.env.MAILGUN_FROM_ADDRESS,
|
|
||||||
to: toAddress,
|
|
||||||
subject: subject,
|
|
||||||
text: textMessage,
|
|
||||||
html: message
|
|
||||||
}).then((res: any) => {
|
|
||||||
logger.info(res);
|
|
||||||
response.send(res);
|
|
||||||
}).catch((err: any) => {
|
|
||||||
logger.error(err);
|
|
||||||
response.send(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export const sendSMSMessage = onRequest({
|
|
||||||
region: '#{SERVICES_RGN}#'
|
|
||||||
}, (request: Request, response: express.Response) => {
|
|
||||||
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
|
|
||||||
const { to, body } = request.body;
|
|
||||||
client.messages
|
|
||||||
.create({
|
|
||||||
body: body,
|
|
||||||
from: process.env.TWILIO_PHONE_NUMBER,
|
|
||||||
to: to
|
|
||||||
})
|
|
||||||
.then((message: any) => {
|
|
||||||
logger.info('SMS sent successfully:', message.sid);
|
|
||||||
response.json({ success: true, messageId: message.sid });
|
|
||||||
})
|
|
||||||
.catch((error: any) => {
|
|
||||||
logger.error('Error sending SMS:', error);
|
|
||||||
response.status(500).json({ success: false, error: error.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Invitation {
|
|
||||||
email: string;
|
|
||||||
phoneNumber: string;
|
|
||||||
gymName: string;
|
|
||||||
invitedByName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const notifyInvitation = onDocumentCreated({
|
|
||||||
document: 'notifications/{notificationId}',
|
|
||||||
region: '#{SERVICES_RGN}#'
|
|
||||||
}, async (event: any) => {
|
|
||||||
const invitation = event.data?.data() as Invitation;
|
|
||||||
const invitationId = event.params.invitationId;
|
|
||||||
|
|
||||||
if (!invitation) {
|
|
||||||
console.error('Invitation data is missing.');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userQuery = await admin
|
|
||||||
.firestore()
|
|
||||||
.collection('users')
|
|
||||||
.where('email', '==', invitation.email)
|
|
||||||
.where('phoneNumber', '==', invitation.phoneNumber)
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (userQuery.empty) {
|
|
||||||
console.log(
|
|
||||||
`User not found for email: ${invitation.email} and phone: ${invitation.phoneNumber}.`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = userQuery.docs[0].data();
|
|
||||||
const fcmToken = user.fcmToken;
|
|
||||||
|
|
||||||
if (!fcmToken) {
|
|
||||||
console.log(`FCM token not found for user: ${invitation.email}.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message: admin.messaging.Message = {
|
|
||||||
notification: {
|
|
||||||
title: 'New Gym Invitation',
|
|
||||||
body: `${invitation.invitedByName} has invited you to join ${invitation.gymName}`,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: 'invitation',
|
|
||||||
invitationId: invitationId,
|
|
||||||
gymName: invitation.gymName,
|
|
||||||
senderName: invitation.invitedByName,
|
|
||||||
},
|
|
||||||
android: {
|
|
||||||
priority: 'high',
|
|
||||||
notification: {
|
|
||||||
channelId: 'invitations_channel',
|
|
||||||
priority: 'high',
|
|
||||||
defaultSound: true,
|
|
||||||
defaultVibrateTimings: true,
|
|
||||||
icon: '@mipmap/ic_launcher',
|
|
||||||
clickAction: 'FLUTTER_NOTIFICATION_CLICK',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
token: fcmToken,
|
|
||||||
};
|
|
||||||
|
|
||||||
await admin.messaging().send(message);
|
|
||||||
console.log(`Invitation notification sent to ${invitation.email}.`);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error sending invitation notification:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
setGlobalOptions({
|
||||||
|
region: "#{SERVICES_RGN}#",
|
||||||
|
memory: "1GiB",
|
||||||
|
timeoutSeconds: 540,
|
||||||
|
minInstances: 0,
|
||||||
|
maxInstances: 10,
|
||||||
|
concurrency: 80
|
||||||
|
});
|
||||||
|
|
||||||
|
export * from './shared/config';
|
||||||
|
export { sendEmailSES } from './email';
|
||||||
|
export { sendSMSMessage } from './sms';
|
||||||
|
export { accessFile } from './storage';
|
||||||
|
export { processNotificationOnCreate,checkExpiredMemberships } from './notifications';
|
||||||
|
export * from './payments';
|
||||||
|
export { getPlaceDetails, getPlacesAutocomplete } from './places';
|
||||||
|
export { registerClient } from './users';
|
||||||
|
export {
|
||||||
|
esslGetUserDetails, esslUpdateUser,
|
||||||
|
esslDeleteUser, esslGetEmployeePunchLogs
|
||||||
|
} from './dooraccess';
|
||||||
|
|||||||
3
functions/src/notifications/index.ts
Normal file
3
functions/src/notifications/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { processNotificationOnCreate } from './processNotification';
|
||||||
|
export { checkExpiredMemberships } from "./membershipStatusNotifications";
|
||||||
|
|
||||||
1070
functions/src/notifications/membershipStatusNotifications.ts
Normal file
1070
functions/src/notifications/membershipStatusNotifications.ts
Normal file
File diff suppressed because it is too large
Load Diff
422
functions/src/notifications/processNotification.ts
Normal file
422
functions/src/notifications/processNotification.ts
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
import { onDocumentCreated } from "firebase-functions/v2/firestore";
|
||||||
|
import { getLogger } from "../shared/config";
|
||||||
|
import { getAdmin } from "../shared/config";
|
||||||
|
import * as admin from "firebase-admin";
|
||||||
|
|
||||||
|
const app = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
interface NotificationData {
|
||||||
|
senderId?: string;
|
||||||
|
recipientId?: string;
|
||||||
|
type?: string;
|
||||||
|
notificationSent?: boolean;
|
||||||
|
timestamp?: admin.firestore.FieldValue;
|
||||||
|
read?: boolean;
|
||||||
|
data?: { [key: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const processNotificationOnCreate = onDocumentCreated(
|
||||||
|
{
|
||||||
|
region: "#{SERVICES_RGN}#",
|
||||||
|
document: "notifications/{notificationId}",
|
||||||
|
},
|
||||||
|
async (event) => {
|
||||||
|
try {
|
||||||
|
const notificationSnapshot = event.data;
|
||||||
|
const notificationId = event.params.notificationId;
|
||||||
|
|
||||||
|
if (!notificationSnapshot) {
|
||||||
|
logger.error(`No data found for notification ${notificationId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = notificationSnapshot.data() as NotificationData;
|
||||||
|
|
||||||
|
if (notification.notificationSent === true) {
|
||||||
|
logger.info(`Notification ${notificationId} already sent, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Processing notification ${notificationId} of type: ${notification.type}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const { userId, fcmToken } = await getUserAndFCMToken(notification);
|
||||||
|
if (!fcmToken) {
|
||||||
|
logger.error(
|
||||||
|
`FCM token not found for notification ${notificationId}, user: ${userId}`
|
||||||
|
);
|
||||||
|
await updateNotificationWithError(
|
||||||
|
notificationId,
|
||||||
|
"FCM token not found for user"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = prepareNotificationMessage(notification, fcmToken);
|
||||||
|
try {
|
||||||
|
const fcmResponse = await app.messaging().send(message);
|
||||||
|
|
||||||
|
logger.info(`FCM notification sent successfully: ${fcmResponse}`);
|
||||||
|
await markNotificationAsSent(notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error sending notification ${notificationId}:`, error);
|
||||||
|
await updateNotificationWithError(
|
||||||
|
notificationId,
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing notification:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function getUserAndFCMToken(
|
||||||
|
notification: NotificationData
|
||||||
|
): Promise<{ userId: string | null; fcmToken: string | null }> {
|
||||||
|
let targetUserId: string | null = null;
|
||||||
|
let fcmToken: string | null = null;
|
||||||
|
|
||||||
|
if (notification.recipientId) {
|
||||||
|
targetUserId = notification.recipientId;
|
||||||
|
logger.info(`Using top-level recipientId: ${targetUserId}`);
|
||||||
|
} else if (notification.data?.phoneNumber) {
|
||||||
|
logger.info(
|
||||||
|
`Looking up user by phone number from data: ${notification.data.phoneNumber}`
|
||||||
|
);
|
||||||
|
const userQuery = await app
|
||||||
|
.firestore()
|
||||||
|
.collection("users")
|
||||||
|
.where("phoneNumber", "==", notification.data.phoneNumber)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!userQuery.empty) {
|
||||||
|
const userDoc = userQuery.docs[0];
|
||||||
|
targetUserId = userDoc.id;
|
||||||
|
fcmToken = userDoc.data()?.fcmToken;
|
||||||
|
logger.info(`Found user by phone: ${targetUserId}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`No user found with phone number from data: ${notification.data.phoneNumber}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error("No valid user identifier found in notification or its data");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId && !fcmToken) {
|
||||||
|
fcmToken = await getFCMTokenFromUserDoc(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId && !fcmToken) {
|
||||||
|
logger.warn(`User ${targetUserId} found but no FCM token available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: targetUserId, fcmToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFCMTokenFromUserDoc(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const userDoc = await app.firestore().collection("users").doc(userId).get();
|
||||||
|
if (userDoc.exists) {
|
||||||
|
const userData = userDoc.data();
|
||||||
|
const fcmToken = userData?.fcmToken;
|
||||||
|
if (!fcmToken) {
|
||||||
|
logger.warn(`User ${userId} exists but has no FCM token`);
|
||||||
|
}
|
||||||
|
return fcmToken;
|
||||||
|
} else {
|
||||||
|
logger.warn(`User document not found: ${userId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error fetching user ${userId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareNotificationMessage(
|
||||||
|
notification: NotificationData,
|
||||||
|
fcmToken: string
|
||||||
|
): admin.messaging.TokenMessage {
|
||||||
|
let title = notification.data?.title || "New Notification";
|
||||||
|
let body = notification.data?.message || "You have a new notification";
|
||||||
|
|
||||||
|
let fcmData: Record<string, string> = {
|
||||||
|
type: notification.type || "general",
|
||||||
|
notificationId: "notification_" + Date.now().toString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (notification.senderId) fcmData.senderId = notification.senderId;
|
||||||
|
if (notification.recipientId) fcmData.recipientId = notification.recipientId;
|
||||||
|
if (notification.read !== undefined) fcmData.read = String(notification.read);
|
||||||
|
|
||||||
|
if (notification.data) {
|
||||||
|
for (const key in notification.data) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(notification.data, key)) {
|
||||||
|
const value = notification.data[key];
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
fcmData[key] = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
fcmData[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (notification.type) {
|
||||||
|
case "trainer_response":
|
||||||
|
title =
|
||||||
|
notification.data?.title ||
|
||||||
|
(notification.data?.status === "accepted"
|
||||||
|
? "Trainer Invitation Accepted"
|
||||||
|
: "Trainer Invitation Update");
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${
|
||||||
|
notification.data?.trainerName
|
||||||
|
} has ${notification.data?.status?.toLowerCase()} your request`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_assignment":
|
||||||
|
title = notification.data?.title || "New Client Assignment";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`You have been assigned to train ${notification.data?.name}.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_assigned_to_client":
|
||||||
|
title = notification.data?.title || "Trainer Assigned";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.trainerName} has been assigned as your trainer.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_update_owner":
|
||||||
|
title = notification.data?.title || "Trainer Schedule Update";
|
||||||
|
body =
|
||||||
|
notification.data?.message || "A trainer has updated their schedule";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_update_client":
|
||||||
|
title = notification.data?.title || "Schedule Update";
|
||||||
|
body =
|
||||||
|
notification.data?.message || "Your training schedule has been updated";
|
||||||
|
if (notification.data?.oldTimeSlot && notification.data?.newTimeSlot) {
|
||||||
|
body += ` from ${notification.data.oldTimeSlot} to ${notification.data.newTimeSlot}`;
|
||||||
|
if (notification.data?.formattedDate) {
|
||||||
|
body += ` on ${notification.data.formattedDate}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "plan_renewal":
|
||||||
|
title = notification.data?.title || "Plan Renewal";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`Plan ${notification.data?.subscriptionName} has been renewed`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "plan_assigned":
|
||||||
|
title = notification.data?.title || "New Plan Assigned";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`You have been assigned ${notification.data?.subscriptionName} at ${notification.data?.gymName}`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "plan_expired":
|
||||||
|
title = notification.data?.title || "Plan Expired";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.clientName}'s membership for ${notification.data?.planName} has expired.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "plan_expiring_soon":
|
||||||
|
title = notification.data?.title || "Plan Expiring Soon";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.clientName}'s membership for ${notification.data?.planName} will expire on ${notification.data?.formattedExpiryDate}.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_client_plan_expired":
|
||||||
|
title = notification.data?.title || "Client Plan Expired";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.clientName}'s membership for ${notification.data?.planName} has expired.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "trainer_client_plan_expiring":
|
||||||
|
title = notification.data?.title || "Client Plan Expiring Soon";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.clientName}'s membership for ${notification.data?.planName} will expire on ${notification.data?.formattedExpiryDate}.`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "schedule_update":
|
||||||
|
title = notification.data?.title || "Schedule Update";
|
||||||
|
body =
|
||||||
|
notification.data?.message || "Your training schedule has been updated";
|
||||||
|
if (notification.data?.oldTimeSlot && notification.data?.newTimeSlot) {
|
||||||
|
body += ` from ${notification.data.oldTimeSlot} to ${notification.data.newTimeSlot}`;
|
||||||
|
if (notification.data?.formattedDate) {
|
||||||
|
body += ` on ${notification.data.formattedDate}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "attendance_dispute":
|
||||||
|
title = notification.data?.title || "Attendance Dispute";
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
`${notification.data?.name} has disputed an attendance record`;
|
||||||
|
if (notification.data?.logTime) {
|
||||||
|
body += ` for ${notification.data.logTime}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "day_pass_entry":
|
||||||
|
const isAccepted = notification.data?.status === "ACCEPTED";
|
||||||
|
title =
|
||||||
|
notification.data?.title ||
|
||||||
|
(isAccepted ? "Day Pass Approved" : "Day Pass Denied");
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
(isAccepted
|
||||||
|
? "Your day pass has been approved"
|
||||||
|
: "Your day pass has been denied");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "client_invitations":
|
||||||
|
if (notification.data?.userId || notification.data?.invitorId) {
|
||||||
|
const isAccept = notification.data?.status === "ACCEPTED";
|
||||||
|
title =
|
||||||
|
notification.data?.title ||
|
||||||
|
(isAccept ? "Invitation Accepted" : "Invitation Rejected");
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
(isAccept
|
||||||
|
? `The invitation for ${notification.data?.subscriptionName} you shared with ${notification.data?.name} has been accepted`
|
||||||
|
: `The invitation for ${notification.data?.subscriptionName} you shared with ${notification.data?.name} has been rejected`);
|
||||||
|
} else if (notification.data?.phoneNumber) {
|
||||||
|
const invitationStatus = getInvitationStatus(notification.data?.status);
|
||||||
|
title =
|
||||||
|
notification.data?.title || getInvitationTitle(invitationStatus);
|
||||||
|
body =
|
||||||
|
notification.data?.message ||
|
||||||
|
getInvitationBody(invitationStatus, notification.data?.name);
|
||||||
|
fcmData.status = invitationStatus;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.info(
|
||||||
|
`Using default handling for notification type: ${notification.type}`
|
||||||
|
);
|
||||||
|
title =
|
||||||
|
notification.data?.title ||
|
||||||
|
(notification.type
|
||||||
|
? `${notification.type.replace("_", " ").toUpperCase()}`
|
||||||
|
: "Notification");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationMessage: admin.messaging.TokenMessage = {
|
||||||
|
notification: { title, body },
|
||||||
|
data: fcmData,
|
||||||
|
android: {
|
||||||
|
priority: "high",
|
||||||
|
notification: {
|
||||||
|
channelId: "notifications_channel",
|
||||||
|
priority: "high",
|
||||||
|
defaultSound: true,
|
||||||
|
defaultVibrateTimings: true,
|
||||||
|
icon: "@mipmap/ic_launcher",
|
||||||
|
clickAction: "FLUTTER_NOTIFICATION_CLICK",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apns: {
|
||||||
|
payload: {
|
||||||
|
aps: {
|
||||||
|
sound: "default",
|
||||||
|
badge: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
token: fcmToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Prepared notification: ${title} - ${body}`);
|
||||||
|
return notificationMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvitationStatus(status?: string): string {
|
||||||
|
if (status === "ACCEPTED") return "accepted";
|
||||||
|
if (status === "REJECTED") return "rejected";
|
||||||
|
if (status === "PENDING") return "pending";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvitationTitle(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "accepted":
|
||||||
|
return "Invitation Accepted";
|
||||||
|
case "rejected":
|
||||||
|
return "Invitation Rejected";
|
||||||
|
case "pending":
|
||||||
|
return "New Invitation";
|
||||||
|
default:
|
||||||
|
return "Invitation Update";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvitationBody(status: string, name?: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "accepted":
|
||||||
|
return `You have accepted the invitation from ${name}`;
|
||||||
|
case "rejected":
|
||||||
|
return `You have rejected the invitation from ${name}`;
|
||||||
|
case "pending":
|
||||||
|
return `You have a new invitation pending from ${name}`;
|
||||||
|
default:
|
||||||
|
return "There is an update to your invitation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNotificationAsSent(notificationId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await app
|
||||||
|
.firestore()
|
||||||
|
.collection("notifications")
|
||||||
|
.doc(notificationId)
|
||||||
|
.update({
|
||||||
|
notificationSent: true,
|
||||||
|
sentAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.info(`Notification ${notificationId} marked as sent`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error marking notification as sent: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNotificationWithError(
|
||||||
|
notificationId: string,
|
||||||
|
error: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await app
|
||||||
|
.firestore()
|
||||||
|
.collection("notifications")
|
||||||
|
.doc(notificationId)
|
||||||
|
.update({
|
||||||
|
notificationError: error,
|
||||||
|
notificationSent: false,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.info(`Notification ${notificationId} marked with error: ${error}`);
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(`Error updating notification with error: ${updateError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
functions/src/payments/cashfree/createLink.ts
Normal file
133
functions/src/payments/cashfree/createLink.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import axios from "axios";
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
interface CashfreeLinkRequest {
|
||||||
|
amount: number;
|
||||||
|
customerName?: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerPhone: string;
|
||||||
|
productInfo?: string;
|
||||||
|
userId?: string;
|
||||||
|
gymId?: string;
|
||||||
|
orderId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashfreeLinkResponse {
|
||||||
|
link_id: string;
|
||||||
|
link_url: string;
|
||||||
|
link_expiry_time: string;
|
||||||
|
link_status: string;
|
||||||
|
link_qrcode: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCashfreeLink = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
|
const uid = decodedToken.uid;
|
||||||
|
|
||||||
|
const linkRequest = request.body as CashfreeLinkRequest;
|
||||||
|
if (!linkRequest.amount || !linkRequest.customerEmail || !linkRequest.customerPhone) {
|
||||||
|
response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
logger.error('Cashfree credentials not configured');
|
||||||
|
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
const expirationString = expirationDate.toISOString();
|
||||||
|
const apiUrl = process.env.CASHFREE_LINK_URL;
|
||||||
|
const linkId = uuidv4();
|
||||||
|
|
||||||
|
const requestHeaders = {
|
||||||
|
'x-client-id': clientId,
|
||||||
|
'x-client-secret': clientSecret,
|
||||||
|
'x-api-version': '2025-01-01',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
link_id: linkId,
|
||||||
|
link_amount: linkRequest.amount,
|
||||||
|
link_currency: "INR",
|
||||||
|
link_purpose: linkRequest.productInfo,
|
||||||
|
customer_details: {
|
||||||
|
customer_phone: linkRequest.customerPhone,
|
||||||
|
customer_email: linkRequest.customerEmail,
|
||||||
|
customer_name: linkRequest.customerName,
|
||||||
|
},
|
||||||
|
link_partial_payments: false,
|
||||||
|
link_notify: {
|
||||||
|
send_sms: true,
|
||||||
|
send_email: true
|
||||||
|
},
|
||||||
|
link_expiry_time: expirationString,
|
||||||
|
link_notes: {
|
||||||
|
order_id: linkRequest.orderId,
|
||||||
|
gym_id: linkRequest.gymId,
|
||||||
|
user_id: linkRequest.userId
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cashfreeResponse = await axios.post<CashfreeLinkResponse>(
|
||||||
|
apiUrl!,
|
||||||
|
requestBody,
|
||||||
|
{ headers: requestHeaders }
|
||||||
|
);
|
||||||
|
|
||||||
|
await admin.firestore().collection('payment_links').doc(linkRequest.orderId).set({
|
||||||
|
requestUserId: uid,
|
||||||
|
amount: linkRequest.amount,
|
||||||
|
customerEmail: linkRequest.customerEmail,
|
||||||
|
customerPhone: linkRequest.customerPhone,
|
||||||
|
userId: linkRequest.userId,
|
||||||
|
gymId: linkRequest.gymId,
|
||||||
|
orderId: linkRequest.orderId,
|
||||||
|
...cashfreeResponse.data,
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
linkId: linkId,
|
||||||
|
linkUrl: cashfreeResponse.data.link_url,
|
||||||
|
linkExpiryTime: cashfreeResponse.data.link_expiry_time,
|
||||||
|
linkStatus: cashfreeResponse.data.link_status,
|
||||||
|
linkQRCode: cashfreeResponse.data.link_qrcode
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Cashfree link creation error:', error);
|
||||||
|
const statusCode = error.response?.status || 500;
|
||||||
|
response.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || 'Failed to create payment link',
|
||||||
|
details: error.response?.data || error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
118
functions/src/payments/cashfree/createOrder.ts
Normal file
118
functions/src/payments/cashfree/createOrder.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import axios from "axios";
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
interface CashfreeOrderRequest {
|
||||||
|
amount: number;
|
||||||
|
customerName?: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerPhone: string;
|
||||||
|
productInfo?: string;
|
||||||
|
userId?: string;
|
||||||
|
gymId?: string;
|
||||||
|
orderId: string;
|
||||||
|
webHostUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CashfreeOrderResponse {
|
||||||
|
order_id: string;
|
||||||
|
payment_session_id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCashfreeOrder = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
|
const uid = decodedToken.uid;
|
||||||
|
|
||||||
|
const orderRequest = request.body as CashfreeOrderRequest;
|
||||||
|
if (!orderRequest.amount || !orderRequest.customerEmail || !orderRequest.customerPhone) {
|
||||||
|
response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
logger.error('Cashfree credentials not configured');
|
||||||
|
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashKey = `hash_${Date.now()}_${uid.substring(0, 1)}_${orderRequest.orderId}`;
|
||||||
|
const apiUrl = process.env.CASHFREE_URL;
|
||||||
|
|
||||||
|
const cashfreeResponse = await axios.post<CashfreeOrderResponse>(
|
||||||
|
apiUrl!,
|
||||||
|
{
|
||||||
|
order_id: orderRequest.orderId,
|
||||||
|
hash_key: hashKey,
|
||||||
|
order_amount: orderRequest.amount,
|
||||||
|
order_currency: 'INR',
|
||||||
|
customer_details: {
|
||||||
|
customer_id: uid,
|
||||||
|
customer_name: orderRequest.customerName || 'Fitlien User',
|
||||||
|
customer_email: orderRequest.customerEmail,
|
||||||
|
customer_phone: orderRequest.customerPhone
|
||||||
|
},
|
||||||
|
order_meta: {
|
||||||
|
return_url: `https://${orderRequest.webHostUrl}?order_id=${orderRequest.orderId}&hash_key=${hashKey}&user_id=${orderRequest.userId}&gym_id=${orderRequest.gymId}#/payment-status-screen`,
|
||||||
|
},
|
||||||
|
order_note: orderRequest.productInfo || 'Fitlien Membership'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-api-version': '2022-09-01',
|
||||||
|
'x-client-id': clientId,
|
||||||
|
'x-client-secret': clientSecret,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await admin.firestore().collection('payment_orders').doc(orderRequest.orderId).set({
|
||||||
|
userId: uid,
|
||||||
|
amount: orderRequest.amount,
|
||||||
|
customerEmail: orderRequest.customerEmail,
|
||||||
|
customerPhone: orderRequest.customerPhone,
|
||||||
|
orderStatus: 'CREATED',
|
||||||
|
paymentGateway: 'Cashfree',
|
||||||
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
hashKey: hashKey,
|
||||||
|
clientId: orderRequest.userId,
|
||||||
|
gymId: orderRequest.gymId,
|
||||||
|
orderId: orderRequest.orderId,
|
||||||
|
...cashfreeResponse.data
|
||||||
|
});
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
order_id: cashfreeResponse.data.order_id,
|
||||||
|
payment_session_id: cashfreeResponse.data.payment_session_id
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Cashfree order creation error:', error);
|
||||||
|
const statusCode = error.response?.status || 500;
|
||||||
|
response.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || 'Failed to create payment order',
|
||||||
|
details: error.response?.data || error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
3
functions/src/payments/cashfree/index.ts
Normal file
3
functions/src/payments/cashfree/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { createCashfreeLink } from './createLink';
|
||||||
|
export { verifyCashfreePayment } from './verifyPayment';
|
||||||
|
export { createCashfreeOrder } from './createOrder';
|
||||||
65
functions/src/payments/cashfree/verifyPayment.ts
Normal file
65
functions/src/payments/cashfree/verifyPayment.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import axios from "axios";
|
||||||
|
const logger = getLogger();
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
const admin = getAdmin();
|
||||||
|
interface CashfreePaymentResponse {
|
||||||
|
order_status: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyCashfreePayment = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const orderId = request.body.order_id || request.query.order_id;
|
||||||
|
if (!orderId) {
|
||||||
|
response.status(400).json({ error: 'Order ID is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.CASHFREE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.CASHFREE_CLIENT_SECRET;
|
||||||
|
const apiUrl = `${process.env.CASHFREE_URL}/${orderId}`;
|
||||||
|
|
||||||
|
const cashfreeResponse = await axios.get<CashfreePaymentResponse>(
|
||||||
|
apiUrl!,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-api-version': '2022-09-01',
|
||||||
|
'x-client-id': clientId,
|
||||||
|
'x-client-secret': clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await admin.firestore().collection('payment_orders').doc(orderId).update({
|
||||||
|
orderStatus: cashfreeResponse.data.order_status,
|
||||||
|
paymentDetails: cashfreeResponse.data,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (request.headers['x-webhook-source'] === 'cashfree') {
|
||||||
|
response.status(200).send('OK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
status: cashfreeResponse.data.order_status,
|
||||||
|
paymentDetails: cashfreeResponse.data
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Cashfree payment verification error:', error);
|
||||||
|
const statusCode = error.response?.status || 500;
|
||||||
|
response.status(statusCode).json({
|
||||||
|
error: 'Failed to verify payment status',
|
||||||
|
details: error.response?.data || error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
2
functions/src/payments/index.ts
Normal file
2
functions/src/payments/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './cashfree';
|
||||||
|
export * from './phonepe';
|
||||||
257
functions/src/payments/phonepe/checkStatus.ts
Normal file
257
functions/src/payments/phonepe/checkStatus.ts
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request} from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import { updatePaymentDataAfterSuccess } from "./paymentData";
|
||||||
|
import { InvoiceService } from "./invoice/invoiceService";
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
export const checkPhonePePaymentStatus = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization || '';
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
try {
|
||||||
|
await admin.auth().verifyIdToken(idToken);
|
||||||
|
|
||||||
|
const merchantOrderId = request.query.merchantOrderId as string;
|
||||||
|
if (!merchantOrderId) {
|
||||||
|
response.status(400).json({ error: 'Missing merchant order ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = request.query.details === 'true';
|
||||||
|
const errorContext = request.query.errorContext === 'true';
|
||||||
|
|
||||||
|
const clientId = process.env.PHONEPE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.PHONEPE_CLIENT_SECRET;
|
||||||
|
const apiUrl = process.env.PHONEPE_API_URL;
|
||||||
|
const clientVersion = process.env.PHONEPE_CLIENT_VERSION || '1';
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !apiUrl) {
|
||||||
|
logger.error('PhonePe credentials not configured');
|
||||||
|
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
`${apiUrl}/v1/oauth/token`,
|
||||||
|
{
|
||||||
|
client_id: clientId,
|
||||||
|
client_version: clientVersion,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = tokenResponse.data.access_token;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (details) queryParams.append('details', 'true');
|
||||||
|
if (errorContext) queryParams.append('errorContext', 'true');
|
||||||
|
const queryString = queryParams.toString() ? `?${queryParams.toString()}` : '';
|
||||||
|
|
||||||
|
const statusResponse = await axios.get(
|
||||||
|
`${apiUrl}/checkout/v2/order/${merchantOrderId}/status${queryString}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `O-Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderQuery = await admin.firestore()
|
||||||
|
.collection('payment_orders')
|
||||||
|
.where('merchantOrderId', '==', merchantOrderId)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (orderQuery.empty) {
|
||||||
|
logger.error(`No payment order found with PhonePe orderId: ${merchantOrderId}`);
|
||||||
|
response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Payment order not found',
|
||||||
|
message: `No record found for PhonePe order ID: ${merchantOrderId}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderDoc = orderQuery.docs[0];
|
||||||
|
const orderData = orderDoc.data();
|
||||||
|
|
||||||
|
await orderDoc.ref.update({
|
||||||
|
orderStatus: statusResponse.data.state || 'UNKNOWN',
|
||||||
|
lastChecked: new Date(),
|
||||||
|
statusResponse: statusResponse.data
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusResponse.data.state === 'COMPLETED') {
|
||||||
|
try {
|
||||||
|
// Update payment data
|
||||||
|
const paymentUpdateSuccess = await updatePaymentDataAfterSuccess(
|
||||||
|
merchantOrderId,
|
||||||
|
statusResponse.data.orderId,
|
||||||
|
statusResponse.data
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paymentUpdateSuccess) {
|
||||||
|
// Extract membership ID from metaInfo
|
||||||
|
const membershipId = orderData.metaInfo?.membershipId;
|
||||||
|
|
||||||
|
if (membershipId) {
|
||||||
|
try {
|
||||||
|
// Get user data for invoice
|
||||||
|
const membershipDoc = await admin.firestore()
|
||||||
|
.collection('memberships')
|
||||||
|
.doc(membershipId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (membershipDoc.exists) {
|
||||||
|
const membershipData = membershipDoc.data();
|
||||||
|
const userId = membershipData?.userId;
|
||||||
|
|
||||||
|
// Get user details
|
||||||
|
const userDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (userDoc.exists) {
|
||||||
|
const userData = userDoc.data();
|
||||||
|
|
||||||
|
// Get gym details
|
||||||
|
const gymId = orderData.metaInfo?.gymId || membershipData?.gymId;
|
||||||
|
let gymName = 'Fitlien';
|
||||||
|
let gymAddress = '';
|
||||||
|
let gstNumber = '';
|
||||||
|
|
||||||
|
if (gymId) {
|
||||||
|
const gymDoc = await admin.firestore()
|
||||||
|
.collection('gyms')
|
||||||
|
.doc(gymId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (gymDoc.exists) {
|
||||||
|
const gymData = gymDoc.data();
|
||||||
|
gymName = gymData?.name || 'Fitlien';
|
||||||
|
gymAddress = gymData?.address || '';
|
||||||
|
gstNumber = gymData?.gstNumber || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invoice data
|
||||||
|
const invoiceData = {
|
||||||
|
invoiceNumber: `INV-${merchantOrderId.substring(0, 8)}`,
|
||||||
|
businessName: gymName,
|
||||||
|
address: gymAddress,
|
||||||
|
gstNumber: gstNumber,
|
||||||
|
customerName: userData?.displayName || `${membershipData?.['first-name'] || ''} ${membershipData?.['last-name'] || ''}`.trim(),
|
||||||
|
phoneNumber: membershipData?.['phone-number'] || orderData.metaInfo?.phoneNumber || '',
|
||||||
|
email: membershipData?.['email'] || '',
|
||||||
|
planName: orderData.metaInfo?.planName || 'Membership',
|
||||||
|
amount: orderData.amount,
|
||||||
|
transactionId: statusResponse.data.orderId,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'Online'
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||||
|
|
||||||
|
// Update payment record with invoice path
|
||||||
|
await admin.firestore()
|
||||||
|
.collection('membership_payments')
|
||||||
|
.doc(membershipId)
|
||||||
|
.get()
|
||||||
|
.then(async (doc) => {
|
||||||
|
if (doc.exists) {
|
||||||
|
const paymentsData = doc.data()?.payments || [];
|
||||||
|
for (let i = 0; i < paymentsData.length; i++) {
|
||||||
|
if (paymentsData[i].referenceNumber === merchantOrderId ||
|
||||||
|
paymentsData[i].transactionId === statusResponse.data.orderId) {
|
||||||
|
paymentsData[i].invoicePath = invoicePath;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await doc.ref.update({
|
||||||
|
'payments': paymentsData,
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Generated invoice for payment: ${merchantOrderId}, path: ${invoicePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (invoiceError) {
|
||||||
|
logger.error('Error generating invoice:', invoiceError);
|
||||||
|
// Continue processing - don't fail the response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Payment data updated for completed payment: ${merchantOrderId}`);
|
||||||
|
} catch (paymentUpdateError) {
|
||||||
|
logger.error('Error updating payment data:', paymentUpdateError);
|
||||||
|
// Continue processing - don't fail the response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('PhonePe status response data:', JSON.stringify(statusResponse.data));
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
state: statusResponse.data.state,
|
||||||
|
data: statusResponse.data
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (authError: any) {
|
||||||
|
logger.error('Authentication error:', authError);
|
||||||
|
|
||||||
|
if (authError.response) {
|
||||||
|
logger.error('API error details:', {
|
||||||
|
status: authError.response.status,
|
||||||
|
data: authError.response.data
|
||||||
|
});
|
||||||
|
|
||||||
|
response.status(authError.response.status).json({
|
||||||
|
success: false,
|
||||||
|
error: 'API error',
|
||||||
|
details: authError.response.data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid authentication token or API error',
|
||||||
|
message: authError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('PhonePe payment status check error:', error);
|
||||||
|
response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to check payment status',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
133
functions/src/payments/phonepe/createPhonepeOrder.ts
Normal file
133
functions/src/payments/phonepe/createPhonepeOrder.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request} from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import axios from "axios";
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
|
||||||
|
export const createPhonePeOrder = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization || '';
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
try {
|
||||||
|
const decodedToken = await admin.auth().verifyIdToken(idToken);
|
||||||
|
const uid = decodedToken.uid;
|
||||||
|
|
||||||
|
const {
|
||||||
|
merchantOrderId,
|
||||||
|
amount,
|
||||||
|
expireAfter,
|
||||||
|
metaInfo,
|
||||||
|
paymentFlow
|
||||||
|
} = request.body;
|
||||||
|
|
||||||
|
if (!merchantOrderId || !amount || !paymentFlow || !expireAfter) {
|
||||||
|
response.status(400).json({ error: 'Missing required fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paymentFlow.type || !paymentFlow.merchantUrls || !paymentFlow.merchantUrls.redirectUrl) {
|
||||||
|
response.status(400).json({ error: 'Invalid payment flow configuration' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = process.env.PHONEPE_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.PHONEPE_CLIENT_SECRET;
|
||||||
|
const apiUrl = process.env.PHONEPE_API_URL;
|
||||||
|
const clientVersion = process.env.PHONEPE_CLIENT_VERSION || '1';
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret || !apiUrl) {
|
||||||
|
logger.error('PhonePe credentials not configured');
|
||||||
|
response.status(500).json({ error: 'Payment gateway configuration error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await axios.post(
|
||||||
|
`${apiUrl}/v1/oauth/token`,
|
||||||
|
{
|
||||||
|
client_id: clientId,
|
||||||
|
client_version: clientVersion,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = tokenResponse.data.access_token;
|
||||||
|
|
||||||
|
const paymentResponse = await axios.post(
|
||||||
|
`${apiUrl}/checkout/v2/pay`,
|
||||||
|
request.body,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `O-Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await admin.firestore().collection('payment_orders').doc(merchantOrderId).set({
|
||||||
|
userId: uid,
|
||||||
|
amount: amount / 100,
|
||||||
|
orderStatus: paymentResponse.data.state || 'PENDING',
|
||||||
|
paymentGateway: 'PhonePe',
|
||||||
|
createdAt: new Date(),
|
||||||
|
merchantOrderId: merchantOrderId,
|
||||||
|
paymentUrl: paymentResponse.data.redirectUrl,
|
||||||
|
orderId: paymentResponse.data.orderId,
|
||||||
|
expireAt: new Date(paymentResponse.data.expireAt),
|
||||||
|
// rawResponse: paymentResponse.data,
|
||||||
|
metaInfo: metaInfo || {}
|
||||||
|
});
|
||||||
|
} catch (firestoreError) {
|
||||||
|
logger.error('Error storing order in Firestore:', firestoreError);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
...paymentResponse.data,
|
||||||
|
merchantOrderId: merchantOrderId
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`PhonePe order created: ${merchantOrderId}`);
|
||||||
|
} catch (apiError: any) {
|
||||||
|
logger.error('PhonePe API error:', apiError);
|
||||||
|
response.status(apiError.response?.status || 500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Payment gateway error',
|
||||||
|
details: apiError.response?.data || apiError.message,
|
||||||
|
code: apiError.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (authError) {
|
||||||
|
logger.error('Authentication error:', authError);
|
||||||
|
response.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid authentication token'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('PhonePe order creation error:', error);
|
||||||
|
response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to create payment order',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
5
functions/src/payments/phonepe/index.ts
Normal file
5
functions/src/payments/phonepe/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { createPhonePeOrder } from './createPhonepeOrder';
|
||||||
|
export { checkPhonePePaymentStatus } from './checkStatus';
|
||||||
|
export { phonePeWebhook } from './webhook';
|
||||||
|
export { updatePaymentDataAfterSuccess } from './paymentData';
|
||||||
|
export * from './invoice';
|
||||||
104
functions/src/payments/phonepe/invoice/directInvoice.ts
Normal file
104
functions/src/payments/phonepe/invoice/directInvoice.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import { getCorsHandler } from "../../../shared/middleware";
|
||||||
|
import { getAdmin, getLogger } from "../../../shared/config";
|
||||||
|
import { InvoiceService, InvoiceData } from "./invoiceService";
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
export const directGenerateInvoice = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const authHeader = request.headers.authorization || '';
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await admin.auth().verifyIdToken(idToken);
|
||||||
|
|
||||||
|
const {
|
||||||
|
invoiceNumber,
|
||||||
|
businessName,
|
||||||
|
address,
|
||||||
|
gstNumber,
|
||||||
|
customerName,
|
||||||
|
phoneNumber,
|
||||||
|
email,
|
||||||
|
planName,
|
||||||
|
amount,
|
||||||
|
transactionId,
|
||||||
|
paymentDate,
|
||||||
|
paymentMethod,
|
||||||
|
sendEmail,
|
||||||
|
emailOptions
|
||||||
|
} = request.body;
|
||||||
|
|
||||||
|
if (!invoiceNumber || !businessName || !customerName || !amount || !transactionId) {
|
||||||
|
response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Missing required fields'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceData: InvoiceData = {
|
||||||
|
invoiceNumber,
|
||||||
|
businessName,
|
||||||
|
address: address || '',
|
||||||
|
gstNumber,
|
||||||
|
customerName,
|
||||||
|
phoneNumber: phoneNumber || '',
|
||||||
|
email: email || '',
|
||||||
|
planName: planName || 'Membership',
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
transactionId,
|
||||||
|
paymentDate: paymentDate ? new Date(paymentDate) : new Date(),
|
||||||
|
paymentMethod: paymentMethod || 'Online'
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||||
|
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
|
||||||
|
let emailSent = false;
|
||||||
|
if (sendEmail && email) {
|
||||||
|
emailSent = await invoiceService.sendInvoiceEmail(invoicePath, {
|
||||||
|
recipientEmail: email,
|
||||||
|
recipientName: customerName,
|
||||||
|
...emailOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
response.json({
|
||||||
|
success: true,
|
||||||
|
invoicePath,
|
||||||
|
downloadUrl,
|
||||||
|
emailSent
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (authError: any) {
|
||||||
|
logger.error('Authentication error:', authError);
|
||||||
|
response.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid authentication token',
|
||||||
|
details: authError.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error generating invoice:', error);
|
||||||
|
response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to generate invoice',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
62
functions/src/payments/phonepe/invoice/getInvoiceUrl.ts
Normal file
62
functions/src/payments/phonepe/invoice/getInvoiceUrl.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
// import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
// import { Request } from "firebase-functions/v2/https";
|
||||||
|
// import { getCorsHandler } from "../../../shared/middleware";
|
||||||
|
// import { getAdmin, getLogger } from "../../../shared/config";
|
||||||
|
// import { InvoiceService } from "./invoiceService";
|
||||||
|
|
||||||
|
// const admin = getAdmin();
|
||||||
|
// const logger = getLogger();
|
||||||
|
// const corsHandler = getCorsHandler();
|
||||||
|
// const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
// export const getInvoiceUrl = onRequest({
|
||||||
|
// region: '#{SERVICES_RGN}#'
|
||||||
|
// }, async (request: Request, response) => {
|
||||||
|
// return corsHandler(request, response, async () => {
|
||||||
|
// try {
|
||||||
|
// const authHeader = request.headers.authorization || '';
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await admin.auth().verifyIdToken(idToken);
|
||||||
|
|
||||||
|
// const { invoicePath } = request.query;
|
||||||
|
|
||||||
|
// if (!invoicePath) {
|
||||||
|
// response.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Missing invoice path'
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath as string);
|
||||||
|
|
||||||
|
// response.json({
|
||||||
|
// success: true,
|
||||||
|
// downloadUrl
|
||||||
|
// });
|
||||||
|
|
||||||
|
// } catch (authError: any) {
|
||||||
|
// logger.error('Authentication error:', authError);
|
||||||
|
// response.status(401).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Invalid authentication token',
|
||||||
|
// details: authError.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// } catch (error: any) {
|
||||||
|
// logger.error('Error getting invoice URL:', error);
|
||||||
|
// response.status(500).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Failed to get invoice URL',
|
||||||
|
// details: error.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// });
|
||||||
13
functions/src/payments/phonepe/invoice/index.ts
Normal file
13
functions/src/payments/phonepe/invoice/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// import { getInvoiceUrl } from './getInvoiceUrl';
|
||||||
|
import { InvoiceService } from './invoiceService';
|
||||||
|
// import { processInvoice } from './processInvoice';
|
||||||
|
// import { sendInvoiceEmail } from './sendInvoiceEmail';
|
||||||
|
import { directGenerateInvoice } from './directInvoice';
|
||||||
|
|
||||||
|
export {
|
||||||
|
// getInvoiceUrl,
|
||||||
|
InvoiceService,
|
||||||
|
// processInvoice,
|
||||||
|
// sendInvoiceEmail,
|
||||||
|
directGenerateInvoice,
|
||||||
|
};
|
||||||
386
functions/src/payments/phonepe/invoice/invoiceService.ts
Normal file
386
functions/src/payments/phonepe/invoice/invoiceService.ts
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
import { getAdmin, getLogger } from "../../../shared/config";
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { sendEmailWithAttachmentUtil } from "../../../utils/emailService";
|
||||||
|
import { jsPDF } from "jspdf";
|
||||||
|
import autoTable from 'jspdf-autotable';
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
export interface InvoiceData {
|
||||||
|
invoiceNumber: string;
|
||||||
|
businessName: string;
|
||||||
|
address: string;
|
||||||
|
gstNumber?: string;
|
||||||
|
customerName: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
email: string;
|
||||||
|
planName: string;
|
||||||
|
amount: number;
|
||||||
|
transactionId: string;
|
||||||
|
paymentDate: Date;
|
||||||
|
paymentMethod: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailOptions {
|
||||||
|
recipientEmail: string;
|
||||||
|
recipientName?: string;
|
||||||
|
subject?: string;
|
||||||
|
customHtml?: string;
|
||||||
|
additionalData?: {
|
||||||
|
gymName?: string;
|
||||||
|
planName?: string;
|
||||||
|
amount?: number;
|
||||||
|
transactionId?: string;
|
||||||
|
paymentDate?: Date;
|
||||||
|
paymentMethod?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvoiceService {
|
||||||
|
async generateInvoice(data: InvoiceData): Promise<string> {
|
||||||
|
try {
|
||||||
|
const tempFilePath = path.join(os.tmpdir(), `invoice_${data.invoiceNumber}.pdf`);
|
||||||
|
|
||||||
|
const hasGst = data.gstNumber && data.gstNumber.length > 0;
|
||||||
|
const baseAmount = hasGst ? data.amount / 1.18 : data.amount;
|
||||||
|
const sgst = hasGst ? baseAmount * 0.09 : 0;
|
||||||
|
const cgst = hasGst ? baseAmount * 0.09 : 0;
|
||||||
|
|
||||||
|
const formattedDate = format(data.paymentDate, 'dd/MM/yyyy');
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text(data.businessName, 15, 20);
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
|
||||||
|
const maxWidth = 170;
|
||||||
|
const lineHeight = 5;
|
||||||
|
|
||||||
|
const addressLines = doc.splitTextToSize(data.address, maxWidth);
|
||||||
|
|
||||||
|
if (addressLines.length <= 2) {
|
||||||
|
for (let i = 0; i < addressLines.length; i++) {
|
||||||
|
doc.text(addressLines[i], 15, 30 + (i * lineHeight));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
doc.text(addressLines[0], 15, 30);
|
||||||
|
|
||||||
|
let secondLine = addressLines[1];
|
||||||
|
if (secondLine.length > 3) {
|
||||||
|
secondLine = secondLine.substring(0, secondLine.length - 3) + '...';
|
||||||
|
} else {
|
||||||
|
secondLine += '...';
|
||||||
|
}
|
||||||
|
doc.text(secondLine, 15, 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gstYPosition = 40;
|
||||||
|
|
||||||
|
if (hasGst) {
|
||||||
|
doc.text(`GSTIN: ${data.gstNumber}`, 20, gstYPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(24);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('RECEIPT', 195, 20, { align: 'right' });
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.text(`Receipt #: ${data.invoiceNumber}`, 195, 30, { align: 'right' });
|
||||||
|
doc.text(`Date: ${formattedDate}`, 195, 40, { align: 'right' });
|
||||||
|
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.line(15, 45, 195, 45);
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
const receiptToBoxX = 15;
|
||||||
|
const receiptToBoxY = 50;
|
||||||
|
const receiptToBoxWidth = 100;
|
||||||
|
const receiptToBoxHeight = 36;
|
||||||
|
|
||||||
|
doc.setDrawColor(0, 0, 0);
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.rect(receiptToBoxX, receiptToBoxY, receiptToBoxWidth, receiptToBoxHeight);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Receipt To:', 18, 60);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(data.customerName, 18, 70);
|
||||||
|
doc.text(`${data.phoneNumber}`, 18, 75);
|
||||||
|
doc.text(`${data.email}`, 18, 80);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: 110,
|
||||||
|
margin: {left: 15, right: 15},
|
||||||
|
head: [
|
||||||
|
[
|
||||||
|
{content: 'No.', styles: {halign: 'center'}},
|
||||||
|
{content: 'Description', styles: {halign: 'left'}},
|
||||||
|
{content: 'HSN/SAC', styles: {halign: 'center'}},
|
||||||
|
{content: 'Amount (INR)', styles: {halign: 'right'}}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
body: [
|
||||||
|
['1', `${data.planName} Subscription`, '999723', baseAmount.toFixed(2)]
|
||||||
|
],
|
||||||
|
headStyles: {
|
||||||
|
fillColor: [220, 220, 220],
|
||||||
|
textColor: [0, 0, 0],
|
||||||
|
fontStyle: 'bold',
|
||||||
|
lineWidth: 0.5,
|
||||||
|
lineColor: [0, 0, 0]
|
||||||
|
},
|
||||||
|
styles: {
|
||||||
|
halign: 'center',
|
||||||
|
lineWidth: 0.5,
|
||||||
|
lineColor: [0, 0, 0]
|
||||||
|
},
|
||||||
|
columnStyles: {
|
||||||
|
0: { halign: 'center', cellWidth: 20 },
|
||||||
|
1: { halign: 'left' },
|
||||||
|
2: { halign: 'center', cellWidth: 40 },
|
||||||
|
3: { halign: 'right', cellWidth: 40 }
|
||||||
|
},
|
||||||
|
theme: 'grid',
|
||||||
|
tableLineWidth: 0.5,
|
||||||
|
tableLineColor: [0, 0, 0],
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalY = (doc as any).lastAutoTable.finalY + 20;
|
||||||
|
|
||||||
|
if (hasGst) {
|
||||||
|
doc.text('Taxable Amount:', 150, finalY, { align: 'right' });
|
||||||
|
doc.text(`${baseAmount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
|
||||||
|
|
||||||
|
doc.text('SGST (9%):', 150, finalY + 10, { align: 'right' });
|
||||||
|
doc.text(`${sgst.toFixed(2)} INR`, 195, finalY + 10, { align: 'right' });
|
||||||
|
|
||||||
|
doc.text('CGST (9%):', 150, finalY + 20, { align: 'right' });
|
||||||
|
doc.text(`${cgst.toFixed(2)} INR`, 195, finalY + 20, { align: 'right' });
|
||||||
|
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.line(15, finalY + 25, 195, finalY + 25);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Total Amount:', 150, finalY + 30, { align: 'right' });
|
||||||
|
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY + 30, { align: 'right' });
|
||||||
|
} else {
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.line(15, finalY - 5, 195, finalY - 5);
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Total Amount:', 150, finalY, { align: 'right' });
|
||||||
|
doc.text(`${data.amount.toFixed(2)} INR`, 195, finalY, { align: 'right' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentY = hasGst ? finalY + 50 : finalY + 20;
|
||||||
|
|
||||||
|
const boxX = 15;
|
||||||
|
const boxY = paymentY - 10;
|
||||||
|
const boxWidth = 100;
|
||||||
|
const boxHeight = 36;
|
||||||
|
|
||||||
|
doc.setDrawColor(0, 0, 0);
|
||||||
|
doc.setLineWidth(0.5);
|
||||||
|
doc.rect(boxX, boxY, boxWidth, boxHeight);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('Payment Information:', 18, paymentY);
|
||||||
|
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text(`Transaction ID: ${data.transactionId}`, 18, paymentY + 10);
|
||||||
|
doc.text(`Payment Method: ${data.paymentMethod}`, 18, paymentY + 15);
|
||||||
|
doc.text(`Payment Date: ${formattedDate}`, 18, paymentY + 20);
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setFont('helvetica', 'italic');
|
||||||
|
doc.text('Thank you for your business!', 105, 270, { align: 'center' });
|
||||||
|
|
||||||
|
doc.setFontSize(10);
|
||||||
|
doc.setFont('helvetica', 'normal');
|
||||||
|
doc.text('This is a computer-generated receipt and does not require a signature.', 105, 280, { align: 'center' });
|
||||||
|
|
||||||
|
fs.writeFileSync(tempFilePath, Buffer.from(doc.output('arraybuffer')));
|
||||||
|
|
||||||
|
const invoicePath = `invoices/${data.invoiceNumber}.pdf`;
|
||||||
|
const bucket = admin.storage().bucket();
|
||||||
|
await bucket.upload(tempFilePath, {
|
||||||
|
destination: invoicePath,
|
||||||
|
metadata: {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.unlinkSync(tempFilePath);
|
||||||
|
|
||||||
|
return invoicePath;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error generating invoice:', error);
|
||||||
|
throw new Error(`Failed to generate invoice: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoiceDownloadUrl(invoicePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const bucket = admin.storage().bucket();
|
||||||
|
const file = bucket.file(invoicePath);
|
||||||
|
|
||||||
|
const expirationMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const [signedUrl] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + expirationMs,
|
||||||
|
responseDisposition: `attachment; filename="${path.basename(invoicePath)}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return signedUrl;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error getting invoice download URL:', error);
|
||||||
|
throw new Error(`Failed to get invoice download URL: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoicePath(membershipId: string, paymentId: string, invoicePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const membershipPaymentsRef = admin.firestore()
|
||||||
|
.collection('membership_payments')
|
||||||
|
.doc(membershipId);
|
||||||
|
|
||||||
|
const docSnapshot = await membershipPaymentsRef.get();
|
||||||
|
|
||||||
|
if (!docSnapshot.exists) {
|
||||||
|
logger.error(`No membership payments found for membershipId: ${membershipId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = docSnapshot.data();
|
||||||
|
const paymentsData = data?.payments || [];
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (let i = 0; i < paymentsData.length; i++) {
|
||||||
|
if (paymentsData[i].referenceNumber === paymentId ||
|
||||||
|
paymentsData[i].transactionId === paymentId) {
|
||||||
|
paymentsData[i].invoicePath = invoicePath;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
logger.error(`No payment found with ID: ${paymentId} in membership: ${membershipId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await membershipPaymentsRef.update({
|
||||||
|
'payments': paymentsData,
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Successfully updated invoice path for payment: ${paymentId}`);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error updating payment with invoice path:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendInvoiceEmail(invoicePath: string, emailOptions: EmailOptions): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
|
||||||
|
const formattedDate = emailOptions.additionalData?.paymentDate
|
||||||
|
? new Date(emailOptions.additionalData.paymentDate).toLocaleDateString('en-GB')
|
||||||
|
: new Date().toLocaleDateString('en-GB');
|
||||||
|
|
||||||
|
const emailHtml = emailOptions.customHtml || `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>Thank you for your payment</h2>
|
||||||
|
<p>Dear ${emailOptions.recipientName || 'Valued Customer'},</p>
|
||||||
|
<p>Thank you for your payment. Your membership has been successfully activated.</p>
|
||||||
|
<p>Please find attached your invoice for the payment.</p>
|
||||||
|
<p>Membership Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Gym: ${emailOptions.additionalData?.gymName || 'Fitlien'}</li>
|
||||||
|
<li>Plan: ${emailOptions.additionalData?.planName || 'Membership'}</li>
|
||||||
|
<li>Amount: ₹${emailOptions.additionalData?.amount || '0'}</li>
|
||||||
|
<li>Transaction ID: ${emailOptions.additionalData?.transactionId || 'N/A'}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
<li>Payment Method: ${emailOptions.additionalData?.paymentMethod || 'Online'}</li>
|
||||||
|
</ul>
|
||||||
|
<p>If you have any questions, please contact us.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
emailOptions.recipientEmail,
|
||||||
|
emailOptions.subject || 'Your Fitlien Membership Invoice',
|
||||||
|
emailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Invoice email sent to ${emailOptions.recipientEmail}`);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error sending invoice email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processInvoice(
|
||||||
|
membershipId: string,
|
||||||
|
paymentId: string,
|
||||||
|
invoiceData: InvoiceData,
|
||||||
|
emailOptions?: EmailOptions
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
invoicePath?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
emailSent: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const invoicePath = await this.generateInvoice(invoiceData);
|
||||||
|
|
||||||
|
const updateSuccess = await this.updateInvoicePath(membershipId, paymentId, invoicePath);
|
||||||
|
|
||||||
|
if (!updateSuccess) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
invoicePath,
|
||||||
|
emailSent: false,
|
||||||
|
error: 'Failed to update payment with invoice path'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = await this.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
|
||||||
|
let emailSent = false;
|
||||||
|
if (emailOptions && emailOptions.recipientEmail) {
|
||||||
|
emailSent = await this.sendInvoiceEmail(invoicePath, emailOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
invoicePath,
|
||||||
|
downloadUrl,
|
||||||
|
emailSent
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error processing invoice:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
emailSent: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
functions/src/payments/phonepe/invoice/processInvoice.ts
Normal file
83
functions/src/payments/phonepe/invoice/processInvoice.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
// import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
// import { Request } from "firebase-functions/v2/https";
|
||||||
|
// import { getCorsHandler } from "../../../shared/middleware";
|
||||||
|
// import { getAdmin, getLogger } from "../../../shared/config";
|
||||||
|
// import { InvoiceService } from "./invoiceService";
|
||||||
|
|
||||||
|
// const admin = getAdmin();
|
||||||
|
// const logger = getLogger();
|
||||||
|
// const corsHandler = getCorsHandler();
|
||||||
|
// const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
// export const processInvoice = onRequest({
|
||||||
|
// region: '#{SERVICES_RGN}#'
|
||||||
|
// }, async (request: Request, response) => {
|
||||||
|
// return corsHandler(request, response, async () => {
|
||||||
|
// try {
|
||||||
|
// const authHeader = request.headers.authorization || '';
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await admin.auth().verifyIdToken(idToken);
|
||||||
|
|
||||||
|
// const {
|
||||||
|
// membershipId,
|
||||||
|
// paymentId,
|
||||||
|
// invoiceData,
|
||||||
|
// emailOptions
|
||||||
|
// } = request.body;
|
||||||
|
|
||||||
|
// if (!membershipId || !paymentId || !invoiceData) {
|
||||||
|
// response.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Missing required fields'
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const result = await invoiceService.processInvoice(
|
||||||
|
// membershipId,
|
||||||
|
// paymentId,
|
||||||
|
// invoiceData,
|
||||||
|
// emailOptions
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!result.success) {
|
||||||
|
// response.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// error: result.error || 'Failed to process invoice'
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// response.json({
|
||||||
|
// success: true,
|
||||||
|
// message: 'Invoice processed successfully',
|
||||||
|
// invoicePath: result.invoicePath,
|
||||||
|
// downloadUrl: result.downloadUrl,
|
||||||
|
// emailSent: result.emailSent
|
||||||
|
// });
|
||||||
|
|
||||||
|
// } catch (authError: any) {
|
||||||
|
// logger.error('Authentication error:', authError);
|
||||||
|
// response.status(401).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Invalid authentication token',
|
||||||
|
// details: authError.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// } catch (error: any) {
|
||||||
|
// logger.error('Error processing invoice:', error);
|
||||||
|
// response.status(500).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Failed to process invoice',
|
||||||
|
// details: error.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// });
|
||||||
91
functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts
Normal file
91
functions/src/payments/phonepe/invoice/sendInvoiceEmail.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
// import { Request } from "firebase-functions/v2/https";
|
||||||
|
// import { getCorsHandler } from "../../../shared/middleware";
|
||||||
|
// import { getAdmin, getLogger } from "../../../shared/config";
|
||||||
|
// import { InvoiceService, EmailOptions } from "./invoiceService";
|
||||||
|
|
||||||
|
// const admin = getAdmin();
|
||||||
|
// const logger = getLogger();
|
||||||
|
// const corsHandler = getCorsHandler();
|
||||||
|
// const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
// export const sendInvoiceEmail = onRequest({
|
||||||
|
// region: '#{SERVICES_RGN}#'
|
||||||
|
// }, async (request: Request, response) => {
|
||||||
|
// return corsHandler(request, response, async () => {
|
||||||
|
// try {
|
||||||
|
// const authHeader = request.headers.authorization || '';
|
||||||
|
// if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
// response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const idToken = authHeader.split('Bearer ')[1];
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await admin.auth().verifyIdToken(idToken);
|
||||||
|
|
||||||
|
// const {
|
||||||
|
// invoicePath,
|
||||||
|
// recipientEmail,
|
||||||
|
// recipientName,
|
||||||
|
// subject,
|
||||||
|
// customHtml,
|
||||||
|
// gymName,
|
||||||
|
// planName,
|
||||||
|
// amount,
|
||||||
|
// transactionId,
|
||||||
|
// paymentDate,
|
||||||
|
// paymentMethod
|
||||||
|
// } = request.body;
|
||||||
|
|
||||||
|
// if (!invoicePath || !recipientEmail) {
|
||||||
|
// response.status(400).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Missing required fields'
|
||||||
|
// });
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const emailOptions: EmailOptions = {
|
||||||
|
// recipientEmail,
|
||||||
|
// recipientName,
|
||||||
|
// subject,
|
||||||
|
// customHtml,
|
||||||
|
// additionalData: {
|
||||||
|
// gymName,
|
||||||
|
// planName,
|
||||||
|
// amount,
|
||||||
|
// transactionId,
|
||||||
|
// paymentDate: paymentDate ? new Date(paymentDate) : undefined,
|
||||||
|
// paymentMethod
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const emailSent = await invoiceService.sendInvoiceEmail(invoicePath, emailOptions);
|
||||||
|
// const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
|
||||||
|
// response.json({
|
||||||
|
// success: true,
|
||||||
|
// message: emailSent ? 'Invoice email sent successfully' : 'Failed to send email but URL generated',
|
||||||
|
// downloadUrl
|
||||||
|
// });
|
||||||
|
|
||||||
|
// } catch (authError: any) {
|
||||||
|
// logger.error('Authentication error:', authError);
|
||||||
|
// response.status(401).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Invalid authentication token',
|
||||||
|
// details: authError.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// } catch (error: any) {
|
||||||
|
// logger.error('Error sending invoice email:', error);
|
||||||
|
// response.status(500).json({
|
||||||
|
// success: false,
|
||||||
|
// error: 'Failed to send invoice email',
|
||||||
|
// details: error.message
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// });
|
||||||
122
functions/src/payments/phonepe/paymentData.ts
Normal file
122
functions/src/payments/phonepe/paymentData.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
interface PaymentData {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
dateTimestamp: FirebaseFirestore.Timestamp;
|
||||||
|
amount: any;
|
||||||
|
paymentMethod: string;
|
||||||
|
referenceNumber: string;
|
||||||
|
discount: any;
|
||||||
|
transactionId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
invoicePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePaymentDataAfterSuccess(
|
||||||
|
merchantOrderId: string,
|
||||||
|
orderId: string,
|
||||||
|
paymentDetails: any,
|
||||||
|
invoicePath?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const orderQuery = await admin.firestore()
|
||||||
|
.collection('payment_orders')
|
||||||
|
.where('merchantOrderId', '==', merchantOrderId)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (orderQuery.empty) {
|
||||||
|
logger.error(`No payment order found with merchantOrderId: ${merchantOrderId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderDoc = orderQuery.docs[0];
|
||||||
|
const orderData = orderDoc.data();
|
||||||
|
|
||||||
|
const membershipId = orderData.metaInfo?.membershipId;
|
||||||
|
if (!membershipId) {
|
||||||
|
logger.error(`No membershipId found in metaInfo for order: ${merchantOrderId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isoDate = new Date().toLocaleDateString('en-GB').split('/').join('-');
|
||||||
|
const dateTimestamp = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
|
const paymentData: PaymentData = {
|
||||||
|
id: admin.firestore().collection('_').doc().id,
|
||||||
|
date: isoDate,
|
||||||
|
dateTimestamp: dateTimestamp,
|
||||||
|
amount: orderData.amount,
|
||||||
|
paymentMethod: 'Online',
|
||||||
|
referenceNumber: merchantOrderId,
|
||||||
|
discount: orderData.metaInfo?.discount || null,
|
||||||
|
transactionId: orderId,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (invoicePath) {
|
||||||
|
paymentData.invoicePath = invoicePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const membershipPaymentsRef = admin.firestore()
|
||||||
|
.collection('membership_payments')
|
||||||
|
.doc(membershipId);
|
||||||
|
|
||||||
|
const docSnapshot = await membershipPaymentsRef.get();
|
||||||
|
|
||||||
|
if (docSnapshot.exists) {
|
||||||
|
await membershipPaymentsRef.update({
|
||||||
|
'payments': admin.firestore.FieldValue.arrayUnion(paymentData),
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
'updatedBy': orderData.userId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await membershipPaymentsRef.set({
|
||||||
|
'membershipId': membershipId,
|
||||||
|
'payments': [paymentData],
|
||||||
|
'createdAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
'createdBy': orderData.userId,
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
'updatedBy': orderData.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateMembershipStatus(membershipId, orderData.userId);
|
||||||
|
|
||||||
|
logger.info(`Successfully updated payment data for membership: ${membershipId}`);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error updating payment data:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMembershipStatus(membershipId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const membershipDoc = await admin.firestore()
|
||||||
|
.collection('memberships')
|
||||||
|
.doc(membershipId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!membershipDoc.exists) {
|
||||||
|
throw new Error(`Membership not found for id: ${membershipId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await admin.firestore()
|
||||||
|
.collection('memberships')
|
||||||
|
.doc(membershipId)
|
||||||
|
.update({
|
||||||
|
'status': 'ACTIVE',
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Successfully updated membership status for: ${membershipId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error updating membership status:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
842
functions/src/payments/phonepe/webhook.ts
Normal file
842
functions/src/payments/phonepe/webhook.ts
Normal file
@ -0,0 +1,842 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import { getAdmin, getLogger } from "../../shared/config";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { updatePaymentDataAfterSuccess } from "./paymentData";
|
||||||
|
import { InvoiceService } from "./invoice/invoiceService";
|
||||||
|
import * as path from 'path';
|
||||||
|
import { sendEmailWithAttachmentUtil } from "../../utils/emailService";
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
const invoiceService = new InvoiceService();
|
||||||
|
|
||||||
|
export const phonePeWebhook = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
try {
|
||||||
|
logger.info('Received webhook request', {
|
||||||
|
headers: request.headers,
|
||||||
|
body: request.body,
|
||||||
|
method: request.method
|
||||||
|
});
|
||||||
|
|
||||||
|
const authHeader = request.headers['authorization'] as string;
|
||||||
|
const username = process.env.PHONEPE_WEBHOOK_USERNAME;
|
||||||
|
const password = process.env.PHONEPE_WEBHOOK_PASSWORD;
|
||||||
|
|
||||||
|
if (!authHeader || !username || !password) {
|
||||||
|
logger.error('Missing authorization header or webhook credentials');
|
||||||
|
response.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialString = `${username}:${password}`;
|
||||||
|
const expectedAuth = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(credentialString)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
const receivedAuth = authHeader.replace(/^SHA256\s+/i, '');
|
||||||
|
|
||||||
|
if (receivedAuth.toLowerCase() !== expectedAuth.toLowerCase()) {
|
||||||
|
logger.error('Invalid webhook authorization');
|
||||||
|
response.status(401).json({ error: 'Invalid authorization' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event, payload } = request.body;
|
||||||
|
|
||||||
|
if (!event || !payload || !payload.merchantOrderId || !payload.orderId) {
|
||||||
|
logger.error('Invalid webhook payload', request.body);
|
||||||
|
response.status(400).json({ error: 'Invalid payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Received PhonePe webhook: ${event}`, {
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
orderId: payload.orderId,
|
||||||
|
state: payload.state
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderQuery = await admin.firestore()
|
||||||
|
.collection('payment_orders')
|
||||||
|
.where('orderId', '==', payload.orderId)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
let orderDoc;
|
||||||
|
|
||||||
|
if (orderQuery.empty) {
|
||||||
|
const merchantOrderQuery = await admin.firestore()
|
||||||
|
.collection('payment_orders')
|
||||||
|
.where('merchantOrderId', '==', payload.merchantOrderId)
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (merchantOrderQuery.empty) {
|
||||||
|
logger.error(`No payment order found for PhonePe orderId: ${payload.orderId} or merchantOrderId: ${payload.merchantOrderId}`);
|
||||||
|
response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Payment order not found'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderDoc = merchantOrderQuery.docs[0];
|
||||||
|
await orderDoc.ref.update({
|
||||||
|
orderStatus: payload.state || 'UNKNOWN',
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
webhookEvent: event,
|
||||||
|
webhookData: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated order status via webhook for merchantOrderId: ${payload.merchantOrderId} to ${payload.state}`);
|
||||||
|
} else {
|
||||||
|
orderDoc = orderQuery.docs[0];
|
||||||
|
await orderDoc.ref.update({
|
||||||
|
orderStatus: payload.state || 'UNKNOWN',
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
webhookEvent: event,
|
||||||
|
webhookData: payload
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated order status via webhook for orderId: ${payload.orderId} to ${payload.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Checking payment state`, {
|
||||||
|
state: payload.state,
|
||||||
|
stateType: typeof payload.state,
|
||||||
|
stateLength: payload.state ? payload.state.length : 0,
|
||||||
|
stateUpperCase: payload.state ? payload.state.toUpperCase() : null,
|
||||||
|
stateComparison: payload.state === 'COMPLETED'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.state && payload.state.trim().toUpperCase() === 'COMPLETED') {
|
||||||
|
try {
|
||||||
|
logger.info(`Starting payment update process for merchantOrderId: ${payload.merchantOrderId}`);
|
||||||
|
|
||||||
|
const orderData = orderDoc.data();
|
||||||
|
const membershipId = orderData.metaInfo?.membershipId;
|
||||||
|
const bookingId = orderData.metaInfo?.bookingId;
|
||||||
|
const paymentId = orderData.metaInfo?.paymentId;
|
||||||
|
|
||||||
|
if (bookingId) {
|
||||||
|
await processDayPassBooking(payload, orderData, bookingId);
|
||||||
|
} else if (membershipId) {
|
||||||
|
const paymentUpdateSuccess = await updatePaymentDataAfterSuccess(
|
||||||
|
payload.merchantOrderId,
|
||||||
|
payload.orderId,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Payment update result for membershipId: ${membershipId}`, {
|
||||||
|
success: paymentUpdateSuccess,
|
||||||
|
orderId: payload.orderId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (paymentUpdateSuccess) {
|
||||||
|
await processMembershipPayment(payload, orderData, membershipId);
|
||||||
|
}
|
||||||
|
} else if (paymentId) {
|
||||||
|
await processServicePayment(payload, orderData, paymentId);
|
||||||
|
} else {
|
||||||
|
logger.error(`No membershipId, bookingId, or paymentId found in metaInfo for order: ${payload.merchantOrderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Payment data updated for completed payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (paymentUpdateError) {
|
||||||
|
logger.error('Error updating payment data:', paymentUpdateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(200).json({ success: true });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('PhonePe webhook processing error:', error);
|
||||||
|
response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to process webhook',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function processDayPassBooking(payload: any, orderData: any, bookingId: string) {
|
||||||
|
try {
|
||||||
|
logger.info(`Processing day pass booking for bookingId: ${bookingId}`);
|
||||||
|
|
||||||
|
const bookingRef = admin.firestore().collection('day_pass_bookings').doc(bookingId);
|
||||||
|
const bookingDoc = await bookingRef.get();
|
||||||
|
|
||||||
|
if (!bookingDoc.exists) {
|
||||||
|
logger.error(`Day pass booking not found for bookingId: ${bookingId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bookingRef.update({
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
paymentDetails: {
|
||||||
|
transactionId: payload.orderId,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
amount: orderData.amount,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'PhonePe'
|
||||||
|
},
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated day pass booking status to 'Accepted' for bookingId: ${bookingId}`);
|
||||||
|
|
||||||
|
const bookingData = bookingDoc.data();
|
||||||
|
const gymId = orderData.metaInfo?.gymId || bookingData?.gymId;
|
||||||
|
|
||||||
|
if (gymId) {
|
||||||
|
try {
|
||||||
|
const gymDoc = await admin.firestore().collection('gyms').doc(gymId).get();
|
||||||
|
let gymName = 'Fitlien';
|
||||||
|
let gymAddress = '';
|
||||||
|
let gymOwnerEmail = '';
|
||||||
|
|
||||||
|
if (gymDoc.exists) {
|
||||||
|
const gymData = gymDoc.data();
|
||||||
|
gymName = gymData?.name || 'Fitlien';
|
||||||
|
gymAddress = gymData?.address || '';
|
||||||
|
|
||||||
|
if (gymData?.userId) {
|
||||||
|
const gymOwnerDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(gymData.userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (gymOwnerDoc.exists) {
|
||||||
|
const gymOwnerData = gymOwnerDoc.data();
|
||||||
|
gymOwnerEmail = gymOwnerData?.email || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceNumber = `INV-${payload.merchantOrderId.substring(0, 8)}`;
|
||||||
|
|
||||||
|
logger.info(`Generated invoice number for day pass: ${invoiceNumber}`);
|
||||||
|
|
||||||
|
const invoiceData = {
|
||||||
|
invoiceNumber,
|
||||||
|
businessName: gymName,
|
||||||
|
address: gymAddress,
|
||||||
|
gstNumber: orderData.metaInfo?.gstNumber,
|
||||||
|
customerName: orderData.metaInfo?.customerName || bookingData?.customerName || '',
|
||||||
|
phoneNumber: orderData.metaInfo?.customerPhone || bookingData?.phoneNumber || '',
|
||||||
|
email: orderData.metaInfo?.customerEmail || bookingData?.email || '',
|
||||||
|
planName: 'Day Pass',
|
||||||
|
amount: orderData.amount,
|
||||||
|
transactionId: payload.orderId,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'Online'
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||||
|
logger.info(`Day pass invoice generated successfully at path: ${invoicePath}`);
|
||||||
|
|
||||||
|
await bookingRef.update({
|
||||||
|
invoicePath: invoicePath,
|
||||||
|
invoiceNumber: invoiceNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated day pass booking with invoice path: ${invoicePath}`);
|
||||||
|
|
||||||
|
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
const formattedDate = format(new Date(), 'dd/MM/yyyy');
|
||||||
|
|
||||||
|
if (gymOwnerEmail) {
|
||||||
|
logger.info(`Preparing to send day pass invoice email to gym owner: ${gymOwnerEmail}`);
|
||||||
|
try {
|
||||||
|
const ownerEmailSubject = `New Day Pass Payment - ${gymName}`;
|
||||||
|
|
||||||
|
const gymOwnerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>New Day Pass Payment Received</h2>
|
||||||
|
<p>Dear Gym Owner,</p>
|
||||||
|
<p>A new day pass payment has been received for your gym.</p>
|
||||||
|
<p>Customer Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name: ${invoiceData.customerName}</li>
|
||||||
|
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Day Pass Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Service: Day Pass</li>
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please find the invoice attached.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
gymOwnerEmail,
|
||||||
|
ownerEmailSubject,
|
||||||
|
gymOwnerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Day pass invoice email sent to gym owner (${gymOwnerEmail}) for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (ownerEmailError) {
|
||||||
|
logger.error('Error sending gym owner day pass invoice email:', ownerEmailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (invoiceError) {
|
||||||
|
logger.error('Error generating day pass invoice:', invoiceError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing day pass booking:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processMembershipPayment(payload: any, orderData: any, membershipId: string) {
|
||||||
|
logger.info(`Processing membership for completed payment`, {
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
orderId: payload.orderId,
|
||||||
|
membershipId: membershipId || 'not-provided'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (membershipId) {
|
||||||
|
try {
|
||||||
|
logger.info(`Fetching membership data for membershipId: ${membershipId}`);
|
||||||
|
const membershipDoc = await admin.firestore()
|
||||||
|
.collection('memberships')
|
||||||
|
.doc(membershipId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (membershipDoc.exists) {
|
||||||
|
logger.info(`Membership data retrieved successfully for membershipId: ${membershipId}`);
|
||||||
|
|
||||||
|
const membershipData = membershipDoc.data();
|
||||||
|
const uid = membershipData?.userId;
|
||||||
|
|
||||||
|
logger.info(`Fetching user data for uid(Client): ${uid}`);
|
||||||
|
const userDoc = await admin.firestore()
|
||||||
|
.collection('client_profiles')
|
||||||
|
.doc(uid)
|
||||||
|
.get();
|
||||||
|
if (userDoc.exists) {
|
||||||
|
logger.info(`User data retrieved successfully for uid(Client): ${uid}`);
|
||||||
|
|
||||||
|
logger.info(`Starting invoice generation process for payment: ${payload.merchantOrderId}`);
|
||||||
|
|
||||||
|
const userData = userDoc.data();
|
||||||
|
|
||||||
|
const gymId = orderData.metaInfo?.gymId || membershipData?.gymId;
|
||||||
|
let gymName = 'Fitlien';
|
||||||
|
let gymAddress = '';
|
||||||
|
let subscriptionName = '';
|
||||||
|
let gymOwnerEmail = '';
|
||||||
|
let paymentType = orderData.metaInfo?.paymentType || 'Gym Membership';
|
||||||
|
let trainerId = orderData.metaInfo?.trainerId;
|
||||||
|
let trainerData = null;
|
||||||
|
let emailCustomer = membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address'];
|
||||||
|
|
||||||
|
const discountPercentage = orderData.metaInfo?.discount || 0;
|
||||||
|
const hasDiscount = discountPercentage > 0;
|
||||||
|
const isFreeplan = discountPercentage === 100;
|
||||||
|
const originalAmount = hasDiscount ?
|
||||||
|
orderData.amount / (1 - discountPercentage / 100) :
|
||||||
|
orderData.amount;
|
||||||
|
const discountText = isFreeplan ?
|
||||||
|
" (Free Plan)" :
|
||||||
|
hasDiscount ? ` (${discountPercentage.toFixed(0)}% discount applied)` :
|
||||||
|
'';
|
||||||
|
const amountSaved = hasDiscount ?
|
||||||
|
originalAmount - orderData.amount :
|
||||||
|
0;
|
||||||
|
|
||||||
|
if (gymId) {
|
||||||
|
const gymDoc = await admin.firestore()
|
||||||
|
.collection('gyms')
|
||||||
|
.doc(gymId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (gymDoc.exists) {
|
||||||
|
const gymData = gymDoc.data();
|
||||||
|
gymName = gymData?.name || 'Fitlien';
|
||||||
|
gymAddress = gymData?.address || '';
|
||||||
|
subscriptionName = membershipData?.subscription?.normalizedName || '';
|
||||||
|
|
||||||
|
if (gymData?.userId) {
|
||||||
|
const gymOwnerDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(gymData.userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (gymOwnerDoc.exists) {
|
||||||
|
const gymOwnerData = gymOwnerDoc.data();
|
||||||
|
gymOwnerEmail = gymOwnerData?.email || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentType === 'Gym Membership with Personal Training' && trainerId) {
|
||||||
|
try {
|
||||||
|
const trainerDoc = await admin.firestore()
|
||||||
|
.collection('trainer_profiles')
|
||||||
|
.doc(trainerId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (trainerDoc.exists) {
|
||||||
|
trainerData = trainerDoc.data();
|
||||||
|
}
|
||||||
|
} catch (trainerError) {
|
||||||
|
logger.error('Error fetching trainer data:', trainerError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceNumber = `INV-${payload.merchantOrderId.substring(0, 8)}`;
|
||||||
|
|
||||||
|
logger.info(`Generated invoice number: ${invoiceNumber}`);
|
||||||
|
|
||||||
|
logger.info(`Preparing invoice data for generation`, {
|
||||||
|
invoiceNumber,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
gymName: gymName
|
||||||
|
});
|
||||||
|
const invoiceData = {
|
||||||
|
invoiceNumber,
|
||||||
|
businessName: gymName,
|
||||||
|
address: gymAddress,
|
||||||
|
gstNumber: userData?.gstNumber,
|
||||||
|
customerName: userData?.displayName || `${membershipData?.fields?.['first-name'] || ''} ${membershipData?.fields?.['last-name'] || ''}`.trim() || membershipData?.fields?.['First Name'] || '',
|
||||||
|
phoneNumber: membershipData?.fields?.['phone-number'] || membershipData?.fields?.['Phone Number'] || orderData.metaInfo?.phoneNumber || '',
|
||||||
|
email: membershipData?.fields?.['email'] || membershipData?.fields?.['Email Address'] || '',
|
||||||
|
planName: orderData.metaInfo?.planName || subscriptionName,
|
||||||
|
amount: orderData.amount,
|
||||||
|
transactionId: payload.orderId,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'Online'
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||||
|
logger.info(`Invoice generated successfully at path: ${invoicePath}`);
|
||||||
|
|
||||||
|
logger.info(`Updating membership payment with invoice path`, {
|
||||||
|
membershipId,
|
||||||
|
invoicePath
|
||||||
|
});
|
||||||
|
|
||||||
|
await admin.firestore()
|
||||||
|
.collection('membership_payments')
|
||||||
|
.doc(membershipId)
|
||||||
|
.get()
|
||||||
|
.then(async (doc) => {
|
||||||
|
if (doc.exists) {
|
||||||
|
logger.info(`Found membership payment document for membershipId: ${membershipId}`);
|
||||||
|
|
||||||
|
const paymentsData = doc.data()?.payments || [];
|
||||||
|
let paymentFound = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < paymentsData.length; i++) {
|
||||||
|
if (paymentsData[i].referenceNumber === payload.merchantOrderId ||
|
||||||
|
paymentsData[i].transactionId === payload.orderId) {
|
||||||
|
paymentsData[i].invoicePath = invoicePath;
|
||||||
|
paymentFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Payment record ${paymentFound ? 'found' : 'not found'} in membership payments`, {
|
||||||
|
membershipId,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
orderId: payload.orderId
|
||||||
|
});
|
||||||
|
|
||||||
|
await doc.ref.update({
|
||||||
|
'payments': paymentsData,
|
||||||
|
'updatedAt': admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Successfully updated membership payment with invoice path`, {
|
||||||
|
membershipId,
|
||||||
|
invoicePath
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.warn(`No membership payment document found for membershipId: ${membershipId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Generated invoice for payment: ${payload.merchantOrderId}, path: ${invoicePath}`);
|
||||||
|
|
||||||
|
logger.info(`Getting download URL for invoice: ${invoicePath}`);
|
||||||
|
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
logger.info(`Generated download URL for invoice: ${invoicePath}`);
|
||||||
|
|
||||||
|
const formattedDate = format(new Date(), 'dd/MM/yyyy');
|
||||||
|
|
||||||
|
if (emailCustomer) {
|
||||||
|
logger.info(`Preparing to send invoice email to customer: ${membershipData?.fields?.['email']}`);
|
||||||
|
try {
|
||||||
|
const emailSubject = isFreeplan
|
||||||
|
? `Free Plan Assigned - ${gymName}`
|
||||||
|
: `New Membership - ${gymName}`;
|
||||||
|
|
||||||
|
const customerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${isFreeplan ? 'Free Plan Assigned' : 'Thank you for your payment'}</h2>
|
||||||
|
<p>Dear ${invoiceData.customerName},</p>
|
||||||
|
<p>${isFreeplan ? 'Your free membership has been successfully activated.' : 'Thank you for your payment. Your membership has been successfully activated.'}</p>
|
||||||
|
<p>Please find attached your invoice for the ${isFreeplan ? 'membership' : 'payment'}.</p>
|
||||||
|
<p>Membership Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Gym: ${gymName}</li>
|
||||||
|
${trainerData ? `<li>Trainer: ${trainerData.fullName || 'Your Personal Trainer'}</li>` : ''}
|
||||||
|
<li>Plan: ${invoiceData.planName}</li>
|
||||||
|
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>You Save: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
${isFreeplan ? '<li>Payment Method: Online}</li>' : ''}
|
||||||
|
</ul>
|
||||||
|
<p>If you have any questions, please contact us.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
emailCustomer,
|
||||||
|
emailSubject,
|
||||||
|
customerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Invoice email sent to ${membershipData?.fields?.['email']} for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.error('Error sending customer invoice email:', emailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gymOwnerEmail) {
|
||||||
|
logger.info(`Preparing to send invoice email to gym owner: ${gymOwnerEmail}`);
|
||||||
|
try {
|
||||||
|
const ownerEmailSubject = isFreeplan
|
||||||
|
? `Free Plan Assigned${paymentType === 'Gym Membership with Personal Training' ? ' with Personal Training' : ''} - ${gymName}`
|
||||||
|
: `New Membership${paymentType === 'Gym Membership with Personal Training' ? ' with Personal Training' : ''} - ${gymName}`;
|
||||||
|
|
||||||
|
const gymOwnerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${isFreeplan ? 'Free Plan Assigned' : `New ${paymentType} Booking Received`}</h2>
|
||||||
|
<p>Dear Gym Owner,</p>
|
||||||
|
<p>${isFreeplan ? 'A free membership' : 'A new membership'}${paymentType === 'Gym Membership with Personal Training' ? ' with personal training' : ''} has been ${isFreeplan ? 'assigned' : 'received'} for your gym.</p>
|
||||||
|
<p>Customer Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name: ${invoiceData.customerName}</li>
|
||||||
|
<li>Email: ${invoiceData.email}</li>
|
||||||
|
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Booking Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Type: ${invoiceData.planName}</li>
|
||||||
|
${trainerData ? `<li>Trainer: ${trainerData.fullName || 'Personal Trainer'}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Amount Saved by Customer: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please find the invoice attached.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
gymOwnerEmail,
|
||||||
|
ownerEmailSubject,
|
||||||
|
gymOwnerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Invoice email sent to gym owner (${gymOwnerEmail}) for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (ownerEmailError) {
|
||||||
|
logger.error('Error sending gym owner invoice email:', ownerEmailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentType === 'Gym Membership with Personal Training' && trainerData && trainerData.email) {
|
||||||
|
try {
|
||||||
|
const trainerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>New Personal Training Client</h2>
|
||||||
|
<p>Dear ${trainerData.fullName || 'Trainer'},</p>
|
||||||
|
<p>A new client has signed up for personal training with you at ${gymName}.</p>
|
||||||
|
<p>Client Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name: ${invoiceData.customerName}</li>
|
||||||
|
<li>Email: ${invoiceData.email}</li>
|
||||||
|
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Booking Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Type: Personal Training Membership</li>
|
||||||
|
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please find the invoice attached.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
trainerData.email,
|
||||||
|
`New Personal Training Client - ${gymName}`,
|
||||||
|
trainerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Invoice email sent to trainer (${trainerData.email}) for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (trainerEmailError) {
|
||||||
|
logger.error('Error sending trainer invoice email:', trainerEmailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (invoiceError) {
|
||||||
|
logger.error('Error generating invoice:', invoiceError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processServicePayment(payload: any, orderData: any, paymentId: string) {
|
||||||
|
try {
|
||||||
|
logger.info(`Processing service payment for serviceId: ${paymentId}`);
|
||||||
|
|
||||||
|
const serviceRef = admin.firestore().collection('service_payments').doc(paymentId);
|
||||||
|
const serviceDoc = await serviceRef.get();
|
||||||
|
|
||||||
|
if (!serviceDoc.exists) {
|
||||||
|
logger.error(`Service booking not found for serviceId: ${paymentId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceData = serviceDoc.data();
|
||||||
|
|
||||||
|
if (serviceData?.status === 'ACCEPTED' && serviceData?.paymentDetails?.merchantOrderId) {
|
||||||
|
logger.warn(`Service payment already processed for serviceId: ${paymentId}, merchantOrderId: ${serviceData.paymentDetails.merchantOrderId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serviceData?.invoicePath && serviceData?.invoiceNumber) {
|
||||||
|
logger.warn(`Invoice already exists for serviceId: ${paymentId}, invoicePath: ${serviceData.invoicePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await serviceRef.update({
|
||||||
|
status: 'ACCEPTED',
|
||||||
|
paymentDetails: {
|
||||||
|
transactionId: payload.orderId,
|
||||||
|
merchantOrderId: payload.merchantOrderId,
|
||||||
|
amount: orderData.amount,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'PhonePe'
|
||||||
|
},
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated service booking status to 'CONFIRMED' for paymentId: ${paymentId}`);
|
||||||
|
|
||||||
|
const gymId = orderData.metaInfo?.gymId || serviceData?.gymId;
|
||||||
|
|
||||||
|
if (gymId) {
|
||||||
|
try {
|
||||||
|
const gymDoc = await admin.firestore().collection('gyms').doc(gymId).get();
|
||||||
|
let gymName = 'Fitlien';
|
||||||
|
let gymAddress = '';
|
||||||
|
let gymOwnerEmail = '';
|
||||||
|
|
||||||
|
if (gymDoc.exists) {
|
||||||
|
const gymData = gymDoc.data();
|
||||||
|
gymName = gymData?.name || 'Fitlien';
|
||||||
|
gymAddress = gymData?.address || '';
|
||||||
|
|
||||||
|
if (gymData?.userId) {
|
||||||
|
const gymOwnerDoc = await admin.firestore()
|
||||||
|
.collection('users')
|
||||||
|
.doc(gymData.userId)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (gymOwnerDoc.exists) {
|
||||||
|
const gymOwnerData = gymOwnerDoc.data();
|
||||||
|
gymOwnerEmail = gymOwnerData?.email || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceNumber = `INV-${payload.merchantOrderId.substring(0, 8)}`;
|
||||||
|
logger.info(`Generated invoice number for service: ${invoiceNumber}`);
|
||||||
|
|
||||||
|
const discountPercentage = orderData.metaInfo?.discount || 0;
|
||||||
|
const hasDiscount = discountPercentage > 0;
|
||||||
|
const isFreeService = discountPercentage === 100;
|
||||||
|
const originalAmount = hasDiscount ?
|
||||||
|
orderData.amount / (1 - discountPercentage / 100) :
|
||||||
|
orderData.amount;
|
||||||
|
const discountText = isFreeService ?
|
||||||
|
" (Free Service)" :
|
||||||
|
hasDiscount ? ` (${discountPercentage.toFixed(0)}% discount applied)` :
|
||||||
|
'';
|
||||||
|
const amountSaved = hasDiscount ?
|
||||||
|
originalAmount - orderData.amount :
|
||||||
|
0;
|
||||||
|
|
||||||
|
const invoiceData = {
|
||||||
|
invoiceNumber,
|
||||||
|
businessName: gymName,
|
||||||
|
address: gymAddress,
|
||||||
|
gstNumber: orderData.metaInfo?.gstNumber,
|
||||||
|
customerName: orderData.metaInfo?.customerName || serviceData?.normalizedName || '',
|
||||||
|
phoneNumber: orderData.metaInfo?.customerPhone || serviceData?.phoneNumber || '',
|
||||||
|
email: orderData.metaInfo?.customerEmail || serviceData?.email || '',
|
||||||
|
planName: orderData.metaInfo?.serviceName || serviceData?.serviceName || 'Service',
|
||||||
|
amount: orderData.amount,
|
||||||
|
transactionId: payload.orderId,
|
||||||
|
paymentDate: new Date(),
|
||||||
|
paymentMethod: 'Online'
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicePath = await invoiceService.generateInvoice(invoiceData);
|
||||||
|
logger.info(`Service invoice generated successfully at path: ${invoicePath}`);
|
||||||
|
|
||||||
|
await serviceRef.update({
|
||||||
|
invoicePath: invoicePath,
|
||||||
|
invoiceNumber: invoiceNumber
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Updated service booking with invoice path: ${invoicePath}`);
|
||||||
|
|
||||||
|
const downloadUrl = await invoiceService.getInvoiceDownloadUrl(invoicePath);
|
||||||
|
const formattedDate = format(new Date(), 'dd/MM/yyyy');
|
||||||
|
|
||||||
|
if (invoiceData.email) {
|
||||||
|
logger.info(`Preparing to send service invoice email to customer: ${invoiceData.email}`);
|
||||||
|
try {
|
||||||
|
const emailSubject = isFreeService
|
||||||
|
? `Free Service Confirmed - ${gymName}`
|
||||||
|
: `Service Booking Confirmed - ${gymName}`;
|
||||||
|
|
||||||
|
const customerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${isFreeService ? 'Free Service Confirmed' : 'Service Booking Confirmed'}</h2>
|
||||||
|
<p>Dear ${invoiceData.customerName},</p>
|
||||||
|
<p>${isFreeService ? 'Your free service has been successfully confirmed.' : 'Thank you for your payment. Your service booking has been confirmed.'}</p>
|
||||||
|
<p>Please find attached your invoice for the service.</p>
|
||||||
|
<p>Service Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Gym: ${gymName}</li>
|
||||||
|
<li>Service: ${invoiceData.planName}</li>
|
||||||
|
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>You Save: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
</ul>
|
||||||
|
<p>If you have any questions, please contact us.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
invoiceData.email,
|
||||||
|
emailSubject,
|
||||||
|
customerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Service invoice email sent to customer (${invoiceData.email}) for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (emailError) {
|
||||||
|
logger.error('Error sending customer service invoice email:', emailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gymOwnerEmail) {
|
||||||
|
logger.info(`Preparing to send service invoice email to gym owner: ${gymOwnerEmail}`);
|
||||||
|
try {
|
||||||
|
const ownerEmailSubject = isFreeService
|
||||||
|
? `Free Service Assigned - ${gymName}`
|
||||||
|
: `New Service Booking - ${gymName}`;
|
||||||
|
|
||||||
|
const gymOwnerEmailHtml = `
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h2>${isFreeService ? 'Free Service Assigned' : 'New Service Booking Received'}</h2>
|
||||||
|
<p>Dear Gym Owner,</p>
|
||||||
|
<p>${isFreeService ? 'A free service has been assigned' : 'A new service booking has been received'} for your gym.</p>
|
||||||
|
<p>Customer Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Name: ${invoiceData.customerName}</li>
|
||||||
|
<li>Phone: ${invoiceData.phoneNumber}</li>
|
||||||
|
<li>Email: ${invoiceData.email}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Service Details:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Service: ${invoiceData.planName}</li>
|
||||||
|
${hasDiscount ? `<li>Original Price: ₹${originalAmount.toFixed(2)}</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Discount: ${discountPercentage.toFixed(1)}%</li>` : ''}
|
||||||
|
${hasDiscount ? `<li>Amount Saved by Customer: ₹${amountSaved.toFixed(2)}</li>` : ''}
|
||||||
|
<li>Amount: ₹${orderData.amount.toFixed(2)}${discountText}</li>
|
||||||
|
<li>Transaction ID: ${payload.merchantOrderId}</li>
|
||||||
|
<li>Date: ${formattedDate}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Please find the invoice attached.</p>
|
||||||
|
<p>Regards,<br>Fitlien Team</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmailWithAttachmentUtil(
|
||||||
|
gymOwnerEmail,
|
||||||
|
ownerEmailSubject,
|
||||||
|
gymOwnerEmailHtml,
|
||||||
|
downloadUrl,
|
||||||
|
`Invoice_${path.basename(invoicePath)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Service invoice email sent to gym owner (${gymOwnerEmail}) for payment: ${payload.merchantOrderId}`);
|
||||||
|
} catch (ownerEmailError) {
|
||||||
|
logger.error('Error sending gym owner service invoice email:', ownerEmailError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (invoiceError) {
|
||||||
|
logger.error('Error generating service invoice:', invoiceError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing service payment:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
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';
|
||||||
9
functions/src/shared/config.ts
Normal file
9
functions/src/shared/config.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import * as admin from 'firebase-admin';
|
||||||
|
import * as logger from 'firebase-functions/logger';
|
||||||
|
|
||||||
|
if (!admin.apps.length) {
|
||||||
|
admin.initializeApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAdmin = () => admin;
|
||||||
|
export const getLogger = () => logger;
|
||||||
51
functions/src/shared/decrypt.ts
Normal file
51
functions/src/shared/decrypt.ts
Normal 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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
functions/src/shared/middleware.ts
Normal file
3
functions/src/shared/middleware.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
|
export const getCorsHandler = () => cors({ origin: true });
|
||||||
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
45
functions/src/storage/accessFile.ts
Normal file
45
functions/src/storage/accessFile.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { onRequest } from "firebase-functions/v2/https";
|
||||||
|
import { Request } from "firebase-functions/v2/https";
|
||||||
|
import * as path from 'path';
|
||||||
|
import { getCorsHandler } from "../shared/middleware";
|
||||||
|
import { getLogger, getAdmin } from "../shared/config";
|
||||||
|
|
||||||
|
const corsHandler = getCorsHandler();
|
||||||
|
const admin = getAdmin();
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
export const accessFile = onRequest({
|
||||||
|
region: '#{SERVICES_RGN}#'
|
||||||
|
}, async (request: Request, response) => {
|
||||||
|
return corsHandler(request, response, async () => {
|
||||||
|
try {
|
||||||
|
const filePath = request.query.path as string;
|
||||||
|
if (!filePath) {
|
||||||
|
response.status(400).send('File path is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationMs = 60 * 60 * 1000;
|
||||||
|
const bucket = admin.storage().bucket();
|
||||||
|
const file = bucket.file(filePath);
|
||||||
|
|
||||||
|
const [exists] = await file.exists();
|
||||||
|
if (!exists) {
|
||||||
|
response.status(404).send('File not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [signedUrl] = await file.getSignedUrl({
|
||||||
|
action: 'read',
|
||||||
|
expires: Date.now() + expirationMs,
|
||||||
|
responseDisposition: `attachment; filename="${path.basename(filePath)}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
response.redirect(signedUrl);
|
||||||
|
logger.info(`File access redirect for ${filePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error accessing file:', error);
|
||||||
|
response.status(500).send('Error accessing file');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
1
functions/src/storage/index.ts
Normal file
1
functions/src/storage/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { accessFile } from './accessFile';
|
||||||
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';
|
||||||
186
functions/src/utils/emailService.ts
Normal file
186
functions/src/utils/emailService.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
|
||||||
|
import { getLogger } from "../shared/config";
|
||||||
|
import { SESClient } from "@aws-sdk/client-ses";
|
||||||
|
import { SendEmailCommand, SendRawEmailCommand } from "@aws-sdk/client-ses";
|
||||||
|
import * as mime from 'mime-types';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const logger = getLogger();
|
||||||
|
|
||||||
|
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`;
|
||||||
|
|
||||||
|
rawMessage += `--${boundary}\n`;
|
||||||
|
rawMessage += `Content-Type: multipart/alternative; boundary="alt_${boundary}"\n\n`;
|
||||||
|
|
||||||
|
if (data.text) {
|
||||||
|
rawMessage += `--alt_${boundary}\n`;
|
||||||
|
rawMessage += `Content-Type: text/plain; charset=UTF-8\n\n`;
|
||||||
|
rawMessage += `${data.text}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
rawMessage += `--alt_${boundary}\n`;
|
||||||
|
rawMessage += `Content-Type: text/html; charset=UTF-8\n\n`;
|
||||||
|
rawMessage += `${data.html}\n\n`;
|
||||||
|
|
||||||
|
rawMessage += `--alt_${boundary}--\n\n`;
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function sendEmailWithAttachmentUtil(
|
||||||
|
toAddress: string,
|
||||||
|
subject: string,
|
||||||
|
message: string,
|
||||||
|
fileUrl: string,
|
||||||
|
fileName?: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
logger.info(`Sending email with attachment to: ${toAddress}`);
|
||||||
|
|
||||||
|
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: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fileUrl && fileName) {
|
||||||
|
logger.info(`Downloading attachment from URL: ${fileUrl}`);
|
||||||
|
try {
|
||||||
|
const fileContent = await downloadFileFromUrl(fileUrl);
|
||||||
|
|
||||||
|
data.attachments!.push({
|
||||||
|
filename: fileName,
|
||||||
|
content: fileContent,
|
||||||
|
contentType: mime.lookup(fileName) || 'application/octet-stream'
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Successfully downloaded attachment: ${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 Error('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];
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (data.attachments && data.attachments.length > 0) {
|
||||||
|
result = await sendEmailWithAttachments(data, recipients);
|
||||||
|
} else {
|
||||||
|
result = await sendSimpleEmail(data, recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Email sent successfully via SES');
|
||||||
|
return { success: true, result };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending email with attachment via SES:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,9 @@
|
|||||||
"outDir": "lib",
|
"outDir": "lib",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"target": "es2017"
|
"target": "es2017",
|
||||||
|
"types": [],
|
||||||
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"compileOnSave": true,
|
"compileOnSave": true,
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
72
package-lock.json
generated
72
package-lock.json
generated
@ -1,72 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "fitlien-services",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@types/busboy": "^1.5.4",
|
|
||||||
"busboy": "^1.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/long": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/busboy": {
|
|
||||||
"version": "1.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
|
|
||||||
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/long": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/long/-/long-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-eQs9RsucA/LNjnMoJvWG/nXa7Pot/RbBzilF/QRIU/xRl+0ApxrSUFsV5lmf01SvSlqMzJ7Zwxe440wmz2SJGA==",
|
|
||||||
"deprecated": "This is a stub types definition. long provides its own type definitions, so you do not need this installed.",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"long": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "22.10.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
|
|
||||||
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.20.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/busboy": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
||||||
"dependencies": {
|
|
||||||
"streamsearch": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/long": {
|
|
||||||
"version": "5.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz",
|
|
||||||
"integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/streamsearch": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "6.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
|
||||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"dependencies": {
|
|
||||||
"@types/busboy": "^1.5.4",
|
|
||||||
"busboy": "^1.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/long": "^5.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +1,9 @@
|
|||||||
rules_version = '2';
|
rules_version = '2';
|
||||||
|
|
||||||
// Craft rules based on data in your Firestore database
|
|
||||||
// allow write: if firestore.get(
|
|
||||||
// /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin;
|
|
||||||
service firebase.storage {
|
service firebase.storage {
|
||||||
match /b/{bucket}/o {
|
match /b/{bucket}/o {
|
||||||
match /{allPaths=**} {
|
match /{allPaths=**} {
|
||||||
allow read, write: if false;
|
allow read, write: if request.auth != null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user