diff --git a/.env.example b/.env.example index d706abe8b..f3d8797de 100755 --- a/.env.example +++ b/.env.example @@ -82,6 +82,7 @@ AZURE_CONNECTION_STRING= # Azure Storage connection string AZURE_INSIGHTS_CONNECTION_STRING= # Azure Application Insights connection string FEEDBACK_BLOB_CONTAINER= # Azure Blob container name for feedback storage AI_CONVERSATION_BLOB_CONTAINER= # Azure Blob container name for AI Conversation storage +DOWNGRADE_HUB_BLOB_CONTAINER= # Azure Blob container name for downgrade Hub Details storage. # [AI SERVICES] # Azure OpenAI Configuration diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml new file mode 100644 index 000000000..ca715fe42 --- /dev/null +++ b/.github/workflows/staging.yml @@ -0,0 +1,44 @@ +name: Staging +on: + push: + branches: + - release/2.34.0 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + + - uses: Azure/docker-login@v1 + with: + login-server: sparrowprod.azurecr.io + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - run: | + docker build . -t sparrowprod.azurecr.io/sparrow-api:${{ github.run_number }} --build-arg GITHUB_TOKEN=${{ secrets.SPARROW_GITHUB_TOKEN }} + docker push sparrowprod.azurecr.io/sparrow-api:${{ github.run_number }} + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - uses: richardrigutins/replace-in-files@v1 + with: + files: "./deploymentManifests/deployment.yml" + search-text: '_BUILD__ID_' + replacement-text: '${{ github.run_number }}' + + - uses: azure/setup-kubectl@v2.0 + + - uses: Azure/k8s-set-context@v2 + with: + kubeconfig: ${{ secrets.KUBE_CONFIG }} + + - uses: Azure/k8s-deploy@v4 + with: + action: deploy + namespace: sparrow-staging + manifests: | + ./deploymentManifests/deployment.yml diff --git a/package.json b/package.json index 0582506d7..e3622f0cf 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "crypto-js": "^4.2.0", "curlconverter": "^4.9.0", "dotenv": "16.0.1", + "exceljs": "^4.4.0", "fastify": "4.28.1", "form-data": "^4.0.1", "gravatar": "1.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3de31bf90..61249ec40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: dotenv: specifier: 16.0.1 version: 16.0.1 + exceljs: + specifier: ^4.4.0 + version: 4.4.0 fastify: specifier: 4.28.1 version: 4.28.1 @@ -230,7 +233,7 @@ importers: optionalDependencies: '@sparrowapp-dev/stripe-billing': specifier: ^1.3.1 - version: 1.3.1(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@types/node@16.11.56) + version: 1.3.1(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20)(@types/node@16.11.56) devDependencies: '@compodoc/compodoc': specifier: 1.1.19 @@ -1259,6 +1262,12 @@ packages: resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-csv/format@4.3.5': + resolution: {integrity: sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==} + + '@fast-csv/parse@4.3.6': + resolution: {integrity: sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==} + '@fastify/accept-negotiator@1.1.0': resolution: {integrity: sha512-OIHZrb2ImZ7XG85HXOONLcJWGosv7sIvM2ifAPQVhg9Lv7qdmMBNVaai4QTdyuaqbKM5eO6sLSQOYI7wEQeCJQ==} engines: {node: '>=14'} @@ -2556,6 +2565,9 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@14.18.63': + resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} + '@types/node@16.11.56': resolution: {integrity: sha512-aFcUkv7EddxxOa/9f74DINReQ/celqH8DiB3fRYgVDM2Xm5QJL8sl80QKuAnGvwAsMn+H3IFA6WCrQh1CY7m1A==} @@ -2839,6 +2851,18 @@ packages: resolution: {integrity: sha512-WzeSPqjut5NiQfZ68+k7i//bWzLdTZvN6BvwiiTaytlsmMVZrfIGozwLVPPtiH48faFVDm5LbZjmol4QUcn3Mg==} engines: {node: '>=8.0.0'} + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2876,6 +2900,9 @@ packages: async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2960,6 +2987,10 @@ packages: bcryptjs@2.4.3: resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -2967,12 +2998,18 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binary@0.3.0: + resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bintrees@1.0.2: resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.4.7: + resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} + blueimp-md5@2.19.0: resolution: {integrity: sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==} @@ -3015,18 +3052,29 @@ packages: resolution: {integrity: sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==} engines: {node: '>=14.20.1'} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-indexof-polyfill@1.0.2: + resolution: {integrity: sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==} + engines: {node: '>=0.10'} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + buffers@0.1.1: + resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} + engines: {node: '>=0.2.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3076,6 +3124,9 @@ packages: resolution: {integrity: sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==} engines: {node: '>=4'} + chainsaw@0.1.0: + resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3195,6 +3246,10 @@ packages: component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3241,6 +3296,9 @@ packages: core-js-compat@3.45.1: resolution: {integrity: sha512-tqTt5T4PzsMIZ430XGviK4vzYSoeNJ6CXODi6c/voxOT6IZqBht5/EKaSNnYiEjjRYxjVz7DQIsOsY0XNi8PIA==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.5: resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} engines: {node: '>= 0.10'} @@ -3249,6 +3307,15 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + create-jest@29.7.0: resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3294,6 +3361,9 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3476,6 +3546,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3668,6 +3741,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + exceljs@4.4.0: + resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==} + engines: {node: '>=8.3.0'} + execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} engines: {node: '>=10'} @@ -3709,6 +3786,10 @@ packages: fast-copy@3.0.2: resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} + fast-csv@4.3.6: + resolution: {integrity: sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==} + engines: {node: '>=10.0.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -3851,6 +3932,9 @@ packages: from@0.1.7: resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3867,6 +3951,11 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fstream@1.0.12: + resolution: {integrity: sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==} + engines: {node: '>=0.6'} + deprecated: This package is no longer supported. + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -4114,6 +4203,9 @@ packages: engines: {node: '>=16.x'} hasBin: true + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -4326,6 +4418,9 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -4565,6 +4660,9 @@ packages: jssha@3.3.1: resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==} + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + jwa@1.4.2: resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} @@ -4584,6 +4682,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4595,6 +4697,9 @@ packages: libphonenumber-js@1.12.15: resolution: {integrity: sha512-TMDCtIhWUDHh91wRC+wFuGlIzKdPzaTUHHVrIZ3vPUEoNaXFLrsIQ1ZpAeZeXApIF6rvDksMTvjrIQlLKaYxqQ==} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + light-my-request@5.14.0: resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} @@ -4613,6 +4718,9 @@ packages: engines: {node: ^16.14.0 || >=18.0.0} hasBin: true + listenercount@1.0.1: + resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} + listr2@6.6.1: resolution: {integrity: sha512-+rAXGHh0fkEWdXBmX+L6mmfmXmXvDGEKzkjxO+8mP3+nI/r/CWznVBvsibXdxda9Zz0OW2e2ikphN3OwCT/jSg==} engines: {node: '>=16.0.0'} @@ -4636,19 +4744,44 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.groupby@4.6.0: + resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isfunction@3.0.9: + resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} + lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + lodash.isnil@4.0.0: + resolution: {integrity: sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==} + lodash.isnumber@3.0.3: resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} @@ -4658,6 +4791,9 @@ packages: lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.isundefined@3.0.1: + resolution: {integrity: sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} @@ -4667,6 +4803,12 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -5105,6 +5247,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5311,6 +5456,9 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@3.0.0: resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} @@ -5394,6 +5542,9 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -5402,6 +5553,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -5507,6 +5661,11 @@ packages: rhea@3.0.4: resolution: {integrity: sha512-n3kw8syCdrsfJ72w3rohpoHHlmv/RZZEP9VY5BVjjo0sEGIt4YSKypBgaiA+OUSgJAzLjOECYecsclG5xbYtZw==} + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -5560,6 +5719,10 @@ packages: sax@1.4.1: resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + saxes@5.0.1: + resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} + engines: {node: '>=10'} + secure-json-parse@2.7.0: resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} @@ -5606,6 +5769,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.1.0: resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} @@ -5777,6 +5943,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -5853,6 +6022,10 @@ packages: resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} engines: {node: ^14.18.0 || >=16.0.0} + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -5876,6 +6049,10 @@ packages: resolution: {integrity: sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==} engines: {node: '>=12'} + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -5902,6 +6079,9 @@ packages: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} + traverse@0.3.9: + resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==} + traverse@0.6.11: resolution: {integrity: sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==} engines: {node: '>= 0.4'} @@ -6095,6 +6275,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + unzipper@0.10.14: + resolution: {integrity: sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==} + update-browserslist-db@1.1.3: resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} hasBin: true @@ -6278,6 +6461,9 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xmldoc@2.0.2: resolution: {integrity: sha512-UiRwoSStEXS3R+YE8OqYv3jebza8cBBAI2y8g3B15XFkn3SbEOyyLnmPHjLBPZANrPJKEzxxB7A3XwcLikQVlQ==} engines: {node: '>=12.0.0'} @@ -6336,6 +6522,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + snapshots: '@aduh95/viz.js@3.4.0': {} @@ -7892,6 +8082,25 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@fast-csv/format@4.3.5': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.isboolean: 3.0.3 + lodash.isequal: 4.5.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + + '@fast-csv/parse@4.3.6': + dependencies: + '@types/node': 14.18.63 + lodash.escaperegexp: 4.1.2 + lodash.groupby: 4.6.0 + lodash.isfunction: 3.0.9 + lodash.isnil: 4.0.0 + lodash.isundefined: 3.0.1 + lodash.uniq: 4.5.0 + '@fastify/accept-negotiator@1.1.0': {} '@fastify/ajv-compiler@3.6.0': @@ -8388,23 +8597,6 @@ snapshots: '@nestjs/core': 10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6) cron: 4.3.3 - '@nestjs/swagger@7.4.2(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)': - dependencies: - '@microsoft/tsdoc': 0.15.1 - '@nestjs/common': 10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6) - '@nestjs/core': 10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6) - '@nestjs/mapped-types': 2.0.5(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13) - js-yaml: 4.1.0 - lodash: 4.17.21 - path-to-regexp: 3.3.0 - reflect-metadata: 0.1.13 - swagger-ui-dist: 5.17.14 - optionalDependencies: - '@fastify/static': 6.12.0 - class-transformer: 0.5.1 - class-validator: 0.14.2 - optional: true - '@nestjs/swagger@7.4.2(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)': dependencies: '@microsoft/tsdoc': 0.15.1 @@ -9531,13 +9723,13 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@sparrowapp-dev/stripe-billing@1.3.1(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@types/node@16.11.56)': + '@sparrowapp-dev/stripe-billing@1.3.1(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20)(@types/node@16.11.56)': dependencies: '@nestjs/common': 10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6) '@nestjs/config': 3.3.0(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(rxjs@7.8.2) '@nestjs/core': 10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6) '@nestjs/platform-express': 10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20) - '@nestjs/swagger': 7.4.2(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/platform-express@10.4.20)(@nestjs/websockets@10.4.20)(reflect-metadata@0.1.13)(rxjs@7.5.6))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13) + '@nestjs/swagger': 7.4.2(@fastify/static@6.12.0)(@nestjs/common@10.1.3(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13)(rxjs@7.5.6))(@nestjs/core@10.4.20)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.13) class-transformer: 0.5.1 class-validator: 0.14.2 reflect-metadata: 0.1.13 @@ -9693,6 +9885,8 @@ snapshots: dependencies: '@types/node': 16.11.56 + '@types/node@14.18.63': {} + '@types/node@16.11.56': {} '@types/nodemailer-express-handlebars@4.0.5': @@ -10054,6 +10248,42 @@ snapshots: transitivePeerDependencies: - supports-color + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + arg@4.1.3: {} argparse@1.0.10: @@ -10092,6 +10322,8 @@ snapshots: dependencies: retry: 0.13.1 + async@3.2.6: {} + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -10210,10 +10442,17 @@ snapshots: bcryptjs@2.4.3: {} + big-integer@1.6.52: {} + bignumber.js@9.3.1: {} binary-extensions@2.3.0: {} + binary@0.3.0: + dependencies: + buffers: 0.1.1 + chainsaw: 0.1.0 + bintrees@1.0.2: {} bl@4.1.0: @@ -10222,6 +10461,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bluebird@3.4.7: {} + blueimp-md5@2.19.0: {} body-parser@1.20.3: @@ -10280,10 +10521,14 @@ snapshots: bson@5.5.1: {} + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} + buffer-indexof-polyfill@1.0.2: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -10294,6 +10539,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffers@0.1.1: {} + bundle-name@4.1.0: dependencies: run-applescript: 7.0.0 @@ -10345,6 +10592,10 @@ snapshots: pathval: 1.1.1 type-detect: 4.1.0 + chainsaw@0.1.0: + dependencies: + traverse: 0.3.9 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10469,6 +10720,13 @@ snapshots: component-emitter@1.3.1: {} + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -10515,6 +10773,8 @@ snapshots: dependencies: browserslist: 4.25.4 + core-util-is@1.0.3: {} + cors@2.8.5: dependencies: object-assign: 4.1.1 @@ -10528,6 +10788,13 @@ snapshots: path-type: 4.0.0 yaml: 1.10.2 + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + create-jest@29.7.0(@types/node@16.11.56)(ts-node@10.9.1(@types/node@16.11.56)(typescript@5.7.2)): dependencies: '@jest/types': 29.6.3 @@ -10597,6 +10864,8 @@ snapshots: dateformat@4.6.3: {} + dayjs@1.11.18: {} + debug@2.6.9: dependencies: ms: 2.0.0 @@ -10739,6 +11008,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -10998,6 +11271,18 @@ snapshots: events@3.3.0: {} + exceljs@4.4.0: + dependencies: + archiver: 5.3.2 + dayjs: 1.11.18 + fast-csv: 4.3.6 + jszip: 3.10.1 + readable-stream: 3.6.2 + saxes: 5.0.1 + tmp: 0.2.5 + unzipper: 0.10.14 + uuid: 8.3.2 + execa@4.1.0: dependencies: cross-spawn: 7.0.6 @@ -11097,6 +11382,11 @@ snapshots: fast-copy@3.0.2: {} + fast-csv@4.3.6: + dependencies: + '@fast-csv/format': 4.3.5 + '@fast-csv/parse': 4.3.6 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -11271,6 +11561,8 @@ snapshots: from@0.1.7: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -11288,6 +11580,13 @@ snapshots: fsevents@2.3.3: optional: true + fstream@1.0.12: + dependencies: + graceful-fs: 4.2.11 + inherits: 2.0.4 + mkdirp: 0.5.6 + rimraf: 2.7.1 + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -11569,6 +11868,8 @@ snapshots: image-size@2.0.2: {} + immediate@3.0.6: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -11761,6 +12062,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -12189,6 +12492,13 @@ snapshots: jssha@3.3.1: {} + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + jwa@1.4.2: dependencies: buffer-equal-constant-time: 1.0.1 @@ -12217,6 +12527,10 @@ snapshots: kleur@3.0.3: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leven@3.1.0: {} levn@0.4.1: @@ -12226,6 +12540,10 @@ snapshots: libphonenumber-js@1.12.15: {} + lie@3.3.0: + dependencies: + immediate: 3.0.6 + light-my-request@5.14.0: dependencies: cookie: 0.7.2 @@ -12258,6 +12576,8 @@ snapshots: - enquirer - supports-color + listenercount@1.0.1: {} + listr2@6.6.1: dependencies: cli-truncate: 3.1.0 @@ -12279,26 +12599,48 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + lodash.get@4.4.2: {} + lodash.groupby@4.6.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} + lodash.isequal@4.5.0: {} + + lodash.isfunction@3.0.9: {} + lodash.isinteger@4.0.4: {} + lodash.isnil@4.0.0: {} + lodash.isnumber@3.0.3: {} lodash.isplainobject@4.0.6: {} lodash.isstring@4.0.1: {} + lodash.isundefined@3.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} lodash.once@4.1.1: {} + lodash.union@4.6.0: {} + + lodash.uniq@4.5.0: {} + lodash@4.17.21: {} log-symbols@4.1.0: @@ -12440,7 +12782,6 @@ snapshots: mkdirp@0.5.6: dependencies: minimist: 1.2.8 - optional: true mkdirp@1.0.4: {} @@ -12682,6 +13023,8 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -12907,6 +13250,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + process-nextick-args@2.0.1: {} + process-warning@3.0.0: {} process-warning@4.0.1: {} @@ -12989,6 +13334,16 @@ snapshots: react-is@18.3.1: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -13003,6 +13358,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -13116,6 +13475,10 @@ snapshots: transitivePeerDependencies: - supports-color + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -13172,6 +13535,10 @@ snapshots: sax@1.4.1: {} + saxes@5.0.1: + dependencies: + xmlchars: 2.2.0 + secure-json-parse@2.7.0: {} semver@6.3.1: {} @@ -13261,6 +13628,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setimmediate@1.0.5: {} + setprototypeof@1.1.0: {} setprototypeof@1.2.0: {} @@ -13465,6 +13834,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -13538,6 +13911,14 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tdigest@0.1.2: dependencies: bintrees: 1.0.2 @@ -13562,6 +13943,8 @@ snapshots: tiny-lru@11.4.5: {} + tmp@0.2.5: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -13580,6 +13963,8 @@ snapshots: dependencies: punycode: 2.3.1 + traverse@0.3.9: {} + traverse@0.6.11: dependencies: gopd: 1.2.0 @@ -13784,6 +14169,19 @@ snapshots: unpipe@1.0.0: {} + unzipper@0.10.14: + dependencies: + big-integer: 1.6.52 + binary: 0.3.0 + bluebird: 3.4.7 + buffer-indexof-polyfill: 1.0.2 + duplexer2: 0.1.4 + fstream: 1.0.12 + graceful-fs: 4.2.11 + listenercount: 1.0.1 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: browserslist: 4.25.4 @@ -13958,6 +14356,8 @@ snapshots: dependencies: is-wsl: 3.1.0 + xmlchars@2.2.0: {} + xmldoc@2.0.2: dependencies: sax: 1.4.1 @@ -14015,3 +14415,9 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 diff --git a/src/modules/billing/billing.module.ts b/src/modules/billing/billing.module.ts index b59f8113f..9e7ee668f 100644 --- a/src/modules/billing/billing.module.ts +++ b/src/modules/billing/billing.module.ts @@ -22,6 +22,11 @@ import { UserRepository } from "@src/modules/identity/repositories/user.reposito import { PricingService } from "@src/modules/workspace/services/pricing.repository"; import { PricingRepository } from "@src/modules/workspace/repositories/pricing.repository"; import { SalesEmailRepository } from "../workspace/repositories/sales-email.repository"; +import { DownGradeTeamRepository } from "./repositories/downgradeTeam.repository"; +import { DownGradeUserRepository } from "./repositories/downgradeUser.repository"; +import { DownGradeWorkspaceRepository } from "./repositories/downgradeWorkspace.repository"; +import { DownGradeService } from "./services/downgrade.service"; +import { ExcelEmailService } from "./services/excel-email.service"; // Try to import the Stripe module, but don't crash if it's not available let StripeModule: any; @@ -48,6 +53,11 @@ export class BillingModule { BillingAuditRepository, PromoCodeRepository, PricingRepository, + DownGradeTeamRepository, + DownGradeUserRepository, + DownGradeWorkspaceRepository, + DownGradeService, + ExcelEmailService, BillingAuditService, PromoCodeService, PricingService, @@ -73,6 +83,11 @@ export class BillingModule { BillingAuditRepository, BillingAuditService, PromoCodeService, + DownGradeTeamRepository, + DownGradeUserRepository, + DownGradeWorkspaceRepository, + DownGradeService, + ExcelEmailService, PricingService, PaymentEmailService, PaymentEmailHelper, diff --git a/src/modules/billing/controllers/stripe.controller.ts b/src/modules/billing/controllers/stripe.controller.ts index ce9eaaa7d..e3b248583 100644 --- a/src/modules/billing/controllers/stripe.controller.ts +++ b/src/modules/billing/controllers/stripe.controller.ts @@ -49,6 +49,7 @@ import { SalesEmailRepository } from "@src/modules/workspace/repositories/sales- import { ConfigService } from "@nestjs/config"; import { TrialType } from "@src/modules/common/enum/trial.enum"; import { PricingPlan } from "@src/modules/common/models/pricing.model"; +import { DownGradeService } from "../services/downgrade.service"; // Dynamically import Stripe services let StripeService: any; @@ -72,6 +73,7 @@ export class StripeController { private readonly pricingService: PricingService, private readonly configService: ConfigService, private readonly salesEmailRepository: SalesEmailRepository, + private readonly downgradeService: DownGradeService, ) { this.isStripeAvailable = !!this.stripeService; @@ -295,10 +297,10 @@ export class StripeController { for (const planBilling of currentPlan.billing) { if (planBilling.providers?.stripe === createSubscriptionDto.priceId) { selectedPlan = currentPlan; - break; + break; } } - if (selectedPlan) break; + if (selectedPlan) break; } if (selectedPlan) { // Replace or set planName @@ -423,6 +425,13 @@ export class StripeController { updateSubscriptionDto.seats, updateSubscriptionDto.paymentBehavior, ); + if (subscription && updateSubscriptionDto?.workspaces) { + await this.downgradeService.addDowgradeDetails( + updateSubscriptionDto?.metadata?.hubId, + updateSubscriptionDto?.workspaces, + updateSubscriptionDto?.users, + ); + } return subscription; } catch (error) { @@ -459,12 +468,17 @@ export class StripeController { ): Promise { try { this.checkStripeAvailability(); - const subscription = await this.stripeService.cancelSubscription( subscriptionId, false, //disables cancellation at mid cycle ); - + if (subscription) { + await this.downgradeService.addDowgradeDetails( + cancelSubscriptionDto.teamId, + cancelSubscriptionDto.workspaces, + cancelSubscriptionDto.users, + ); + } return { subscription }; } catch (error) { throw new HttpException( @@ -509,7 +523,11 @@ export class StripeController { subscriptionId, reactivateDto.metadata, ); - + if (subscription) { + await this.downgradeService.removeDowngradeDetails( + reactivateDto.metadata?.hubId, + ); + } return { subscription }; } catch (error) { throw new HttpException( diff --git a/src/modules/billing/helpers/payment-email.helper.ts b/src/modules/billing/helpers/payment-email.helper.ts index 4c4d08776..741921a8a 100644 --- a/src/modules/billing/helpers/payment-email.helper.ts +++ b/src/modules/billing/helpers/payment-email.helper.ts @@ -6,6 +6,10 @@ import { } from "../services/payment-email.service"; import { StripeCustomerService } from "../services/stripe-customer.service"; import { PlanName } from "@src/modules/common/enum/plan.enum"; +import { + UserExcelDto, + WorkspaceExcelDto, +} from "../payloads/downgrade-user.payload"; // Dynamically import payment methods service let PaymentMethodsService: any; @@ -187,6 +191,64 @@ export class PaymentEmailHelper { } } + /** + * Send plan downgraded emails to existing users in Hub. + */ + async sendHubDowngradedEmail( + team: any, + startDate: Date, + previousPlan: string, + newPlan: string, + sendEmails: string[], + workspaces: WorkspaceExcelDto[], + users: UserExcelDto[], + ): Promise { + try { + const emailData = await this.buildPlanDowngradeEmailData( + team, + startDate, + previousPlan, + newPlan, + ); + if (!emailData) return; + const updateEmailData = { ...emailData, sendEmails, workspaces, users }; + await this.paymentEmailService.sendPaymentEmail( + PaymentEmailType.HUB_DOWNGRADED, + updateEmailData, + ); + } catch (error) { + console.error("Error sending plan downgraded email:", error); + } + } + + /** + * Send plan downgraded emails to Removed users in Hub. + */ + async sendHubDowngradeRemoveUserEmail( + team: any, + startDate: Date, + previousPlan: string, + newPlan: string, + sendEmails: string[], + ): Promise { + try { + const emailData = await this.buildPlanDowngradeEmailData( + team, + startDate, + previousPlan, + newPlan, + ); + if (!emailData) return; + const updateEmailData = { ...emailData, sendEmails }; + await this.paymentEmailService.sendPaymentEmail( + PaymentEmailType.HUB_DOWNGRADED_REMOVE_USER, + updateEmailData, + ); + } catch (error) { + console.error("Error sending plan downgraded email:", error); + } + } + /** * Send upcoming payment email using customer's default payment method email */ diff --git a/src/modules/billing/helpers/stripe-webhook.helper.ts b/src/modules/billing/helpers/stripe-webhook.helper.ts index 1a12449aa..efed9e815 100644 --- a/src/modules/billing/helpers/stripe-webhook.helper.ts +++ b/src/modules/billing/helpers/stripe-webhook.helper.ts @@ -95,10 +95,13 @@ export class StripeWebhookHelper { * Handle subscription updated webhook event */ private async handleSubscriptionUpdated(event: any): Promise { + // Check for resubscription (subscription reactivated) + const isResubscribed = this.detectResubscription(event); // Only handle for specific status changes, like cancellation await this.stripeSubscriptionService.handleSubscriptionUpdated( event.data.object, event.id, + isResubscribed ); // Get the updated team data @@ -106,8 +109,6 @@ export class StripeWebhookHelper { event.data.object.metadata?.hubId, ); - // Check for resubscription (subscription reactivated) - const isResubscribed = this.detectResubscription(event); if (isResubscribed && teamUpdated) { // Send resubscription email await this.paymentEmailHelper.sendSubscriptionResubscribedEmail( diff --git a/src/modules/billing/payloads/downgrade-user.payload.ts b/src/modules/billing/payloads/downgrade-user.payload.ts new file mode 100644 index 000000000..a96a8f715 --- /dev/null +++ b/src/modules/billing/payloads/downgrade-user.payload.ts @@ -0,0 +1,131 @@ +import { IsNotEmpty, IsMongoId } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { + IsString, + IsOptional, + IsEmail, + IsArray, + ValidateNested, + IsBoolean, + IsDate, +} from "class-validator"; +import { Type } from "class-transformer"; +import { ObjectId } from "mongodb"; + +export class DowngradeUserDto { + @ApiProperty({ example: "64f03af32e420f7f68055b92" }) + @IsMongoId() + @IsNotEmpty() + teamId: string; + + @ApiProperty({ example: "64f03af32e420f7f68055b92" }) + @IsMongoId() + @IsNotEmpty() + userIds: string[]; +} + +export class TourGuideDto { + @IsBoolean() + @IsOptional() + isRequestTestsNoCodeDemoCompleted?: boolean; + + @IsBoolean() + @IsOptional() + isGenerateVariableDemoCompleted?: boolean; + + @IsBoolean() + @IsOptional() + isRequestTestsScriptDemoCompleted?: boolean; + + @IsBoolean() + @IsOptional() + isRequestAssertionsDemoCompleted?: boolean; +} + +class UserWorkspaceDto { + @IsMongoId() + @IsNotEmpty() + workspaceId: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsMongoId() + @IsNotEmpty() + teamId: string; + + @IsBoolean() + @IsOptional() + isNewInvite?: boolean; +} + +class TeamDto { + @IsMongoId() + @IsNotEmpty() + id: ObjectId; + + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + role: string; + + @IsBoolean() + @IsOptional() + isNewInvite?: boolean; + + @IsOptional() + @IsDate() + joinedAt?: Date; +} + +class DownGradeUserDto { + @IsString() + @IsOptional() + name?: string; + + @IsEmail() + @IsOptional() + email?: string; + + @IsArray() + @IsOptional() + @Type(() => TeamDto) + teams?: TeamDto[]; + + @IsArray() + @Type(() => UserWorkspaceDto) + @IsOptional() + @ValidateNested({ each: true }) + workspaces?: UserWorkspaceDto[]; +} + +class DownGradeUserGenerateVariableDto extends DownGradeUserDto { + @IsArray() + @IsString({ each: true }) + @Type(() => String) + isGenerateVariableTrial?: string[]; +} + +export class DownGradeUserTourGuideDto extends DownGradeUserGenerateVariableDto { + @IsOptional() + @ValidateNested() + @Type(() => TourGuideDto) + tourGuide?: TourGuideDto; +} + +export interface WorkspaceExcelDto { + name: string; + created_at: string; + collections: number; + testflow: number; +} + +export interface UserExcelDto { + name: string; + email: string; +} + diff --git a/src/modules/billing/payloads/stripe.payload.ts b/src/modules/billing/payloads/stripe.payload.ts index 8bac5b331..90558e288 100644 --- a/src/modules/billing/payloads/stripe.payload.ts +++ b/src/modules/billing/payloads/stripe.payload.ts @@ -1,4 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { + downgradeUser, + downgradeWorkspace, +} from "@src/modules/common/models/billing.model"; import { IsEmail, IsString, @@ -7,6 +11,8 @@ import { IsBoolean, IsNotEmpty, IsNumber, + IsArray, + ValidateNested, } from "class-validator"; export class CreateCustomerDto { @@ -199,6 +205,28 @@ export class UpdateSubscriptionDto { @IsNumber() seats?: number; paymentBehavior?: "default_incomplete" | "allow_incomplete"; + + @ApiPropertyOptional({ + description: "List of workspaces to downgrade", + example: [ + { id: "workspace-id-1", name: "Workspace One" }, + { id: "workspace-id-2", name: "Workspace Two" }, + ], + }) + @IsOptional() + @IsArray() + workspaces?: downgradeWorkspace[]; + + @ApiPropertyOptional({ + description: "List of users to downgrade", + example: [ + { id: "user-id-1", email: "example1@gmail.com" }, + { id: "user-id-2", email: "example2@gmail.com" }, + ], + }) + @IsOptional() + @IsArray() + users?: downgradeUser[]; } export class CancelSubscriptionDto { @@ -210,6 +238,26 @@ export class CancelSubscriptionDto { @IsOptional() @IsBoolean() cancelImmediately?: boolean; + + @ApiPropertyOptional({ + description: "team Id", + example: "688731d965594536e3ffdce1", + }) + @IsOptional() + @IsString() + teamId?: string; + + @IsOptional() + @IsArray() + workspaces?: downgradeWorkspace[]; + + @ApiPropertyOptional({ + description: "List of user IDs to downgrade", + example: ["example@gmail.com"], + }) + @IsOptional() + @IsArray() + users?: downgradeUser[]; } export class ReactivateSubscriptionDto { diff --git a/src/modules/billing/repositories/downgradeTeam.repository.ts b/src/modules/billing/repositories/downgradeTeam.repository.ts new file mode 100644 index 000000000..ab784aeea --- /dev/null +++ b/src/modules/billing/repositories/downgradeTeam.repository.ts @@ -0,0 +1,33 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Collections } from "@src/modules/common/enum/database.collection.enum"; +import { Team, TeamDto } from "@src/modules/common/models/team.model"; +import { Db } from "mongodb"; +import { ObjectId, WithId } from "mongodb"; + +/** + * Repository for handling promo code database operations + */ +@Injectable() +export class DownGradeTeamRepository { + constructor(@Inject("DATABASE_CONNECTION") private db: Db) {} + + async findTeamByTeamId(id: ObjectId): Promise> { + const teamData = await this.db + .collection(Collections.TEAM) + .findOne({ _id: id }); + return teamData; + } + + async updateTeamById( + id: ObjectId, + updateParams: Partial, + ): Promise> { + const updatedTeamParams = { + $set: updateParams, + }; + const responseData = await this.db + .collection(Collections.TEAM) + .findOneAndUpdate({ _id: id }, updatedTeamParams); + return responseData.value; + } +} diff --git a/src/modules/billing/repositories/downgradeUser.repository.ts b/src/modules/billing/repositories/downgradeUser.repository.ts new file mode 100644 index 000000000..09c1cdb73 --- /dev/null +++ b/src/modules/billing/repositories/downgradeUser.repository.ts @@ -0,0 +1,94 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Db } from "mongodb"; +import { ObjectId, WithId } from "mongodb"; +import { User, UserWorkspaceDto } from "@src/modules/common/models/user.model"; +import { Collections } from "@src/modules/common/enum/database.collection.enum"; +import { DownGradeUserTourGuideDto } from "../payloads/downgrade-user.payload"; +import { TeamDto } from "@src/modules/common/models/team.model"; + +/** + * Repository for handling promo code database operations + */ +@Injectable() +export class DownGradeUserRepository { + constructor(@Inject("DATABASE_CONNECTION") private db: Db) {} + async findUserByUserId(id: ObjectId): Promise> { + const userData = await this.db + .collection(Collections.USER) + .findOne({ _id: id }); + return userData; + } + + async updateUserById( + id: ObjectId, + updateParams: Partial, + ): Promise> { + const updatedUserParams = { + $set: updateParams, + }; + const responseData = await this.db + .collection(Collections.USER) + .findOneAndUpdate({ _id: id }, updatedUserParams); + return responseData.value; + } + + async updateUserTeamsAndWorkspaces( + userId: ObjectId, + updatedTeams: TeamDto[], + updatedWorkspaces: UserWorkspaceDto[], + ): Promise> { + if (!userId) { + throw new BadRequestException("User ID is required."); + } + if (!Array.isArray(updatedTeams)) { + throw new BadRequestException("updatedTeams must be an array."); + } + if (!Array.isArray(updatedWorkspaces)) { + throw new BadRequestException("updatedWorkspaces must be an array."); + } + const updatePayload: Partial = { + teams: updatedTeams, + workspaces: updatedWorkspaces, + updatedAt: new Date(), + }; + try { + const result = await this.db + .collection(Collections.USER) + .findOneAndUpdate( + { _id: userId }, + { $set: updatePayload }, + { returnDocument: "after" }, + ); + if (!result.value) { + throw new BadRequestException("User not found."); + } + return result.value; + } catch (error) { + console.error("Error updating user teams and workspaces:", error); + } + } + + async findUsersByIdArray(IdArray: Array): Promise[]> { + const response = await this.db + .collection(Collections.USER) + .find({ _id: { $in: IdArray } }) + .toArray(); + return response; + } + + async findUsersByStringIds(ids: string[]): Promise[]> { + try { + const objectIds = ids.map((id) => new ObjectId(id)); + const users = await this.db + .collection(Collections.USER) + .find({ _id: { $in: objectIds } }) + .toArray(); + if (!users || users.length === 0) { + return; + } + return users; + } catch (error) { + console.error("Error fetching users by string IDs:", error); + } + } +} diff --git a/src/modules/billing/repositories/downgradeWorkspace.repository.ts b/src/modules/billing/repositories/downgradeWorkspace.repository.ts new file mode 100644 index 000000000..5a0c40492 --- /dev/null +++ b/src/modules/billing/repositories/downgradeWorkspace.repository.ts @@ -0,0 +1,106 @@ +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Collections } from "@src/modules/common/enum/database.collection.enum"; +import { AdminDto, UserDto, Workspace } from "@src/modules/common/models/workspace.model"; +import { Db, DeleteResult } from "mongodb"; +import { ObjectId, WithId } from "mongodb"; + +/** + * Repository for handling promo code database operations + */ +@Injectable() +export class DownGradeWorkspaceRepository { + constructor(@Inject("DATABASE_CONNECTION") private db: Db) {} + + async get(id: string): Promise> { + const _id = new ObjectId(id); + const data = await this.db + .collection(Collections.WORKSPACE) + .findOne({ _id }); + if (!data) { + throw new BadRequestException("Not Found"); + } + return data; + } + + async getWorkspacesByIds(ids: string[]): Promise[]> { + if (!ids || ids.length === 0) { + throw new BadRequestException("workspaceIds are required."); + } + const objectIds = ids.map((id) => new ObjectId(id)); + try { + const data = await this.db + .collection(Collections.WORKSPACE) + .find({ _id: { $in: objectIds } }) + .toArray(); + if (!data || data.length === 0) { + throw new BadRequestException("No workspaces found."); + } + return data; + } catch (error) { + console.error("Error fetching workspaces:", error); + throw new BadRequestException("Failed to fetch workspaces."); + } + } + + delete(id: string): Promise { + const _id = new ObjectId(id); + return this.db + .collection(Collections.WORKSPACE) + .deleteOne({ _id }); + } + + async setWorkspaceRestriction( + workspaceId: string, + isRestricted: boolean, + ): Promise> { + const _id = new ObjectId(workspaceId); + const result = await this.db + .collection(Collections.WORKSPACE) + .findOneAndUpdate( + { _id }, + { $set: { isRestricted, updatedAt: new Date() } }, + { returnDocument: "after" }, + ); + if (!result.value) { + throw new BadRequestException("Workspace not found or update failed"); + } + return result.value; + } + + async updateWorkspaceUsers( + workspaceId: string, + updatedUsers: UserDto[], + updatedAdmins?: AdminDto[], + ): Promise { + if (!workspaceId) { + throw new BadRequestException("workspaceId is required."); + } + if (!Array.isArray(updatedUsers)) { + throw new BadRequestException("updatedUsers must be an array."); + } + if (updatedAdmins && !Array.isArray(updatedAdmins)) { + throw new BadRequestException( + "updatedAdmins must be an array when provided.", + ); + } + const _id = new ObjectId(workspaceId); + const updatePayload: Partial = { + users: updatedUsers, + updatedAt: new Date(), + }; + if (updatedAdmins) { + updatePayload.admins = updatedAdmins; + } + try { + const result = await this.db + .collection(Collections.WORKSPACE) + .updateOne({ _id }, { $set: updatePayload }); + if (result.matchedCount === 0) { + throw new BadRequestException("Workspace not found."); + } + return result; + } catch (error) { + console.error("Error updating workspace users/admins:", error); + } + } +} diff --git a/src/modules/billing/repositories/stripe-subscription.repository.ts b/src/modules/billing/repositories/stripe-subscription.repository.ts index ec60d728d..e19a2bacb 100644 --- a/src/modules/billing/repositories/stripe-subscription.repository.ts +++ b/src/modules/billing/repositories/stripe-subscription.repository.ts @@ -3,6 +3,7 @@ import { Collections } from "@src/modules/common/enum/database.collection.enum"; import { BillingType, PaymentProvider, + SubscriptionDowngradeType, SubscriptionStatus, } from "@src/modules/common/enum/billing.enum"; import { Db, ObjectId, UpdateResult } from "mongodb"; @@ -107,14 +108,12 @@ export class StripeSubscriptionRepository { */ async findTeamByCustomerId(customerId: string): Promise { try { - return await this.db - .collection(Collections.TEAM) - .findOne({ - $or: [ - { "billing.customerId": customerId }, - { "billing.paymentProviders.customerId": customerId } - ] - }); + return await this.db.collection(Collections.TEAM).findOne({ + $or: [ + { "billing.customerId": customerId }, + { "billing.paymentProviders.customerId": customerId }, + ], + }); } catch (error) { throw error; } @@ -248,4 +247,152 @@ export class StripeSubscriptionRepository { throw error; } } + + /** + * Add downgrade details (workspaces and users) to a team's downgrade record. + * This function updates the downgrade section of a team document by appending + * workspace objects to `downgrade.workspaces` and user objects to + * `downgrade.users`. Duplicate entries are automatically avoided + * using MongoDB's `$addToSet` operator. + * @param teamId The unique identifier of the team whose downgrade record will be updated. + * @param workspaces Array of workspace objects {id, name} to mark for downgrade. + * @param users Array of user objects {id, email} to mark for downgrade. + * @param downgradeType Type of downgrade (manual or auto) + * @returns MongoDB UpdateResult indicating the success or failure of the update. + */ + async addDowngradeDetails( + teamId: string, + workspaces: Array<{ id: string; name: string }>, + users: Array<{ id: string; email: string }>, + downgradeType?: SubscriptionDowngradeType, + ): Promise { + try { + if (!teamId) { + throw new Error("teamId is required to update downgrade details."); + } + const updateQuery: Record = {}; + // Add workspaces if provided + if (workspaces && workspaces.length > 0) { + updateQuery["downgrade.workspaces"] = { $each: workspaces }; + } + // Add users if provided + if (users && users.length > 0) { + updateQuery["downgrade.users"] = { $each: users }; + } + if (Object.keys(updateQuery).length === 0) { + throw new Error("No workspaces or users provided to update."); + } + const teamObjectId = new ObjectId(teamId); + const setQuery: Record = { + updatedBy: "system", + updatedAt: new Date(), + }; + // Set downgrade type if provided + if (downgradeType) { + setQuery["downgrade.downgradeType"] = downgradeType; + } + const result = await this.db.collection(Collections.TEAM).updateOne( + { _id: teamObjectId }, + { + $addToSet: updateQuery, + $set: setQuery, + }, + ); + return result; + } catch (error) { + console.error("Error adding downgrade details:", error); + throw error; + } + } + + /** + * Remove downgrade details from a team + * Removes the entire downgrade object from the team document + * @param teamId The team ID to update + * @returns MongoDB UpdateResult + */ + async removeDowngradeDetails(teamId: string): Promise { + try { + if (!teamId) { + throw new Error("teamId is required to remove downgrade details."); + } + const teamObjectId = new ObjectId(teamId); + const result = await this.db.collection(Collections.TEAM).updateOne( + { _id: teamObjectId }, + { + $unset: { + downgrade: "", // Removes the entire downgrade object + }, + $set: { + updatedBy: "system", + updatedAt: new Date(), + }, + }, + ); + return result; + } catch (error) { + console.error("Error removing downgrade details:", error); + throw error; + } + } + + /** + * Enable automated downgrade for a team + * Sets downgrade type to auto and optionally updates plan active state + * @param teamId - The team ID to update + * @returns UpdateResult from MongoDB + */ + async enableAutoDowngrade(teamId: string): Promise { + try { + if (!teamId) { + throw new Error("teamId is required to enable auto downgrade."); + } + const teamObjectId = new ObjectId(teamId); + const setQuery: Record = { + "downgrade.downgradeType": SubscriptionDowngradeType.AUTOMATIC, + "plan.active": false, + updatedBy: "system", + updatedAt: new Date(), + }; + const result = await this.db + .collection(Collections.TEAM) + .updateOne({ _id: teamObjectId }, { $set: setQuery }); + return result; + } catch (error) { + console.error("Error enabling auto downgrade:", error); + throw error; + } + } + + /** + * Disable automated downgrade for a team + * Clears downgrade type and sets plan to active + * @param teamId - The team ID to update + * @returns UpdateResult from MongoDB + */ + async disableAutoDowngrade(teamId: string): Promise { + try { + if (!teamId) { + throw new Error("teamId is required to disable auto downgrade."); + } + const teamObjectId = new ObjectId(teamId); + const result = await this.db.collection(Collections.TEAM).updateOne( + { _id: teamObjectId }, + { + $unset: { + "downgrade.downgradeType": "", + }, + $set: { + "plan.active": true, + updatedBy: "system", + updatedAt: new Date(), + }, + }, + ); + return result; + } catch (error) { + console.error("Error disabling auto downgrade:", error); + throw error; + } + } } diff --git a/src/modules/billing/services/downgrade.service.ts b/src/modules/billing/services/downgrade.service.ts new file mode 100644 index 000000000..cbf74c4f8 --- /dev/null +++ b/src/modules/billing/services/downgrade.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from "@nestjs/common"; +import { TeamRole } from "@src/modules/common/enum/roles.enum"; +import { Team } from "@src/modules/common/models/team.model"; +import { DeleteResult, ObjectId, WithId } from "mongodb"; +import { DowngradeUserDto } from "../payloads/downgrade-user.payload"; +import { DownGradeTeamRepository } from "../repositories/downgradeTeam.repository"; +import { DownGradeUserRepository } from "../repositories/downgradeUser.repository"; +import { LicenseManagementService } from "./license-management.service"; +import { DownGradeWorkspaceRepository } from "../repositories/downgradeWorkspace.repository"; +import { isString } from "class-validator"; +import { SubscriptionDowngradeType } from "@src/modules/common/enum/billing.enum"; +import { StripeSubscriptionRepository } from "../repositories/stripe-subscription.repository"; + +@Injectable() +export class DownGradeService { + constructor( + private readonly downgradeTeamRepository: DownGradeTeamRepository, + private readonly downgradeUserRepository: DownGradeUserRepository, + private readonly licenseManagementService: LicenseManagementService, + private readonly downgradeWorkspaceReposiory: DownGradeWorkspaceRepository, + private readonly stripeSubscriptionRepository: StripeSubscriptionRepository, + ) {} + + async removeUserFromTeam(payload: DowngradeUserDto): Promise> { + const teamFilter = new ObjectId(payload.teamId); + const teamData = + await this.downgradeTeamRepository.findTeamByTeamId(teamFilter); + const teamUsersData = teamData.users; + const teamAdminsData = teamData.admins; + const updateTeamUsersData = teamUsersData.filter( + (user) => !payload.userIds.includes(user.id), + ); + const updateAdminUserData = teamAdminsData.filter( + (admin) => !payload.userIds.includes(admin), + ); + const workspaces = teamData.workspaces; + for (let workspace of workspaces) { + const workspaceData = await this.downgradeWorkspaceReposiory.get( + workspace.id.toString(), + ); + const updatedUsers = workspaceData.users.filter( + (user) => !payload.userIds.includes(user.id), + ); + const updatedAdmins = workspaceData.admins.filter( + (admin) => !payload.userIds.includes(admin.id), + ); + await this.downgradeWorkspaceReposiory.updateWorkspaceUsers( + workspace.id.toString(), + updatedUsers, + updatedAdmins, + ); + } + for (let user of payload.userIds) { + const userObject = new ObjectId(user); + const userData = + await this.downgradeUserRepository.findUserByUserId(userObject); + const updateUserTeams = userData.teams.filter( + (usr) => usr.id.toString() != payload.teamId, + ); + const updateUserWorkspaces = userData.workspaces.filter( + (workspace) => + !workspaces.some((w) => w.id.toString() === workspace.workspaceId), + ); + await this.downgradeUserRepository.updateUserTeamsAndWorkspaces( + userObject, + updateUserTeams, + updateUserWorkspaces, + ); + } + const teamUpdatedParams = { + users: updateTeamUsersData, + admins: updateAdminUserData, + }; + const data = await this.downgradeTeamRepository.updateTeamById( + teamFilter, + teamUpdatedParams, + ); + // Update license tracking after user removal + await this.licenseManagementService.updateLicenseTracking(payload.teamId); + return data; + } + + /** + * Get owner details by ID. + * @param {string} ownerId - The ID of the owner. + * @param {any[]} users - Array of users. + * @returns {Promise<{ email: string; name: string } | null>} Owner details or null if not found. + */ + async getOwnerDetails( + ownerId: string, + users: any[], + ): Promise<{ email: string; name: string } | null> { + for (const user of users) { + if (user.id.toString() === ownerId.toString()) { + return { email: user.email, name: user.name }; + } + } + return null; + } + + /** + * Restrict a specific workspace by its ID. + * + * @param id - The unique identifier of the workspace to restrict. + */ + async restrictWorkspace(id: string): Promise { + const response = + await this.downgradeWorkspaceReposiory.setWorkspaceRestriction(id, true); + return response; + } + + /** + * Add downgrade details for a specific team. + * @param teamId - The unique identifier of the team being downgraded. + * @param workspaces - An array of workspace objects (each containing `id` and `name`) + * associated with the downgrade. + * @param users - An array of user objects (each containing `id` and `email`) + * affected by the downgrade. + * associated workspaces and users. + */ + async addDowgradeDetails( + teamId: string, + workspaces: Array<{ id: string; name: string }>, + users: Array<{ id: string; email: string }>, + ) { + try { + await this.stripeSubscriptionRepository.addDowngradeDetails( + teamId, + workspaces, + users, + SubscriptionDowngradeType.MANUAL, + ); + } catch (error) { + console.log("Error in add Downgrade", error); + } + } + + /** + * Remove existing downgrade details for a team. + * + * @param teamId - The unique identifier of the team whose downgrade details are to be removed. + */ + async removeDowngradeDetails(teamId: string) { + try { + await this.stripeSubscriptionRepository.removeDowngradeDetails(teamId); + } catch (error) { + console.log("Error in Remove Downgrade", error); + } + } + + /** + * unRestrict a specific Hub. + * @param id - The unique identifier of the workspace to restrict. + * @returns A promise resolving to the repository response after setting the restriction. + **/ + async unRestrictWorkpsaces(team: Team) { + try { + const workspaces = team.workspaces; + for (let workspace of workspaces) { + await this.downgradeWorkspaceReposiory.setWorkspaceRestriction( + workspace.id.toString(), + false, + ); + } + } catch (error) { + console.log("Error in Removing restricted Workspaces." + error); + } + } +} diff --git a/src/modules/billing/services/excel-email.service.ts b/src/modules/billing/services/excel-email.service.ts new file mode 100644 index 000000000..4a30d6515 --- /dev/null +++ b/src/modules/billing/services/excel-email.service.ts @@ -0,0 +1,157 @@ +// src/excel/excel.service.ts +import { Injectable } from "@nestjs/common"; +import * as ExcelJS from "exceljs"; +import { + UserExcelDto, + WorkspaceExcelDto, +} from "../payloads/downgrade-user.payload"; + +@Injectable() +export class ExcelEmailService { + /** + * Generate downgrade summary Excel with deleted workspaces and members + */ + async generateDowngradeSummaryExcel( + workspaces: WorkspaceExcelDto[], + users: UserExcelDto[], + ): Promise { + const workbook = new ExcelJS.Workbook(); + // Set workbook properties + workbook.creator = "Sparrow"; + workbook.created = new Date(); + workbook.modified = new Date(); + // ==================== Deleted Workspaces Sheet ==================== + const workspacesSheet = workbook.addWorksheet("Deleted Workspaces", { + properties: { tabColor: { argb: "FF316CF6" } }, + }); + // Define columns for workspaces + workspacesSheet.columns = [ + { header: "Workspace Name", key: "name", width: 35 }, + { header: "Created At", key: "created_at", width: 20 }, + { header: "Collections", key: "collections", width: 15 }, + { header: "Test Flows", key: "testflow", width: 15 }, + ]; + // Style header row for workspaces + const workspaceHeaderRow = workspacesSheet.getRow(1); + workspaceHeaderRow.font = { + bold: true, + color: { argb: "FFFFFFFF" }, + size: 12, + }; + workspaceHeaderRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF316CF6" }, + }; + workspaceHeaderRow.alignment = { + vertical: "middle", + horizontal: "center", + }; + workspaceHeaderRow.height = 25; + // Add border to header + workspaceHeaderRow.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + // Add workspace data + workspaces.forEach((workspace, index) => { + const row = workspacesSheet.addRow({ + name: workspace.name, + created_at: workspace.created_at || "N/A", + collections: workspace.collections, + testflow: workspace.testflow, + }); + // Alternate row colors for better readability + if (index % 2 === 0) { + row.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFF8F9FA" }, + }; + } + // Add borders to data cells + row.eachCell((cell) => { + cell.border = { + top: { style: "thin", color: { argb: "FFE0E0E0" } }, + left: { style: "thin", color: { argb: "FFE0E0E0" } }, + bottom: { style: "thin", color: { argb: "FFE0E0E0" } }, + right: { style: "thin", color: { argb: "FFE0E0E0" } }, + }; + cell.alignment = { vertical: "middle" }; + }); + }); + // ==================== Deleted Members Sheet ==================== + const membersSheet = workbook.addWorksheet("Deleted Members", { + properties: { tabColor: { argb: "FF316CF6" } }, + }); + // Define columns for members + membersSheet.columns = [ + { header: "Name", key: "name", width: 30 }, + { header: "Email", key: "email", width: 40 }, + { header: "Role", key: "role", width: 15 }, + ]; + // Style header row for members + const memberHeaderRow = membersSheet.getRow(1); + memberHeaderRow.font = { + bold: true, + color: { argb: "FFFFFFFF" }, + size: 12, + }; + memberHeaderRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FF316CF6" }, + }; + memberHeaderRow.alignment = { + vertical: "middle", + horizontal: "center", + }; + memberHeaderRow.height = 25; + + // Add border to header + memberHeaderRow.eachCell((cell) => { + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + }; + }); + + // Add member data + users.forEach((user, index) => { + const row = membersSheet.addRow({ + name: user.name, + email: user.email, + }); + + // Alternate row colors + if (index % 2 === 0) { + row.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFF8F9FA" }, + }; + } + + // Add borders to data cells + row.eachCell((cell) => { + cell.border = { + top: { style: "thin", color: { argb: "FFE0E0E0" } }, + left: { style: "thin", color: { argb: "FFE0E0E0" } }, + bottom: { style: "thin", color: { argb: "FFE0E0E0" } }, + right: { style: "thin", color: { argb: "FFE0E0E0" } }, + }; + cell.alignment = { vertical: "middle" }; + }); + }); + + // Generate and return buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } +} diff --git a/src/modules/billing/services/payment-email.service.ts b/src/modules/billing/services/payment-email.service.ts index 9ec2294a0..253ce944c 100644 --- a/src/modules/billing/services/payment-email.service.ts +++ b/src/modules/billing/services/payment-email.service.ts @@ -1,6 +1,8 @@ import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { EmailService } from "@src/modules/common/services/email.service"; +import { ExcelEmailService } from "./excel-email.service"; +import { BlobStorageService } from "@src/modules/common/services/blobStorage.service"; export enum PaymentEmailType { PAYMENT_SUCCESS = "payment_success", @@ -9,6 +11,8 @@ export enum PaymentEmailType { SUBSCRIPTION_RESUBSCRIBED = "subscription_resubscribed", PLAN_UPGRADED = "plan_upgraded", PLAN_DOWNGRADED = "plan_downgraded", + HUB_DOWNGRADED = "hub_downgraded", + HUB_DOWNGRADED_REMOVE_USER = "hub_downgrade_remove_user", UPCOMING_PAYMENT = "upcoming_payment", SUBSCRIPTION_EXPIRED = "subscription_expired", PAYMENT_INFO_UPDATED = "payment_info_updated", @@ -43,6 +47,9 @@ export interface PaymentEmailData { usedSeats?: number; invitedSeats?: number; manageUsersUrl?: string; + workspaces?: any; + users?: any; + sendEmails?: string[]; } @Injectable() @@ -50,6 +57,8 @@ export class PaymentEmailService { constructor( private readonly emailService: EmailService, private readonly configService: ConfigService, + private readonly excelEmailService: ExcelEmailService, + private readonly blobStorageService: BlobStorageService, ) {} /** @@ -81,6 +90,12 @@ export class PaymentEmailService { case PaymentEmailType.PLAN_DOWNGRADED: await this.sendPlanDowngradedEmail(data); break; + case PaymentEmailType.HUB_DOWNGRADED: + await this.sendHubDowngradedEmail(data); + break; + case PaymentEmailType.HUB_DOWNGRADED_REMOVE_USER: + await this.sendHubDowngradeRemovedUserEmail(data); + break; case PaymentEmailType.UPCOMING_PAYMENT: await this.sendUpcomingPaymentActionRequiredEmail(data); break; @@ -330,6 +345,91 @@ export class PaymentEmailService { await this.emailService.sendEmail(transporter, mailOptions); } + /** + * Send hub downgraded email + */ + private async sendHubDowngradedEmail(data: PaymentEmailData): Promise { + // Generate Excel buffer + const excelBuffer = + await this.excelEmailService.generateDowngradeSummaryExcel( + data.workspaces, + data.users, + ); + // Upload to Azure Blob Storage and get URL + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + const storageName = `Downgrade_Summary_${data.hubName}_${timestamp}`; + const downloadName = `Downgrade_Summary_${data.hubName}`; + const mimetype = ".xlsx"; + const blobResult = await this.blobStorageService.uploadExcelBlob( + excelBuffer, + storageName, + downloadName, + mimetype, + ); + console.log("---------------this is the blob result---->", blobResult); + // Send emails to all recipients + const transporter = this.emailService.createTransporter(); + for (const email of data.sendEmails) { + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: email, + text: "Hub Downgraded", + template: "hubDowngradedEmail", + context: { + firstName: this.extractFirstName(data.ownerName), + hubName: data.hubName, + previousPlanName: data.previousPlanName || "Previous Plan", + newPlanName: data.planName, + effectiveDate: this.formatDate(data.billingPeriodStart), + excelDownloadUrl: blobResult.fileUrl, + sparrowEmail: this.configService.get("support.sparrowEmail"), + sparrowWebsite: this.configService.get("support.sparrowWebsite"), + sparrowWebsiteName: this.configService.get( + "support.sparrowWebsiteName", + ), + }, + subject: `Your Plan for ${data.hubName} has been updated to ${data.planName}`, + }; + await this.emailService.sendEmail(transporter, mailOptions); + } + } + + /** + * Send a Hub downgrade Email after the user is removed from the Hub + */ + private async sendHubDowngradeRemovedUserEmail( + data: PaymentEmailData, + ): Promise { + for (let i = 0; i < data.sendEmails.length; i++) { + const transporter = this.emailService.createTransporter(); + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: data.sendEmails[i], + text: "Hub Downgraded", + template: "hubDowngradeRemoveUserEmail", + context: { + firstName: this.extractFirstName(data.ownerName), + hubName: data.hubName, + previousPlanName: data.previousPlanName || "Previous Plan", + newPlanName: data.planName, + effectiveDate: data.billingPeriodStart + ? this.formatDate(data.billingPeriodStart) + : this.formatDate(data.billingPeriodStart), + sparrowEmail: this.configService.get("support.sparrowEmail"), + sparrowWebsite: this.configService.get("support.sparrowWebsite"), + sparrowWebsiteName: this.configService.get( + "support.sparrowWebsiteName", + ), + }, + subject: `Your Plan for ${data.hubName} has been updated to ${data.planName}`, + }; + await this.emailService.sendEmail(transporter, mailOptions); + } + } + /** * Send upcoming payment action required email */ diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index 288d75347..43029dc21 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -10,11 +10,23 @@ import { BillingActorType, BillingSource, BillingEventType, + SubscriptionDowngradeType, } from "@src/modules/common/enum/billing.enum"; import { PlanName } from "@src/modules/common/enum/plan.enum"; -import { TeamsPlan } from "@src/modules/common/models/team.model"; +import { Team, TeamsPlan } from "@src/modules/common/models/team.model"; import { ScheduledDowngradeDto } from "@src/modules/common/models/billing.model"; import { LicensesDto } from "@src/modules/common/models/licenses.model"; +import { WorkspaceDto } from "@src/modules/common/models/workspace.model"; +import { UserDto } from "@src/modules/common/models/user.model"; +import { DownGradeService } from "./downgrade.service"; +import { DownGradeTeamRepository } from "../repositories/downgradeTeam.repository"; +import { ObjectId } from "mongodb"; +import { + UserExcelDto, + WorkspaceExcelDto, +} from "../payloads/downgrade-user.payload"; +import { DownGradeWorkspaceRepository } from "../repositories/downgradeWorkspace.repository"; +import { DownGradeUserRepository } from "../repositories/downgradeUser.repository"; // Dynamically import Stripe service class let StripeService: any; @@ -33,6 +45,10 @@ export class StripeSubscriptionService { private readonly stripeSubscriptionRepo: StripeSubscriptionRepository, private readonly billingAuditService: BillingAuditService, private readonly paymentEmailHelper: PaymentEmailHelper, + private readonly downgradeService: DownGradeService, + private readonly downgradeTeamRepository: DownGradeTeamRepository, + private readonly downGradeWorkspaceRepository: DownGradeWorkspaceRepository, + private readonly downGradeUserRepository: DownGradeUserRepository, @Optional() @Inject(StripeService) private readonly stripeService?: any, ) {} @@ -93,7 +109,6 @@ export class StripeSubscriptionService { reason: `Subscription created with status: ${subscription.status}`, }, ); - // Only update team state if subscription is active if (subscription.status === SubscriptionStatus.ACTIVE) { await this.updateTeamAndWorkspacesWithPlan( @@ -156,6 +171,7 @@ export class StripeSubscriptionService { async handleSubscriptionUpdated( subscription: any, eventId?: string, + isResubscribed?: boolean, ): Promise { try { // Extract metadata and validate required fields @@ -163,6 +179,9 @@ export class StripeSubscriptionService { subscription.metadata, ["planName", "hubId"], ); + if (isResubscribed) { + await this.downgradeService.removeDowngradeDetails(metadata.hubId); + } if (!isValid) { return; @@ -552,6 +571,46 @@ export class StripeSubscriptionService { const isSeatChange = previousSeats !== newSeats; const isSubscriptionRenewal = !isPlanChange && !isSeatChange && previousPeriodEnd; + let isDowngrading = false; + // Only check for downgrade if there's a plan change + if (isPlanChange && previousPlan && newPlan) { + // Get plan details to determine if it's a downgrade + const previousPlanDetails = + await this.stripeSubscriptionRepo.findPlanByName(previousPlan); + const newPlanDetails = + await this.stripeSubscriptionRepo.findPlanByName(newPlan); + + // Check if this is actually a downgrade (moving to a lower tier) + const isDowngrade = this.isPlanDowngrade( + previousPlanDetails, + newPlanDetails, + ); + + if (isDowngrade) { + const hasDowngradeConfig = team?.downgrade; + const isManualDowngrade = + team?.downgrade?.downgradeType === SubscriptionDowngradeType.MANUAL; + if (hasDowngradeConfig && isManualDowngrade) { + isDowngrading = true; + await this.executeManualDowngrade( + team, + metadata.hubId, + previousPlan, + newPlan, + new Date(), + ); + await this.stripeSubscriptionRepo.removeDowngradeDetails( + metadata.hubId, + ); + } + } + } + // Handle automatic downgrade cleanup (separate from manual downgrade) + if ( + team?.downgrade?.downgradeType === SubscriptionDowngradeType.AUTOMATIC + ) { + await this.stripeSubscriptionRepo.disableAutoDowngrade(metadata.hubId); + } // Create billing details object with successful payment status const billingDetails = { @@ -589,6 +648,12 @@ export class StripeSubscriptionService { }; await this.updateTeamPlanWithBilling(metadata.hubId, plan, billingDetails); + if (!isDowngrading) { + const teamIdObject = new ObjectId(metadata.hubId); + const updateTeam = + await this.downgradeTeamRepository.findTeamByTeamId(teamIdObject); + await this.downgradeService.unRestrictWorkpsaces(updateTeam); + } // Update team with new license data await this.stripeSubscriptionRepo.updateTeamById(metadata.hubId, { licenses: licenseUpdate, @@ -918,6 +983,14 @@ export class StripeSubscriptionService { const team = await this.stripeSubscriptionRepo.findTeamById( metadata.hubId, ); + // Execute manual downgrade AFTER plan change is complete + await this.executeManualDowngrade( + team, + metadata.hubId, + communityPlan.name, + team.plan.name, + new Date(), + ); const previousPlan = team?.plan?.name || "unknown"; // Get cancellation reason if available @@ -970,6 +1043,7 @@ export class StripeSubscriptionService { reason: `Subscription deleted - ${cancellationReason}`, }, ); + await this.stripeSubscriptionRepo.removeDowngradeDetails(metadata.hubId); } catch (error) { throw error; } @@ -1083,7 +1157,6 @@ export class StripeSubscriptionService { scheduledDowngrade: scheduledDowngrade, updatedBy: "system-stripe-webhook", }; - await this.stripeSubscriptionRepo.updateTeamPlan(hubId, team.plan, { billing: updatedBilling, }); @@ -1143,18 +1216,18 @@ export class StripeSubscriptionService { } // Find the community plan for downgrade - const communityPlan = - await this.stripeSubscriptionRepo.findPlanByName( - PlanName.COMMUNITY, - ); - if (!communityPlan) { - console.error("Community plan not found"); - continue; - } + // const communityPlan = + // await this.stripeSubscriptionRepo.findPlanByName( + // PlanName.COMMUNITY, + // ); + // if (!communityPlan) { + // console.error("Community plan not found"); + // continue; + // } // Ensure the plan has an ID for the update - communityPlan.id = communityPlan._id; - delete communityPlan._id; + // communityPlan.id = communityPlan._id; + // delete communityPlan._id; // Update billing details for expired subscription const billingDetails = { @@ -1167,11 +1240,11 @@ export class StripeSubscriptionService { }; // Update team to community plan - await this.updateTeamPlanWithBilling( - team._id.toString(), - communityPlan, - billingDetails, - ); + // await this.updateTeamPlanWithBilling( + // team._id.toString(), + // communityPlan, + // billingDetails, + // ); // Send plan downgrade email notification if (this.paymentEmailHelper && team.plan?.name) { @@ -1193,18 +1266,20 @@ export class StripeSubscriptionService { | { previous: Record; new: Record } | undefined; - if (team?.plan?.limits && communityPlan.limits) { + if (team?.plan?.limits) { planLimits = { previous: team.plan.limits, - new: communityPlan.limits, + new: team.plan.limits, }; } - + await this.stripeSubscriptionRepo.enableAutoDowngrade( + team._id.toString(), + ); // Log the plan change await this.billingAuditService.recordPlanChange( team._id.toString(), team.plan?.name || "unknown", - PlanName.COMMUNITY, + team.plan?.name, { actor: { type: BillingActorType.SYSTEM, name: "maintenance-job" }, source: BillingSource.BILLING_MAINTENANCE, @@ -2007,4 +2082,228 @@ export class StripeSubscriptionService { throw error; } } + + /** + * Execute manual downgrade - remove workspaces and users not in the downgrade list + * This should only be called when the plan change is complete + * @param team The team data + * @param hubId The team hub ID + */ + private async executeManualDowngrade( + team: Team, + hubId: string, + previousPlan?: string, + currentPlan?: string, + startDate?: Date, + ): Promise { + const downgrade = team?.downgrade; + if ( + !downgrade || + (downgrade?.downgradeType && + downgrade?.downgradeType !== SubscriptionDowngradeType.MANUAL) + ) { + return; + } + const downgradeType = team?.downgrade?.downgradeType; + const teamDowngradeWorkspaces = team?.downgrade?.workspaces; + const teamDowngradeUsers = team?.downgrade?.users; + // Only proceed if downgrade type is MANUAL + if (downgradeType !== SubscriptionDowngradeType.MANUAL) { + return; + } + // Skip if downgrade data is missing + if (!teamDowngradeWorkspaces) { + console.warn( + `Manual downgrade enabled but missing downgrade data for team ${hubId}.`, + ); + return; + } + if (teamDowngradeWorkspaces.length < 1) { + console.warn(`Manual downgrade Workspaces are Missing for ${hubId}.`); + return; + } + try { + // Get all current workspace IDs + const allWorkspaces = + team?.workspaces?.map((workspace: WorkspaceDto) => + workspace.id.toString(), + ) || []; + // Get all current user IDs (excluding owner) + const allUsers = + team?.users + ?.filter((user: UserDto) => user.role !== "owner") + .map((user: UserDto) => user.id) || []; + const OwnerEmail = team.users[0].email; + // Extract workspace IDs from downgrade list (workspaces to keep) + const downgradeWorkspaceIds = + teamDowngradeWorkspaces?.map((ws) => ws.id) || []; + // Extract user IDs from downgrade list (users to keep) + const downgradeUserIds = teamDowngradeUsers?.map((user) => user.id) || []; + const downgradeUserEmails = teamDowngradeUsers.map((user) => user.email); + const nonDowngradedUsersWithEmail = + team?.users + ?.filter( + (user: UserDto) => + user.role !== "owner" && !downgradeUserIds.includes(user.id), + ) + .map((user: UserDto) => user.email) || []; + // Workspaces not in the downgrade list (these will be deleted) + const nonDowngradedWorkspaces = allWorkspaces.filter( + (wsId: string) => !downgradeWorkspaceIds.includes(wsId), + ); + // Users not in the downgrade list (these will be removed) + const nonDowngradedUsers = allUsers.filter( + (userId: string) => !downgradeUserIds.includes(userId), + ); + + // Delete workspaces that are not in the downgrade list + if (nonDowngradedWorkspaces.length > 0) { + for (const workspaceId of nonDowngradedWorkspaces) { + await this.downgradeService.restrictWorkspace(workspaceId); + } + } + + // Remove users not in the downgrade list + if (nonDowngradedUsers.length > 0 && teamDowngradeUsers.length > 0) { + try { + const payload = { + teamId: hubId, + userIds: nonDowngradedUsers, + }; + await this.downgradeService.removeUserFromTeam(payload); + } catch (error) { + console.error(`Error removing users from team ${hubId}:`, error); + } + } + const workspaceExcelData = await this.workspaceExcelData( + nonDowngradedWorkspaces, + ); + const userExcelData = await this.userExcelData(nonDowngradedUsers); + await this.sendEmailsToUserHubDowngrade( + previousPlan, + currentPlan, + team, + startDate, + workspaceExcelData, + userExcelData, + [...downgradeUserEmails, OwnerEmail], + nonDowngradedUsersWithEmail, + ); + console.log( + `Manual downgrade completed for team ${hubId}: ${nonDowngradedWorkspaces.length} workspaces deleted, ${nonDowngradedUsers.length} users removed`, + ); + } catch (error) { + console.error( + `Error executing manual downgrade for team ${hubId}:`, + error, + ); + } + } + + /** + * Helper method to determine if a plan change is a downgrade + * @param previousPlan The previous plan object + * @param newPlan The new plan object + * @returns Boolean indicating if this is a downgrade + */ + private isPlanDowngrade(previousPlan: any, newPlan: any): boolean { + // Add your plan hierarchy logic here + // For example, you might have a plan hierarchy like: + // Community < standard < Professional + + const planHierarchy: { [key: string]: number } = { + [PlanName.COMMUNITY]: 0, + [PlanName.STANDARD]: 1, + [PlanName.PROFESSIONAL]: 2, + }; + + const previousLevel = planHierarchy[previousPlan.name] ?? 0; + const newLevel = planHierarchy[newPlan.name] ?? 0; + + return newLevel < previousLevel; + } + + private async workspaceExcelData( + workspaceIds: string[], + ): Promise { + if (workspaceIds.length === 0) { + return [ + { + name: "", + created_at: "", + collections: 0, + testflow: 0, + }, + ]; + } + const workspaces = + await this.downGradeWorkspaceRepository.getWorkspacesByIds(workspaceIds); + const resultWorkspaces: WorkspaceExcelDto[] = workspaces.map( + (workspace) => ({ + name: workspace.name, + created_at: workspace.createdAt + ? workspace.createdAt.toUTCString() + : "N/A", + collections: workspace?.collection?.length || 0, + testflow: workspace?.testflows?.length || 0, + }), + ); + return resultWorkspaces; + } + + private async userExcelData(userIds: string[]): Promise { + if (userIds.length === 0) { + return [ + { + name: "", + email: "", + }, + ]; + } + const usersData = + await this.downGradeUserRepository.findUsersByStringIds(userIds); + const resultUsers: UserExcelDto[] = usersData.map((user) => ({ + name: user.name || "N/A", + email: user.email || "N/A", + })); + return resultUsers; + } + + /** + * Helper method to determine if a send Email after downgrade to Hub + * @param previousPlan The previous plan object. + * @param newPlan The new plan object. + * @param currentUsers downgraded users. + * @param removedUser removed users from Hub. + * @returns Boolean indicating if this is a downgrade + */ + private async sendEmailsToUserHubDowngrade( + previousPlan: string, + newPlan: string, + team: Team, + startDate: Date, + workspaces?: WorkspaceExcelDto[], + users?: UserExcelDto[], + currentUser?: string[], + removedUser?: string[], + ) { + await this.paymentEmailHelper.sendHubDowngradedEmail( + team, + startDate, + previousPlan, + newPlan, + currentUser, + workspaces, + users, + ); + if (removedUser.length > 0) { + await this.paymentEmailHelper.sendHubDowngradeRemoveUserEmail( + team, + startDate, + previousPlan, + newPlan, + removedUser, + ); + } + } } diff --git a/src/modules/common/config/configuration.ts b/src/modules/common/config/configuration.ts index 0e7f7851a..3d54aad44 100644 --- a/src/modules/common/config/configuration.ts +++ b/src/modules/common/config/configuration.ts @@ -152,4 +152,7 @@ export default () => ({ trial: { trialPeriod: 14, }, + downgradeHub: { + container: process.env.DOWNGRADE_HUB_BLOB_CONTAINER, + }, }); diff --git a/src/modules/common/enum/billing.enum.ts b/src/modules/common/enum/billing.enum.ts index 72a7eb62f..f5613f0da 100644 --- a/src/modules/common/enum/billing.enum.ts +++ b/src/modules/common/enum/billing.enum.ts @@ -137,3 +137,8 @@ export enum BillingTransactionType { ADJUSTMENT = "adjustment", PRORATION = "proration", } + +export enum SubscriptionDowngradeType { + MANUAL = "manual", + AUTOMATIC = "auto", +} diff --git a/src/modules/common/instructions/generate-pre-script.ts b/src/modules/common/instructions/generate-pre-script.ts new file mode 100644 index 000000000..f70cab163 --- /dev/null +++ b/src/modules/common/instructions/generate-pre-script.ts @@ -0,0 +1,110 @@ +export const generatePreScriptInstructions = ` + const expect = (actual: any) => ({ + to: { + equal: (expected: any) => { + + }, + notEqual: (expected: any) => { + }, + exist: () => { + + }, + notExist: () => { + + }, + be: { + a: (type: string) => { + }, + true: () => { + }, + false: () => { + + + }, + within: (min: number, max: number) => { + + }, + lessThan: (expected: number) => { + }, + greaterThan: (expected: number) => { + + }, + empty: () => { + + }, + notEmpty: () => { + + }, + }, + contain: (expected: any) => { + + }, + notContain: (expected: any) => { + + }, + beInList: (list: any[]) => { + + }, + notBeInList: (list: any[]) => { + + }, + have: { + all: { + keys: (...keys: string[]) => { + + }, + }, + }, + }, + }); + + const sp = { + response: { + statusCode: number , + body: { + text: () => { + try { + return string; + } catch { + return {}; + } + }, + json: () => { + try { + return JSON.parse(string); + } catch { + return {}; + } + }, + }, + headers: object, + size: number, + time: number, + }, + test: (name: string, fn: Function) => { + try { + fn(); + } catch (err: any) { + } + }, + xmlToJSON: (xml: string) => { + const json = {}; + // Convert XML to JSON logic here + return json; + }, + expect, + }; + + - create testcases using above syntax, + - ensure all test cases are valid syntactically, + - dont use any other syntax or return any other text outside of the test cases. + - dont use any markdown or code snippet + - dont wrap output in triple backticks or labels like "javascript", "js", etc. + - Output must be ONLY the raw test cases in javascript format. + - if user prompt is not valid then return + - Example format: ' + sp.test("userId is a number", function () { + sp.expect(jsonBody.userId).to.be.a("number"); + }); + ' + `; diff --git a/src/modules/common/models/billing.model.ts b/src/modules/common/models/billing.model.ts index 7e0a439c2..32b40e493 100644 --- a/src/modules/common/models/billing.model.ts +++ b/src/modules/common/models/billing.model.ts @@ -16,6 +16,7 @@ import { BillingEventType, BillingSource, BillingTransactionType, + SubscriptionDowngradeType, } from "../enum/billing.enum"; export class PaymentProviderDto { @@ -179,6 +180,42 @@ export class BillingDto { subscription_expired_email_sent?: boolean | Date; } +export class DowngradeDetails { + @IsArray() + @IsOptional() + @Type(() => downgradeWorkspace) + workspaces?: downgradeWorkspace[]; + + @IsArray() + @IsOptional() + @Type(() => downgradeUser) + users?: downgradeUser[]; + + @IsString() + @IsOptional() + downgradeType?: SubscriptionDowngradeType; +} + +export class downgradeWorkspace { + @IsString() + @IsOptional() + id: string; + + @IsString() + @IsOptional() + name: string; +} + +export class downgradeUser { + @IsString() + @IsOptional() + id: string; + + @IsString() + @IsOptional() + email: string; +} + class BillingChangeDto { @IsString() @IsOptional() diff --git a/src/modules/common/models/collection.model.ts b/src/modules/common/models/collection.model.ts index 65ad50d77..c34435d13 100644 --- a/src/modules/common/models/collection.model.ts +++ b/src/modules/common/models/collection.model.ts @@ -1246,6 +1246,10 @@ export class CollectionWithRequestTestsOption extends CollectionGenerateVariable @IsBoolean() @IsOptional() isRequestTestsScriptDemoCompleted?: boolean; + + @IsBoolean() + @IsOptional() + isRequestAssertionsDemoCompleted?: boolean; } export class CollectionDto { diff --git a/src/modules/common/models/team.model.ts b/src/modules/common/models/team.model.ts index 53020643b..f835c6634 100644 --- a/src/modules/common/models/team.model.ts +++ b/src/modules/common/models/team.model.ts @@ -19,7 +19,7 @@ import { UserDto } from "./user.model"; import { ObjectId } from "mongodb"; import { SelectedWorkspaces } from "@src/modules/identity/payloads/teamUser.payload"; import { Plan } from "./plan.model"; -import { BillingDto } from "./billing.model"; +import { BillingDto, DowngradeDetails } from "./billing.model"; import { LicensesDto } from "./licenses.model"; export class logoDto { @@ -125,6 +125,11 @@ export class Team { @IsOptional() @IsObject() licenses?: LicensesDto; + + @IsOptional() + @ValidateNested() + @Type(() => DowngradeDetails) + downgrade?: DowngradeDetails; } export class TeamWithNewInviteTag extends Team { diff --git a/src/modules/common/models/user.model.ts b/src/modules/common/models/user.model.ts index eb3706429..68854fd7b 100644 --- a/src/modules/common/models/user.model.ts +++ b/src/modules/common/models/user.model.ts @@ -45,8 +45,11 @@ export class TourGuideDto { @IsBoolean() @IsOptional() isRequestTestsScriptDemoCompleted?: boolean; -} + @IsBoolean() + @IsOptional() + isRequestAssertionsDemoCompleted?: boolean; +} export class User { @IsString() diff --git a/src/modules/common/models/workspace.model.ts b/src/modules/common/models/workspace.model.ts index f53b1d053..ef19e8761 100644 --- a/src/modules/common/models/workspace.model.ts +++ b/src/modules/common/models/workspace.model.ts @@ -126,6 +126,10 @@ export class Workspace { @IsOptional() users?: UserDto[]; + @IsBoolean() + @IsOptional() + isRestricted?: boolean; + @IsDate() @IsOptional() createdAt?: Date; diff --git a/src/modules/common/services/blobStorage.service.ts b/src/modules/common/services/blobStorage.service.ts index 598f28897..140c41844 100644 --- a/src/modules/common/services/blobStorage.service.ts +++ b/src/modules/common/services/blobStorage.service.ts @@ -17,6 +17,7 @@ export class BlobStorageService { private blobServiceClient: BlobServiceClient; private containerClient: ContainerClient; private aiContainerClient: ContainerClient; + private downGradeHubClient: ContainerClient; /** * Constructor to initialize BlobStorageService with required dependencies. @@ -30,8 +31,8 @@ export class BlobStorageService { "feedbackBlob.container", ); const aiConversationBLobContainer = this.configService.get( - "ai.conversationConatiner" - ) + "ai.conversationConatiner", + ); try { /** @@ -63,7 +64,18 @@ export class BlobStorageService { ); if (!aiConversationBLobContainer) { - console.warn("AI Conversation Blob is disabled: No container provided."); + console.warn( + "AI Conversation Blob is disabled: No container provided.", + ); + return; + } + + const downgradeHubBlobContainer = this.configService.get( + "downgradeHub.container", + ); + + if (!downgradeHubBlobContainer) { + console.warn("Downgrade Blob is disabled: No container provided."); return; } @@ -82,6 +94,9 @@ export class BlobStorageService { this.aiContainerClient = this.blobServiceClient.getContainerClient( aiConversationBLobContainer, ); + this.downGradeHubClient = this.blobServiceClient.getContainerClient( + downgradeHubBlobContainer, + ); } catch (e) { console.error(e); } @@ -177,6 +192,56 @@ export class BlobStorageService { return docURL; } + /** + * Uploads an Excel Document to Azure Blob Storage. + * @param buffer - Buffer containing the Excel file data + * @param storageName - Name used for storing the file in blob (with timestamp) + * @param downloadName - Name shown when user downloads the file (clean name) + * @param mimetype - MIME type of the file (Excel format) or file extension (e.g., ".xlsx") + * @returns Object containing fileId and fileUrl + */ + async uploadExcelBlob( + buffer: Buffer, + storageName: string, + downloadName: string, + mimetype: string, + ): Promise<{ fileUrl: string; fileId: string }> { + const fileId = uuidv4(); + // Handle both full MIME type and file extension + let fileExtension: string; + let contentType: string; + if (mimetype.startsWith(".")) { + // If mimetype is an extension like ".xlsx" + fileExtension = mimetype.substring(1); // Remove the dot + contentType = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } else { + // If mimetype is a full MIME type + fileExtension = await this.getFileExtension(mimetype); + contentType = mimetype; + } + const uniqueFileName = `${fileId}-${storageName}.${fileExtension}`; + if (!this.downGradeHubClient) { + throw new BadRequestException( + "Azure blob container is not connected to backend server.", + ); + } + const blockBlobClient = + this.downGradeHubClient.getBlockBlobClient(uniqueFileName); + // Set Content-Type and Content-Disposition headers for Excel download + const uploadOptions = { + blobHTTPHeaders: { + blobContentType: contentType, + blobContentDisposition: `attachment; filename="${downloadName}.${fileExtension}"`, + }, + }; + await blockBlobClient.upload(buffer, buffer.length, uploadOptions); + return { + fileId: fileId, + fileUrl: blockBlobClient.url, + }; + } + async deleteAiDocByUrl(fileUrl: string): Promise { if (!this.aiContainerClient) { throw new BadRequestException( diff --git a/src/modules/identity/controllers/user.controller.ts b/src/modules/identity/controllers/user.controller.ts index cf3553b9c..d5e10fbb1 100644 --- a/src/modules/identity/controllers/user.controller.ts +++ b/src/modules/identity/controllers/user.controller.ts @@ -587,4 +587,31 @@ export class UserController { ); return res.status(HttpStatusCode.OK).send(responseData); } + + @Post("/request-assertions-demo") + @ApiOperation({ + summary: "When the User has completed the Assertions Demo.", + description: + "Marks the property as true when the user has completed the assertions demo.", + }) + @UseGuards(JwtAuthGuard) + @ApiResponse({ + status: 200, + description: "Assertions Demo completed successfully", + }) + @ApiResponse({ status: 400, description: "Bad Request" }) + async requestAssertionsDemoCompleted( + @Req() request: ExtendedFastifyRequest, + @Res() res: FastifyReply, + ) { + const user = request.user; + const result = await this.userService.requestAssertionsDemoCompleted( + user.email, + ); + const responseData = new ApiResponseService( + "Assertions Demo completed successfully", + HttpStatusCode.OK, + result, + ); + } } diff --git a/src/modules/identity/guards/hub-invite.guard.ts b/src/modules/identity/guards/hub-invite.guard.ts index f181e9b98..f13ab2ec2 100644 --- a/src/modules/identity/guards/hub-invite.guard.ts +++ b/src/modules/identity/guards/hub-invite.guard.ts @@ -9,9 +9,7 @@ import { TeamService } from "@src/modules/identity/services/team.service"; @Injectable() export class HubInviteGuard implements CanActivate { - constructor( - private readonly teamService: TeamService, - ) {} + constructor(private readonly teamService: TeamService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); @@ -30,6 +28,9 @@ export class HubInviteGuard implements CanActivate { }); const planData = userTeam?.plan; + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if (teamUserEmails.size > planData?.limits?.usersPerHub?.value + 1) { throw new ForbiddenException("Plan limit reached"); } diff --git a/src/modules/identity/guards/user-limt-guard.ts b/src/modules/identity/guards/user-limt-guard.ts index 73a858839..3ffd3dca0 100644 --- a/src/modules/identity/guards/user-limt-guard.ts +++ b/src/modules/identity/guards/user-limt-guard.ts @@ -29,6 +29,9 @@ export class UserLimitGuard implements CanActivate { } const teamData = await this.teamRepository.get(teamId); + if (!teamData.plan?.active) { + throw new ForbiddenException("Hub is Restricted."); + } if (!teamData || !teamData.users) { throw new ForbiddenException("Team not found or invalid."); } @@ -37,7 +40,7 @@ export class UserLimitGuard implements CanActivate { if (!user) { throw new ForbiddenException("User not found in team."); } - + const status = await this.userLimitService.checkLimitAndLogRequest( user.id, teamId, diff --git a/src/modules/identity/repositories/team.repository.ts b/src/modules/identity/repositories/team.repository.ts index 2a7b48e99..49197c7d4 100644 --- a/src/modules/identity/repositories/team.repository.ts +++ b/src/modules/identity/repositories/team.repository.ts @@ -84,10 +84,20 @@ export class TeamRepository { "The Team with that id could not be found.", ); } + const isActive = team?.plan?.active; + if (isActive === false) { + return { + _id: team._id, + name: team.name, + hubUrl: team.hubUrl, + logo: team.logo, + isRestricted: true + } as unknown as WithId; + } return team; } - /** + /** * Fetches teams from database by UUID * @param {string[]} teamIds * @returns {Promise} queried team data @@ -101,7 +111,20 @@ export class TeamRepository { "The teams with that ids could not be found.", ); } - return teams; + const processedTeams = teams.map((team) => { + const isActive = team?.plan?.active; + if (isActive === false) { + return { + _id: team._id, + name: team.name, + hubUrl: team.hubUrl, + logo: team.logo, + isRestricted: true + } as Partial>; + } + return team; + }); + return processedTeams as WithId[]; } /** @@ -185,11 +208,11 @@ export class TeamRepository { const responseData = await this.db .collection(Collections.TEAM) .findOneAndUpdate( - { + { _id: id, $expr: { $lt: [{ $size: "$workspaces" }, planData.limits.workspacesPerHub.value], } , - }, + }, { $push: { workspaces: ws }, }, @@ -255,21 +278,21 @@ export class TeamRepository { const incomingEmails = users.map(u => u.email); const result = await this.db.collection(Collections.TEAM).findOneAndUpdate( - { - _id: teamObjectId, - // Ensure limit not exceeded - $expr: { - $lte: [ - { - $add: [ - { $size: { $ifNull: ["$users", []] } }, - { $size: { $ifNull: ["$invites", []] } }, - { - $size: { - $setDifference: [ - incomingEmails, - { - $concatArrays: [ + { + _id: teamObjectId, + // Ensure limit not exceeded + $expr: { + $lte: [ + { + $add: [ + { $size: { $ifNull: ["$users", []] } }, + { $size: { $ifNull: ["$invites", []] } }, + { + $size: { + $setDifference: [ + incomingEmails, + { + $concatArrays: [ { $map: { input: { $ifNull: ["$users", []] }, as: "u", in: "$$u.email" } }, { $map: { input: { $ifNull: ["$invites", []] }, as: "i", in: "$$i.email" } } ] @@ -278,27 +301,27 @@ export class TeamRepository { } } ] - }, + }, { $add: ["$plan.limits.usersPerHub.value", 1] } ] } - }, - [ - { + }, + [ + { $set: { - invites: { - $setUnion: [ - { $ifNull: ["$invites", []] }, // πŸ‘ˆ fallback to [] - { - $filter: { - input: users, // πŸ‘ˆ inject your payload as a constant - as: "newInvite", - cond: { - $not: { - $in: [ - "$$newInvite.email", - { - $concatArrays: [ + invites: { + $setUnion: [ + { $ifNull: ["$invites", []] }, // πŸ‘ˆ fallback to [] + { + $filter: { + input: users, // πŸ‘ˆ inject your payload as a constant + as: "newInvite", + cond: { + $not: { + $in: [ + "$$newInvite.email", + { + $concatArrays: [ { $map: { input: { $ifNull: ["$users", []] }, as: "u", in: "$$u.email" } }, { $map: { input: { $ifNull: ["$invites", []] }, as: "i", in: "$$i.email" } } ] @@ -314,7 +337,7 @@ export class TeamRepository { } ] , { returnDocument: "after" } - ); + ); return result.value; } diff --git a/src/modules/identity/services/team.service.ts b/src/modules/identity/services/team.service.ts index 75e30b942..a23d85e55 100644 --- a/src/modules/identity/services/team.service.ts +++ b/src/modules/identity/services/team.service.ts @@ -462,7 +462,7 @@ export class TeamService { const teamMap = new Map>(); for (const team of teamDocs) { // Sanitize invites - team.invites?.forEach((invite: Invite) => { + team?.invites?.forEach((invite: Invite) => { delete invite.inviteId; delete invite.isAccepted; delete invite.workspaces; diff --git a/src/modules/identity/services/user.service.ts b/src/modules/identity/services/user.service.ts index 613b20d70..08c996653 100644 --- a/src/modules/identity/services/user.service.ts +++ b/src/modules/identity/services/user.service.ts @@ -825,4 +825,19 @@ export class UserService { }); return response; } + + async requestAssertionsDemoCompleted(email: string) { + const userDetails = await this.userRepository.getUserByEmail(email); + if (!userDetails) { + throw new BadRequestException("User does not exist"); + } + const updatedTourGuide = { + ...userDetails.tourGuide, + isRequestAssertionsDemoCompleted: true, + }; + const response = await this.userRepository.updateUserById(userDetails._id, { + tourGuide: updatedTourGuide, + }); + return response; + } } diff --git a/src/modules/views/hubDowngradeRemoveUserEmail.handlebars b/src/modules/views/hubDowngradeRemoveUserEmail.handlebars new file mode 100644 index 000000000..1bfdc87c9 --- /dev/null +++ b/src/modules/views/hubDowngradeRemoveUserEmail.handlebars @@ -0,0 +1,143 @@ +{{!< layoutName}} + + + + + + Plan Downgraded User + + + + + +
+
+
+ + {{> headerV2}} + + + + +
+ + + + +
+
+

Your {{hubName}} Hub Has Been Downgraded


+
+
+
+
+ + + + + +
+ +
+ + + + +
+ +
+
+ {{> footerV2}} +
+
+
+ + \ No newline at end of file diff --git a/src/modules/views/hubDowngradedEmail.handlebars b/src/modules/views/hubDowngradedEmail.handlebars new file mode 100644 index 000000000..90588a447 --- /dev/null +++ b/src/modules/views/hubDowngradedEmail.handlebars @@ -0,0 +1,201 @@ +{{!< layoutName}} + + + + + + Plan Downgraded + + + + + +
+
+
+ + {{> headerV2}} + + + + +
+ + + + +
+
+

Your {{hubName}} Hub Has Been Downgraded


+
+
+
+
+ + + + + +
+ +
+ + + + + + + + +
+
+
+

+ For a detailed list of all the content and users affected by this change, you can view a full summary report. +

+
+ + + + +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ {{> footerV2}} +
+
+
+ + \ No newline at end of file diff --git a/src/modules/workspace/controllers/ai-assistant.controller.ts b/src/modules/workspace/controllers/ai-assistant.controller.ts index 3bb4770f2..e42949cb8 100644 --- a/src/modules/workspace/controllers/ai-assistant.controller.ts +++ b/src/modules/workspace/controllers/ai-assistant.controller.ts @@ -266,4 +266,41 @@ export class AiAssistantController { ); return res.status(responseData.httpStatusCode).send(responseData); } + + @Post("/generate-pre-script") + @ApiOperation({ + summary: "Generate Pre-Script for API request", + description: + "Generates a pre-script for a specific API request definition.", + }) + @UseGuards(JwtAuthGuard, UserLimitGuard) + @ApiResponse({ + status: 200, + description: "Generated Pre-Script Successfully", + }) + @ApiResponse({ + status: 400, + description: "Failed to generate pre-script.", + }) + @ApiResponse({ + status: 500, + description: "Server failed to generate pre-script.", + }) + async generatePreScriptForRequest( + @Body() content: generateTestCasesDto, + @Res() res: FastifyReply, + @Req() request: ExtendedFastifyRequest, + ) { + const user = request.user; + const preScript = await this.aiAssistantService.generatePreScript( + user, + content, + ); + const responseData = new ApiResponseService( + "Generated Pre-Script Successfully", + HttpStatusCode.OK, + preScript, + ); + return res.status(responseData.httpStatusCode).send(responseData); + } } diff --git a/src/modules/workspace/guards/plan-limits/create-testflow-block-guard.ts b/src/modules/workspace/guards/plan-limits/create-testflow-block-guard.ts index 6c390a50d..1d8d246aa 100644 --- a/src/modules/workspace/guards/plan-limits/create-testflow-block-guard.ts +++ b/src/modules/workspace/guards/plan-limits/create-testflow-block-guard.ts @@ -12,7 +12,7 @@ import { TeamService } from "@src/modules/identity/services/team.service"; export class CreateTestflowBlockGuard implements CanActivate { constructor( private readonly workspaceService: WorkspaceService, - private readonly teamService: TeamService + private readonly teamService: TeamService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -22,8 +22,10 @@ export class CreateTestflowBlockGuard implements CanActivate { ); const teamId = workspaceDetails.team.id; const userTeam = await this.teamService.get(teamId); - const planData = userTeam?.plan - + const planData = userTeam?.plan; + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if ( request?.body?.nodes?.length > planData?.limits?.blocksPerTestflow?.value + 1 diff --git a/src/modules/workspace/guards/plan-limits/create-testflow-guard.ts b/src/modules/workspace/guards/plan-limits/create-testflow-guard.ts index e558f6992..e712ec229 100644 --- a/src/modules/workspace/guards/plan-limits/create-testflow-guard.ts +++ b/src/modules/workspace/guards/plan-limits/create-testflow-guard.ts @@ -23,6 +23,9 @@ export class CreateTestflowGuard implements CanActivate { const teamId = workspaceDetails.team.id; const userTeam = await this.teamService.get(teamId); const planData = userTeam?.plan + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if ( workspaceDetails?.testflows?.length >= planData?.limits?.testflowPerWorkspace?.value diff --git a/src/modules/workspace/guards/plan-limits/create-testflow-schedule-guard.ts b/src/modules/workspace/guards/plan-limits/create-testflow-schedule-guard.ts index b7d8f18aa..f66f16671 100644 --- a/src/modules/workspace/guards/plan-limits/create-testflow-schedule-guard.ts +++ b/src/modules/workspace/guards/plan-limits/create-testflow-schedule-guard.ts @@ -28,6 +28,9 @@ export class CreateTestflowScheduleGuard implements CanActivate { const testflow = await this.testflowRepository.get( request?.body?.testflowId, ); + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if ( Array.isArray(testflow?.schedules) && testflow.schedules.length >= diff --git a/src/modules/workspace/guards/plan-limits/create-workspace-guard.ts b/src/modules/workspace/guards/plan-limits/create-workspace-guard.ts index 9d635f1b7..fc51ec275 100644 --- a/src/modules/workspace/guards/plan-limits/create-workspace-guard.ts +++ b/src/modules/workspace/guards/plan-limits/create-workspace-guard.ts @@ -17,6 +17,9 @@ export class CreateWorkspaceGuard implements CanActivate { const teamId = request?.body?.id; const usersTeamdetails = await this.teamService.get(teamId); const planData = usersTeamdetails?.plan; + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if ( usersTeamdetails?.workspaces?.length >= planData?.limits?.workspacesPerHub?.value diff --git a/src/modules/workspace/guards/plan-limits/workspace-invite.guard.ts b/src/modules/workspace/guards/plan-limits/workspace-invite.guard.ts index 7c9c9ede1..c4ad71eea 100644 --- a/src/modules/workspace/guards/plan-limits/workspace-invite.guard.ts +++ b/src/modules/workspace/guards/plan-limits/workspace-invite.guard.ts @@ -35,6 +35,9 @@ export class WorkspaceInviteGuard implements CanActivate { const planData = userTeam?.plan; + if (!planData.active) { + throw new ForbiddenException("Hub is Restricted."); + } if (teamUserEmails.size > planData?.limits?.usersPerHub?.value + 1) { throw new ForbiddenException("Plan limit reached"); } diff --git a/src/modules/workspace/repositories/workspace.repository.ts b/src/modules/workspace/repositories/workspace.repository.ts index a21019615..f28b6bf2c 100644 --- a/src/modules/workspace/repositories/workspace.repository.ts +++ b/src/modules/workspace/repositories/workspace.repository.ts @@ -44,6 +44,13 @@ export class WorkspaceRepository { if (!data) { throw new BadRequestException("Not Found"); } + // Check if workspace is restricted + if (data?.isRestricted === true) { + console.log(`🚫 Access denied: Workspace ${_id} is restricted`); + throw new BadRequestException( + "This workspace is restricted and cannot be accessed.", + ); + } return data; } @@ -61,7 +68,16 @@ export class WorkspaceRepository { "The workspaces with that ids could not be found.", ); } - return workspaces; + // Filter out restricted workspaces + const filteredWorkspaces = workspaces.filter((workspace) => { + // Skip if workspace.isRestricted === true + if (workspace?.isRestricted === true) { + console.log(`🚫 Skipping restricted workspace: ${workspace._id}`); + return false; + } + return true; + }); + return filteredWorkspaces; } async getPublicWorkspace(id: string): Promise> { @@ -87,6 +103,16 @@ export class WorkspaceRepository { const response = await this.db .collection(Collections.WORKSPACE) .findOne({ _id: id }); + // Check if workspace is restricted + if (!response) { + throw new BadRequestException("Workspace not found."); + } + if (response?.isRestricted === true) { + console.log(`Access denied: Workspace ${id} is restricted`); + throw new BadRequestException( + "This workspace is restricted and cannot be accessed.", + ); + } return response; } @@ -97,7 +123,17 @@ export class WorkspaceRepository { .collection(Collections.WORKSPACE) .find({ _id: { $in: IdArray } }) .toArray(); - return response; + if (!response || response.length === 0) { + throw new BadRequestException("No workspaces found."); + } + // Filter out restricted workspaces + const filteredResponse = response.filter((workspace) => { + if (workspace?.isRestricted === true) { + return false; + } + return true; + }); + return filteredResponse; } async updateWorkspaceById( diff --git a/src/modules/workspace/services/ai-assistant.service.ts b/src/modules/workspace/services/ai-assistant.service.ts index a020e4f6b..0250de954 100644 --- a/src/modules/workspace/services/ai-assistant.service.ts +++ b/src/modules/workspace/services/ai-assistant.service.ts @@ -84,6 +84,7 @@ import { } from "@src/modules/common/enum/collection.request.enum"; import { fixTestScriptInstructions } from "@src/modules/common/instructions/fix-test-script"; import { generateTestCasesInstructions } from "@src/modules/common/instructions/generate-test-cases"; +import { generatePreScriptInstructions } from "@src/modules/common/instructions/generate-pre-script"; async function initializeGenAI(authKey: string, client?: WebSocket) { const { GoogleGenAI } = await import("@google/genai"); @@ -164,7 +165,7 @@ export class AiAssistantService { try { if (!this.endpoint || !this.apiKey || !this.apiVersion) { console.warn("GPT Client is disabled. Missing configuration values"); - }else{ + } else { this.gptAssistantsClient = this.getGPTClient(); } } catch (e) { @@ -181,10 +182,9 @@ export class AiAssistantService { console.warn( "Deepseek Client is disabled. Missing configuration values", ); - }else{ + } else { this.deepseekClient = this.getDeepSeekClient(); } - } catch (e) { console.error(e); } @@ -2492,7 +2492,7 @@ export class AiAssistantService { ], }, }); - + const output = ( response.body as any ).choices?.[0]?.message?.content?.trim(); @@ -2548,6 +2548,92 @@ export class AiAssistantService { } } + /** + * Generates a pre-script for a request item within a collection. + * @param user - The authenticated user requesting the data. + * @param content - The request pre-script content. + * @returns Generated pre-script as JSON or string. + */ + public async generatePreScript( + user: DecodedUserObject, + content: generateTestCasesDto, // Or create a dedicated DTO if needed + ) { + if (!content.text?.trim()) { + throw new BadRequestException("prompt must be provided."); + } + try { + const response = await this.deepseekClient + .path("/chat/completions") + .post({ + body: { + model: this.deepseekModel, + messages: [ + { + role: "system", + content: generatePreScriptInstructions, + }, + { + role: "user", + content: `Pre-Script Prompt:\n${content.text}\n\n`, + }, + ], + }, + }); + + const output = ( + response.body as any + ).choices?.[0]?.message?.content?.trim(); + if (!output) { + throw new BadRequestException( + "No pre-script generated from the model.", + ); + } + let parsedOutput: any; + try { + parsedOutput = JSON.parse(output); + } catch { + parsedOutput = output; + } + const body = response.body as any; + const tokens = body?.usage?.total_tokens; + + const eventMessage = { + userId: user._id, + tokenCount: tokens, + model: "deepseek", + }; + + await this.producerService.produce(TOPIC.AI_RESPONSE_GENERATED_TOPIC, { + value: JSON.stringify(eventMessage), + }); + + const activityLog = { + userId: user._id.toString(), + userEmail: user.email, + activity: "generate-pre-script", + model: "deepseek", + tokenConsumed: tokens, + threadId: "null", + }; + + // Send activity log to Kafka topic + await this.producerService.produce(TOPIC.AI_ACTIVITY_LOG_TOPIC, { + value: JSON.stringify(activityLog), + }); + + return { result: parsedOutput }; + } catch (error) { + console.error("Error generating pre-script:", error); + Sentry.withScope((scope) => { + scope.setTag("emailId", user.email); + scope.setTag("errorType", "AI"); + Sentry.captureException(error); + }); + throw new BadRequestException( + error?.message || "Failed to generate pre-script. Please try again.", + ); + } + } /** * Generates test cases for a request item within a collection, based on the specified type. * @param user - The authenticated user requesting the data. diff --git a/src/modules/workspace/services/collection.service.ts b/src/modules/workspace/services/collection.service.ts index 588d5e09d..ce7f64dea 100644 --- a/src/modules/workspace/services/collection.service.ts +++ b/src/modules/workspace/services/collection.service.ts @@ -521,6 +521,10 @@ export class CollectionService { ?.isRequestTestsScriptDemoCompleted ? false : true; + collection.isRequestAssertionsDemoCompleted = userDetails?.tourGuide + ?.isRequestAssertionsDemoCompleted + ? false + : true; let alreadyProcessed = false; if ( userDetails?.isGenerateVariableTrial && @@ -596,6 +600,10 @@ export class CollectionService { ?.isRequestTestsNoCodeDemoCompleted ? false : true; + collections[i].isRequestAssertionsDemoCompleted = userDetails?.tourGuide + ?.isRequestAssertionsDemoCompleted + ? false + : true; let alreadyProcessed = false; // Case 1: Trial array exists and contains collectionId if ( @@ -1387,7 +1395,7 @@ export class CollectionService { const validGeneratedPairs = generatedPairs.filter( (pair) => pair.key?.trim() && pair.value?.trim(), ); - if(validGeneratedPairs.length < 1){ + if (validGeneratedPairs.length < 1) { throw new BadRequestException( "Please provide Vaild Generated Variables.", );