From e3a51398d6b05978f37dea9fdedd0ecbe3510e39 Mon Sep 17 00:00:00 2001 From: "aakash.reddy@techdome.net.in" Date: Tue, 14 Oct 2025 17:50:03 +0530 Subject: [PATCH 1/3] feat: auto-downgrade restriction setup --- .../billing/helpers/stripe-webhook.helper.ts | 5 +- .../services/stripe-subscription.service.ts | 4 + .../identity/repositories/team.repository.ts | 97 ++++++++++++------- src/modules/identity/services/team.service.ts | 2 +- 4 files changed, 68 insertions(+), 40 deletions(-) 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/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index f5326927b..630589d25 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -163,6 +163,7 @@ export class StripeSubscriptionService { async handleSubscriptionUpdated( subscription: any, eventId?: string, + isResubscribed?: boolean, ): Promise { try { // Extract metadata and validate required fields @@ -170,6 +171,9 @@ export class StripeSubscriptionService { subscription.metadata, ["planName", "hubId"], ); + if (isResubscribed) { + await this.downgradeService.removeDowngradeDetails(metadata.hubId); + } if (!isValid) { return; 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; From 8095ae9fe05fa6c85e9b551b146637356d9ca099 Mon Sep 17 00:00:00 2001 From: "aakash.reddy@techdome.net.in" Date: Thu, 16 Oct 2025 00:01:27 +0530 Subject: [PATCH 2/3] feat: autodown grade with email template --- package.json | 1 + pnpm-lock.yaml | 448 +++++++++++++++++- src/modules/billing/billing.module.ts | 3 + .../billing/helpers/payment-email.helper.ts | 62 +++ .../payloads/downgrade-user.payload.ts | 13 + .../repositories/downgradeUser.repository.ts | 18 +- .../downgradeWorkspace.repository.ts | 51 +- .../billing/services/downgrade.service.ts | 13 + .../billing/services/excel-email.service.ts | 157 ++++++ .../billing/services/payment-email.service.ts | 100 ++++ .../services/stripe-subscription.service.ts | 136 +++++- .../common/services/blobStorage.service.ts | 74 ++- .../hubDowngradeRemoveUserEmail.handlebars | 143 ++++++ .../views/hubDowngradedEmail.handlebars | 201 ++++++++ 14 files changed, 1390 insertions(+), 30 deletions(-) create mode 100644 src/modules/billing/services/excel-email.service.ts create mode 100644 src/modules/views/hubDowngradeRemoveUserEmail.handlebars create mode 100644 src/modules/views/hubDowngradedEmail.handlebars diff --git a/package.json b/package.json index f5acbe065..724b41a69 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 773582f3a..9e7ee668f 100644 --- a/src/modules/billing/billing.module.ts +++ b/src/modules/billing/billing.module.ts @@ -26,6 +26,7 @@ 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; @@ -56,6 +57,7 @@ export class BillingModule { DownGradeUserRepository, DownGradeWorkspaceRepository, DownGradeService, + ExcelEmailService, BillingAuditService, PromoCodeService, PricingService, @@ -85,6 +87,7 @@ export class BillingModule { DownGradeUserRepository, DownGradeWorkspaceRepository, DownGradeService, + ExcelEmailService, PricingService, PaymentEmailService, PaymentEmailHelper, 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/payloads/downgrade-user.payload.ts b/src/modules/billing/payloads/downgrade-user.payload.ts index b59cf5c4c..36347e07e 100644 --- a/src/modules/billing/payloads/downgrade-user.payload.ts +++ b/src/modules/billing/payloads/downgrade-user.payload.ts @@ -112,3 +112,16 @@ export class DownGradeUserTourGuideDto extends DownGradeUserGenerateVariableDto @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/repositories/downgradeUser.repository.ts b/src/modules/billing/repositories/downgradeUser.repository.ts index fb640ffb5..ca19dea3b 100644 --- a/src/modules/billing/repositories/downgradeUser.repository.ts +++ b/src/modules/billing/repositories/downgradeUser.repository.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from "@nestjs/common"; +import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Db } from "mongodb"; import { ObjectId, WithId } from "mongodb"; import { User } from "@src/modules/common/models/user.model"; @@ -38,4 +38,20 @@ export class DownGradeUserRepository { .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 index 952b93b4f..04235ed7e 100644 --- a/src/modules/billing/repositories/downgradeWorkspace.repository.ts +++ b/src/modules/billing/repositories/downgradeWorkspace.repository.ts @@ -1,6 +1,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Collections } from "@src/modules/common/enum/database.collection.enum"; -import { Workspace } from "@src/modules/common/models/workspace.model"; +import { UserDto, Workspace } from "@src/modules/common/models/workspace.model"; import { Db, DeleteResult } from "mongodb"; import { ObjectId, WithId } from "mongodb"; @@ -22,6 +22,26 @@ export class DownGradeWorkspaceRepository { 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 @@ -46,4 +66,33 @@ export class DownGradeWorkspaceRepository { } return result.value; } + + async updateWorkspaceUsers( + workspaceId: string, + updatedUsers: UserDto[], + ): Promise { + if (!workspaceId) { + throw new BadRequestException("workspaceId is required."); + } + if (!Array.isArray(updatedUsers)) { + throw new BadRequestException("updatedUsers must be an array."); + } + const _id = new ObjectId(workspaceId); + const updatePayload: Partial = { + users: updatedUsers, + updatedAt: new Date(), + }; + 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:", error); + throw new BadRequestException("Failed to update workspace users."); + } + } } diff --git a/src/modules/billing/services/downgrade.service.ts b/src/modules/billing/services/downgrade.service.ts index ccc7aedf6..ff499ef69 100644 --- a/src/modules/billing/services/downgrade.service.ts +++ b/src/modules/billing/services/downgrade.service.ts @@ -56,6 +56,19 @@ export class DownGradeService { const userFilteredWorkspaces = userData.workspaces.filter( (workspace) => workspace.teamId !== payload.teamId, ); + const workspaces = teamData.workspaces; + for (let workspace of workspaces) { + const workspaceData = await this.downgradeWorkspaceReposiory.get( + workspace.id.toString(), + ); + const updatedUsers = workspaceData.users.filter( + (user) => user.id !== payload.userId, + ); + await this.downgradeWorkspaceReposiory.updateWorkspaceUsers( + workspace.id.toString(), + updatedUsers, + ); + } const userUpdatedParams = { teams: userFilteredTeams, workspaces: userFilteredWorkspaces, 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 630589d25..36d24bea8 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -21,6 +21,12 @@ 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; @@ -41,6 +47,8 @@ export class StripeSubscriptionService { 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, ) {} @@ -203,7 +211,6 @@ export class StripeSubscriptionService { metadata: any, eventId?: string, ): Promise { - console.log("this is the subscription of cancekk------->"); // Get current team state const currentTeam = await this.stripeSubscriptionRepo.findTeamById( metadata.hubId, @@ -585,7 +592,13 @@ export class StripeSubscriptionService { team?.downgrade?.downgradeType === SubscriptionDowngradeType.MANUAL; if (hasDowngradeConfig && isManualDowngrade) { isDowngrading = true; - await this.executeManualDowngrade(team, metadata.hubId); + await this.executeManualDowngrade( + team, + metadata.hubId, + previousPlan, + newPlan, + new Date(), + ); await this.stripeSubscriptionRepo.removeDowngradeDetails( metadata.hubId, ); @@ -971,7 +984,13 @@ export class StripeSubscriptionService { metadata.hubId, ); // Execute manual downgrade AFTER plan change is complete - await this.executeManualDowngrade(team, metadata.hubId); + await this.executeManualDowngrade( + team, + metadata.hubId, + communityPlan.name, + team.plan.name, + new Date(), + ); const previousPlan = team?.plan?.name || "unknown"; // Get cancellation reason if available @@ -2073,6 +2092,9 @@ export class StripeSubscriptionService { private async executeManualDowngrade( team: Team, hubId: string, + previousPlan?: string, + currentPlan?: string, + startDate?: Date, ): Promise { const downgrade = team?.downgrade; if ( @@ -2111,12 +2133,20 @@ export class StripeSubscriptionService { 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), @@ -2151,6 +2181,20 @@ export class StripeSubscriptionService { } } } + 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`, ); @@ -2184,4 +2228,88 @@ export class StripeSubscriptionService { 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/services/blobStorage.service.ts b/src/modules/common/services/blobStorage.service.ts index 598f28897..495fab470 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,11 @@ export class BlobStorageService { "feedbackBlob.container", ); const aiConversationBLobContainer = this.configService.get( - "ai.conversationConatiner" - ) + "ai.conversationConatiner", + ); + // const downgradeHubBlobContainer = this.configService.get( + // "downgradeHub.container", + // ); try { /** @@ -63,10 +67,21 @@ 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; + // } + this.blobServiceClient = BlobServiceClient.fromConnectionString( azureConnectionString, ); @@ -82,6 +97,9 @@ export class BlobStorageService { this.aiContainerClient = this.blobServiceClient.getContainerClient( aiConversationBLobContainer, ); + // this.downGradeHubClient = this.blobServiceClient.getContainerClient( + // downgradeHubBlobContainer, + // ); } catch (e) { console.error(e); } @@ -177,6 +195,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.aiContainerClient) { + throw new BadRequestException( + "Azure blob container is not connected to backend server.", + ); + } + const blockBlobClient = + this.aiContainerClient.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/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 From 6ee9f9114fbcbb29c9ee89a53a4c32b243742390 Mon Sep 17 00:00:00 2001 From: "aakash.reddy@techdome.net.in" Date: Thu, 16 Oct 2025 16:25:38 +0530 Subject: [PATCH 3/3] feat: remove user function updation --- .env.example | 1 + .../payloads/downgrade-user.payload.ts | 2 +- .../repositories/downgradeUser.repository.ts | 39 ++++++++++- .../downgradeWorkspace.repository.ts | 14 +++- .../billing/services/downgrade.service.ts | 68 +++++++++---------- .../services/stripe-subscription.service.ts | 22 +++--- src/modules/common/config/configuration.ts | 7 +- .../common/services/blobStorage.service.ts | 27 ++++---- 8 files changed, 107 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index f7cf5750a..ad8309448 100755 --- a/.env.example +++ b/.env.example @@ -80,6 +80,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/src/modules/billing/payloads/downgrade-user.payload.ts b/src/modules/billing/payloads/downgrade-user.payload.ts index 36347e07e..ee070fee6 100644 --- a/src/modules/billing/payloads/downgrade-user.payload.ts +++ b/src/modules/billing/payloads/downgrade-user.payload.ts @@ -21,7 +21,7 @@ export class DowngradeUserDto { @ApiProperty({ example: "64f03af32e420f7f68055b92" }) @IsMongoId() @IsNotEmpty() - userId: string; + userIds: string[]; } export class TourGuideDto { diff --git a/src/modules/billing/repositories/downgradeUser.repository.ts b/src/modules/billing/repositories/downgradeUser.repository.ts index ca19dea3b..09c1cdb73 100644 --- a/src/modules/billing/repositories/downgradeUser.repository.ts +++ b/src/modules/billing/repositories/downgradeUser.repository.ts @@ -1,9 +1,10 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Db } from "mongodb"; import { ObjectId, WithId } from "mongodb"; -import { User } from "@src/modules/common/models/user.model"; +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 @@ -31,6 +32,42 @@ export class DownGradeUserRepository { 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) diff --git a/src/modules/billing/repositories/downgradeWorkspace.repository.ts b/src/modules/billing/repositories/downgradeWorkspace.repository.ts index 04235ed7e..5a0c40492 100644 --- a/src/modules/billing/repositories/downgradeWorkspace.repository.ts +++ b/src/modules/billing/repositories/downgradeWorkspace.repository.ts @@ -1,6 +1,6 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Collections } from "@src/modules/common/enum/database.collection.enum"; -import { UserDto, Workspace } from "@src/modules/common/models/workspace.model"; +import { AdminDto, UserDto, Workspace } from "@src/modules/common/models/workspace.model"; import { Db, DeleteResult } from "mongodb"; import { ObjectId, WithId } from "mongodb"; @@ -70,6 +70,7 @@ export class DownGradeWorkspaceRepository { async updateWorkspaceUsers( workspaceId: string, updatedUsers: UserDto[], + updatedAdmins?: AdminDto[], ): Promise { if (!workspaceId) { throw new BadRequestException("workspaceId is required."); @@ -77,11 +78,19 @@ export class DownGradeWorkspaceRepository { 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) @@ -91,8 +100,7 @@ export class DownGradeWorkspaceRepository { } return result; } catch (error) { - console.error("Error updating workspace users:", error); - throw new BadRequestException("Failed to update workspace users."); + console.error("Error updating workspace users/admins:", error); } } } diff --git a/src/modules/billing/services/downgrade.service.ts b/src/modules/billing/services/downgrade.service.ts index ff499ef69..cbf74c4f8 100644 --- a/src/modules/billing/services/downgrade.service.ts +++ b/src/modules/billing/services/downgrade.service.ts @@ -25,36 +25,13 @@ export class DownGradeService { const teamFilter = new ObjectId(payload.teamId); const teamData = await this.downgradeTeamRepository.findTeamByTeamId(teamFilter); - const userFilter = new ObjectId(payload.userId); - const userData = - await this.downgradeUserRepository.findUserByUserId(userFilter); - const teamAdmins = [...teamData.admins]; - let userTeamRole; - for (const item of userData.teams) { - if (item.id.toString() === payload.teamId) { - userTeamRole = item.role; - } - } - const teamUser = [...teamData.users]; - let filteredAdmin; - const filteredData = teamUser.filter( - (item) => item.id.toString() !== payload.userId.toString(), - ); - if (userTeamRole === TeamRole.ADMIN) { - filteredAdmin = teamAdmins.filter( - (id: string) => id.toString() !== payload.userId.toString(), - ); - } - const teamUpdatedParams = { - users: filteredData, - admins: userTeamRole === TeamRole.ADMIN ? filteredAdmin : teamAdmins, - }; - const userTeams = [...userData.teams]; - const userFilteredTeams = userTeams.filter( - (item) => item.id.toString() !== payload.teamId.toString(), + const teamUsersData = teamData.users; + const teamAdminsData = teamData.admins; + const updateTeamUsersData = teamUsersData.filter( + (user) => !payload.userIds.includes(user.id), ); - const userFilteredWorkspaces = userData.workspaces.filter( - (workspace) => workspace.teamId !== payload.teamId, + const updateAdminUserData = teamAdminsData.filter( + (admin) => !payload.userIds.includes(admin), ); const workspaces = teamData.workspaces; for (let workspace of workspaces) { @@ -62,21 +39,38 @@ export class DownGradeService { workspace.id.toString(), ); const updatedUsers = workspaceData.users.filter( - (user) => user.id !== payload.userId, + (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, ); } - const userUpdatedParams = { - teams: userFilteredTeams, - workspaces: userFilteredWorkspaces, + 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, }; - await this.downgradeUserRepository.updateUserById( - userFilter, - userUpdatedParams, - ); const data = await this.downgradeTeamRepository.updateTeamById( teamFilter, teamUpdatedParams, diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index 36d24bea8..43029dc21 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -2165,20 +2165,14 @@ export class StripeSubscriptionService { // Remove users not in the downgrade list if (nonDowngradedUsers.length > 0 && teamDowngradeUsers.length > 0) { - for (const userId of nonDowngradedUsers) { - try { - const payload = { - teamId: hubId, - userId: userId, - }; - await this.downgradeService.removeUserFromTeam(payload); - } catch (error) { - console.error( - `Error removing user ${userId} from team ${hubId}:`, - error, - ); - // Continue with other users even if one fails - } + 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( diff --git a/src/modules/common/config/configuration.ts b/src/modules/common/config/configuration.ts index 814a2c486..e150f252d 100644 --- a/src/modules/common/config/configuration.ts +++ b/src/modules/common/config/configuration.ts @@ -141,10 +141,13 @@ export default () => ({ adminEmail: process.env.SELF_HOST_ADMIN_EMAIL, adminPassword: process.env.SELF_HOST_ADMIN_PASSWORD, }, - sparrowProxy:{ - baseUrl: process.env.SPARROW_PROXY_BASE_URL + sparrowProxy: { + baseUrl: process.env.SPARROW_PROXY_BASE_URL, }, trial: { trialPeriod: 14, }, + downgradeHub: { + container: process.env.DOWNGRADE_HUB_BLOB_CONTAINER, + }, }); diff --git a/src/modules/common/services/blobStorage.service.ts b/src/modules/common/services/blobStorage.service.ts index 495fab470..140c41844 100644 --- a/src/modules/common/services/blobStorage.service.ts +++ b/src/modules/common/services/blobStorage.service.ts @@ -33,9 +33,6 @@ export class BlobStorageService { const aiConversationBLobContainer = this.configService.get( "ai.conversationConatiner", ); - // const downgradeHubBlobContainer = this.configService.get( - // "downgradeHub.container", - // ); try { /** @@ -73,14 +70,14 @@ export class BlobStorageService { return; } - // const downgradeHubBlobContainer = this.configService.get( - // "downgradeHub.container", - // ); + const downgradeHubBlobContainer = this.configService.get( + "downgradeHub.container", + ); - // if (!downgradeHubBlobContainer) { - // console.warn("Downgrade Blob is disabled: No container provided."); - // return; - // } + if (!downgradeHubBlobContainer) { + console.warn("Downgrade Blob is disabled: No container provided."); + return; + } this.blobServiceClient = BlobServiceClient.fromConnectionString( azureConnectionString, @@ -97,9 +94,9 @@ export class BlobStorageService { this.aiContainerClient = this.blobServiceClient.getContainerClient( aiConversationBLobContainer, ); - // this.downGradeHubClient = this.blobServiceClient.getContainerClient( - // downgradeHubBlobContainer, - // ); + this.downGradeHubClient = this.blobServiceClient.getContainerClient( + downgradeHubBlobContainer, + ); } catch (e) { console.error(e); } @@ -224,13 +221,13 @@ export class BlobStorageService { contentType = mimetype; } const uniqueFileName = `${fileId}-${storageName}.${fileExtension}`; - if (!this.aiContainerClient) { + if (!this.downGradeHubClient) { throw new BadRequestException( "Azure blob container is not connected to backend server.", ); } const blockBlobClient = - this.aiContainerClient.getBlockBlobClient(uniqueFileName); + this.downGradeHubClient.getBlockBlobClient(uniqueFileName); // Set Content-Type and Content-Disposition headers for Excel download const uploadOptions = { blobHTTPHeaders: {