From ee913f3feb3885a75d94660788f97aa9fd05d074 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 6 Mar 2026 12:55:43 +0530 Subject: [PATCH 01/25] feat: trial-extension-per-hub [SPRW-3087] --- .../billing/services/payment-email.service.ts | 28 +++- .../services/stripe-subscription.service.ts | 16 +- .../controllers/user-admin.hubs.controller.ts | 29 ++++ .../payloads/trial-extension.payload.ts | 24 +++ .../user-admin.hubs.repository.ts | 23 +++ .../services/user-admin.hubs.service.ts | 146 +++++++++++++++++- src/modules/user-admin/user-admin.module.ts | 8 +- 7 files changed, 266 insertions(+), 8 deletions(-) create mode 100644 src/modules/user-admin/payloads/trial-extension.payload.ts diff --git a/src/modules/billing/services/payment-email.service.ts b/src/modules/billing/services/payment-email.service.ts index 57049ad02..72383978b 100644 --- a/src/modules/billing/services/payment-email.service.ts +++ b/src/modules/billing/services/payment-email.service.ts @@ -17,6 +17,7 @@ export enum PaymentEmailType { SUBSCRIPTION_EXPIRED = "subscription_expired", PAYMENT_INFO_UPDATED = "payment_info_updated", DOWNGRADED_TO_COMMUNITY = "downgraded_to_community", + TRIAL_EXTENDED = "trial_extended", } export interface PaymentEmailData { @@ -108,6 +109,9 @@ export class PaymentEmailService { case PaymentEmailType.DOWNGRADED_TO_COMMUNITY: await this.sendDowngradedToCommunityEmail(data); break; + case PaymentEmailType.TRIAL_EXTENDED: + await this.sendTrialExtendedEmail(data); + break; default: console.warn(`Unknown payment email type: ${emailType}`); } @@ -634,7 +638,7 @@ export class PaymentEmailService { date: Date | number, options?: { grace_period?: boolean }, ): string { - let d = typeof date === "number" ? new Date(date * 1000) : new Date(date); + const d = typeof date === "number" ? new Date(date * 1000) : new Date(date); if (options?.grace_period) { d.setDate(d.getDate() + 3); @@ -646,4 +650,26 @@ export class PaymentEmailService { day: "numeric", }); } + + private async sendTrialExtendedEmail(data: PaymentEmailData): Promise { + const transporter = this.emailService.createTransporter(); + + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: data.ownerEmail, + text: "Trial Extended", + template: "trialExtendedEmail", + context: { + firstName: this.extractFirstName(data.ownerName), + hubName: data.hubName, + planName: data.planName, + newTrialEndDate: this.formatDate(data.billingPeriodEnd), + sparrowEmail: this.configService.get("support.sparrowEmail"), + sparrowWebsite: this.configService.get("support.sparrowWebsite"), + }, + subject: `Your trial for ${data.hubName} has been extended`, + }; + + await this.emailService.sendEmail(transporter, mailOptions); + } } diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index a82355ddd..c8eea425c 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -514,6 +514,7 @@ export class StripeSubscriptionService { const isTrialOngoing = trialEndDateStr && new Date(trialEndDateStr).getTime() > Date.now(); + const isTrialExtension = metadata?.trialExtension === "true"; // Initialize or update the licenses object based on billing seats const currentSeats = latestSubscription?.quantity || metadata?.userCount || 1; @@ -630,10 +631,17 @@ export class StripeSubscriptionService { // Create billing details object with successful payment status const billingDetails = { - current_period_start: period.start - ? new Date(period.start * 1000) - : new Date(), - current_period_end: period.end ? new Date(period.end * 1000) : null, + current_period_start: isTrialExtension + ? team.billing?.current_period_start + : period.start + ? new Date(period.start * 1000) + : new Date(), + + current_period_end: isTrialExtension + ? new Date(metadata.trial_end_date) + : period.end + ? new Date(period.end * 1000) + : null, amount_billed: amount, currency: invoice.currency, status: SubscriptionStatus.ACTIVE, diff --git a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts index cf8da42ba..d01f0ac12 100644 --- a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts +++ b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts @@ -41,6 +41,7 @@ import { TeamService } from "@src/modules/identity/services/team.service"; import { ExtendedFastifyRequest } from "@src/types/fastify"; import { CreateOrUpdateAdminHubDto } from "../payloads/hub.payload"; import { SalesEmailService } from "@src/modules/workspace/services/sales-email.service"; +import { ExtendTrialDto } from "../payloads/trial-extension.payload"; @Controller("api/admin") @ApiTags("admin hubs") @@ -379,4 +380,32 @@ export class AdminHubsController { return res.status(statusCode).send(responseData); } } + + @UseGuards(JwtAuthGuard, RolesGuard) + // @Roles("admin") + @Post("hubs/:hubId/trial/extend") + @ApiOperation({ summary: "Extend trial period for a hub" }) + async extendTrial( + @Param("hubId") hubId: string, + @Body() body: ExtendTrialDto, + @Req() request: any, + + @Res() res: FastifyReply, + ) { + console.log("USER:", request.user); + const result = await this.hubsService.extendTrial( + hubId, + body.extensionDays, + body.reason, + body.notifyCustomer, + ); + + const response = new ApiResponseService( + "Trial extended successfully", + HttpStatusCode.OK, + result, + ); + + return res.status(response.httpStatusCode).send(response); + } } diff --git a/src/modules/user-admin/payloads/trial-extension.payload.ts b/src/modules/user-admin/payloads/trial-extension.payload.ts new file mode 100644 index 000000000..5148e7d9d --- /dev/null +++ b/src/modules/user-admin/payloads/trial-extension.payload.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Min, +} from "class-validator"; + +export class ExtendTrialDto { + @ApiProperty({ example: 30 }) + @IsNumber() + @Min(1) + extensionDays: number; + + @ApiProperty({ example: "Customer requested extension" }) + @IsString() + reason: string; + + @ApiProperty({ example: true }) + @IsOptional() + @IsBoolean() + notifyCustomer?: boolean; +} diff --git a/src/modules/user-admin/repositories/user-admin.hubs.repository.ts b/src/modules/user-admin/repositories/user-admin.hubs.repository.ts index e080fd551..c3eff01a2 100644 --- a/src/modules/user-admin/repositories/user-admin.hubs.repository.ts +++ b/src/modules/user-admin/repositories/user-admin.hubs.repository.ts @@ -248,4 +248,27 @@ export class AdminHubsRepository { throw new InternalServerErrorException("Failed to update team feedback"); } } + + async updateHubBillingPeriod( + hubId: string, + newTrialEnd: Date, + ): Promise { + try { + const hubObjectId = new ObjectId(hubId); + + await this.db.collection(Collections.TEAM).updateOne( + { _id: hubObjectId }, + { + $set: { + "billing.current_period_end": newTrialEnd, + }, + }, + ); + } catch (error) { + console.error("Error updating hub billing period:", error); + throw new InternalServerErrorException( + "Failed to update hub billing period", + ); + } + } } diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index 52d9276ac..b7f52946a 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; import { ObjectId } from "mongodb"; import { AdminHubsRepository } from "../repositories/user-admin.hubs.repository"; @@ -6,6 +10,19 @@ import { AdminWorkspaceRepository } from "../repositories/user-admin.workspace.r import { TeamRole } from "@src/modules/common/enum/roles.enum"; import { PlanName } from "@src/modules/common/enum/plan.enum"; import { UserRepository } from "@src/modules/identity/repositories/user.repository"; +import { StripeSubscriptionService } from "@src/modules/billing/services/stripe-subscription.service"; +import { BillingAuditService } from "@src/modules/billing/services/billing-audit.service"; +import { + PaymentEmailService, + PaymentEmailType, +} from "@src/modules/billing/services/payment-email.service"; +import { + BillingActorType, + BillingSource, + PaymentProvider, +} from "@src/modules/common/enum/billing.enum"; +import { ConfigService } from "@nestjs/config"; +import { HttpService } from "@nestjs/axios"; interface SortOptions { sortBy: string; @@ -18,6 +35,11 @@ export class AdminHubsService { private readonly teamsRepo: AdminHubsRepository, private readonly workspaceRepo: AdminWorkspaceRepository, private readonly userRepo: UserRepository, + private readonly stripeSubscriptionService: StripeSubscriptionService, + private readonly billingAuditService: BillingAuditService, + private readonly paymentEmailService: PaymentEmailService, + private readonly configService: ConfigService, + private readonly httpService: HttpService, ) {} async getHubsForUser(userId: string) { @@ -39,7 +61,7 @@ export class AdminHubsService { role: matchedUser?.role, users: team.users, workspaces: team.workspaces, - plan: team?.plan?.name + plan: team?.plan?.name, }; }); } @@ -304,4 +326,124 @@ export class AdminHubsService { return { success: true }; } + + async extendTrial( + hubId: string, + extensionDays: number, + reason: string, + notifyCustomer?: boolean, + ) { + // Validate hub exists + const team = await this.teamsRepo.findHubById(hubId); + + if (!team) { + throw new NotFoundException("Hub not found"); + } + + // Validate trial status + if (team.billing?.in_trial !== true) { + throw new BadRequestException("Hub is not currently in trial"); + } + + // Validate extension limits + const MAX_TRIAL_EXTENSION_DAYS = 100; + + if (extensionDays <= 0 || extensionDays > MAX_TRIAL_EXTENSION_DAYS) { + throw new BadRequestException( + `Trial extension must be between 1 and ${MAX_TRIAL_EXTENSION_DAYS} days`, + ); + } + + // Get Stripe subscription + const stripeProvider = team.billing?.paymentProviders?.find( + (p: any) => p.provider === PaymentProvider.STRIPE, + ); + + if (!stripeProvider?.subscriptionId) { + throw new BadRequestException("Stripe subscription not found for hub"); + } + + const subscriptionId = stripeProvider.subscriptionId; + + let currentTrialEnd: Date; + + // Local development bypass + if (subscriptionId.startsWith("sub_test")) { + currentTrialEnd = new Date(team.billing.current_period_end); + } else { + const subscription = + await this.stripeSubscriptionService["stripeService"].getSubscription( + subscriptionId, + ); + + if (!subscription?.trial_end) { + throw new BadRequestException( + "Subscription does not have an active trial", + ); + } + + currentTrialEnd = new Date(subscription.trial_end * 1000); + } + + // Calculate new trial end + const newTrialEnd = new Date( + currentTrialEnd.getTime() + extensionDays * 24 * 60 * 60 * 1000, + ); + + // Update Stripe if real subscription + if (!subscriptionId.startsWith("sub_test")) { + await this.stripeSubscriptionService["stripeService"].updateSubscription( + subscriptionId, + undefined, + { + hubId: hubId, + planName: team.plan?.name, + trial_end_date: newTrialEnd.toISOString(), + trialExtension: "true", + extensionDays: extensionDays.toString(), + }, + ); + } + + // Update database billing + await this.teamsRepo.updateHubBillingPeriod(hubId, newTrialEnd); + console.log("ADMIN API updating billing to:", newTrialEnd); + + // Record audit event + await this.billingAuditService.recordTrialStarted( + hubId, + team.plan?.name, + { + trialEndDate: newTrialEnd, + seats: team.billing?.seats || 1, + }, + { + actor: { + type: BillingActorType.SYSTEM, + name: "Admin Trial Extension", + }, + source: BillingSource.API_CALL, + reason, + }, + ); + + // Optional email notification + if (notifyCustomer) { + try { + await this.httpService.axiosRef.post( + `${this.configService.get("app.baseURL")}/api/user-trial-confirmation-mail/${hubId}`, + ); + } catch (error) { + console.warn("Failed to send trial confirmation email", error); + } + } + + // Return response + return { + hubId, + previousTrialEnd: currentTrialEnd, + newTrialEnd, + extensionDays, + }; + } } diff --git a/src/modules/user-admin/user-admin.module.ts b/src/modules/user-admin/user-admin.module.ts index b8ea9870a..adf5c7392 100644 --- a/src/modules/user-admin/user-admin.module.ts +++ b/src/modules/user-admin/user-admin.module.ts @@ -23,13 +23,19 @@ import { AdminUsersController } from "./controllers/user-admin.enterprise-user.c import { AdminUsersService } from "./services/user-admin.enterprise-user.service"; import { AdminUpdatesRepository } from "./repositories/user-admin.updates.repository"; import { BillingModule } from "../billing/billing.module"; +import { HttpModule } from "@nestjs/axios"; /** * Admin Module provides all necessary services, handlers, repositories, * and controllers related to the admin dashboard functionality. */ @Module({ - imports: [IdentityModule, WorkspaceModule, BillingModule.register()], + imports: [ + IdentityModule, + WorkspaceModule, + BillingModule.register(), + HttpModule, + ], providers: [ WorkspaceService, JwtService, From 8db822d82cbc9c6743fd301cf22c8162258307ac Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 9 Mar 2026 16:07:32 +0530 Subject: [PATCH 02/25] feat: backed plan adddition per hub [SPRW-3087] --- .../billing/services/payment-email.service.ts | 25 ++++ .../controllers/user-admin.hubs.controller.ts | 28 ++++ .../user-admin/payloads/add-plan.payload.ts | 21 +++ .../user-admin.hubs.repository.ts | 36 +++++ .../services/user-admin.hubs.service.ts | 123 ++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 src/modules/user-admin/payloads/add-plan.payload.ts diff --git a/src/modules/billing/services/payment-email.service.ts b/src/modules/billing/services/payment-email.service.ts index 72383978b..baa8ae46b 100644 --- a/src/modules/billing/services/payment-email.service.ts +++ b/src/modules/billing/services/payment-email.service.ts @@ -18,6 +18,7 @@ export enum PaymentEmailType { PAYMENT_INFO_UPDATED = "payment_info_updated", DOWNGRADED_TO_COMMUNITY = "downgraded_to_community", TRIAL_EXTENDED = "trial_extended", + PLAN_ADDED = "plan_added", } export interface PaymentEmailData { @@ -112,6 +113,9 @@ export class PaymentEmailService { case PaymentEmailType.TRIAL_EXTENDED: await this.sendTrialExtendedEmail(data); break; + case PaymentEmailType.PLAN_ADDED: + await this.sendPlanAddedEmail(data); + break; default: console.warn(`Unknown payment email type: ${emailType}`); } @@ -672,4 +676,25 @@ export class PaymentEmailService { await this.emailService.sendEmail(transporter, mailOptions); } + + private async sendPlanAddedEmail(data: PaymentEmailData): Promise { + const transporter = this.emailService.createTransporter(); + + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: data.ownerEmail, + text: "Plan Updated", + template: "planAddedEmail", + context: { + firstName: this.extractFirstName(data.ownerName), + hubName: data.hubName, + planName: data.planName, + sparrowEmail: this.configService.get("support.sparrowEmail"), + sparrowWebsite: this.configService.get("support.sparrowWebsite"), + }, + subject: `Your hub ${data.hubName} has been upgraded to ${data.planName}`, + }; + + await this.emailService.sendEmail(transporter, mailOptions); + } } diff --git a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts index d01f0ac12..b95128d3c 100644 --- a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts +++ b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts @@ -42,6 +42,7 @@ import { ExtendedFastifyRequest } from "@src/types/fastify"; import { CreateOrUpdateAdminHubDto } from "../payloads/hub.payload"; import { SalesEmailService } from "@src/modules/workspace/services/sales-email.service"; import { ExtendTrialDto } from "../payloads/trial-extension.payload"; +import { AddPlanDto } from "../payloads/add-plan.payload"; @Controller("api/admin") @ApiTags("admin hubs") @@ -408,4 +409,31 @@ export class AdminHubsController { return res.status(response.httpStatusCode).send(response); } + + @UseGuards(JwtAuthGuard, RolesGuard) + // @Roles("admin") + @Post("hubs/:hubId/plans/add") + @ApiOperation({ summary: "Add a plan to a hub from backend" }) + async addPlan( + @Param("hubId") hubId: string, + @Body() body: AddPlanDto, + @Req() request: any, + @Res() res: FastifyReply, + ) { + const result = await this.hubsService.addPlanToHub( + hubId, + body.planId, + body.effectiveDate, + body.billingCycle, + body.notes, + ); + + const response = new ApiResponseService( + "Plan added successfully", + HttpStatusCode.OK, + result, + ); + + return res.status(response.httpStatusCode).send(response); + } } diff --git a/src/modules/user-admin/payloads/add-plan.payload.ts b/src/modules/user-admin/payloads/add-plan.payload.ts new file mode 100644 index 000000000..67096b5e5 --- /dev/null +++ b/src/modules/user-admin/payloads/add-plan.payload.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsOptional, IsDateString } from "class-validator"; + +export class AddPlanDto { + @ApiProperty({ example: "premium_plan_123" }) + @IsString() + planId: string; + + @ApiProperty({ example: "2024-01-15" }) + @IsDateString() + effectiveDate: string; + + @ApiProperty({ example: "monthly" }) + @IsString() + billingCycle: string; + + @ApiProperty({ example: "Adding premium features" }) + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/user-admin/repositories/user-admin.hubs.repository.ts b/src/modules/user-admin/repositories/user-admin.hubs.repository.ts index c3eff01a2..0c93534d3 100644 --- a/src/modules/user-admin/repositories/user-admin.hubs.repository.ts +++ b/src/modules/user-admin/repositories/user-admin.hubs.repository.ts @@ -271,4 +271,40 @@ export class AdminHubsRepository { ); } } + + async findPlanById(planId: string) { + try { + const planObjectId = new ObjectId(planId); + + const plan = await this.db + .collection(Collections.PLAN) + .findOne({ _id: planObjectId, active: true }); + + return plan; + } catch (error) { + console.error("Error fetching plan:", error); + throw new InternalServerErrorException("Failed to fetch plan"); + } + } + + async updateHubPlan(hubId: string, plan: any): Promise { + try { + const hubObjectId = new ObjectId(hubId); + + await this.db.collection(Collections.TEAM).updateOne( + { _id: hubObjectId }, + { + $set: { + plan: { + ...plan, + id: plan._id, + }, + }, + }, + ); + } catch (error) { + console.error("Error updating hub plan:", error); + throw new InternalServerErrorException("Failed to update hub plan"); + } + } } diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index b7f52946a..89a89d584 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -446,4 +446,127 @@ export class AdminHubsService { extensionDays, }; } + + async addPlanToHub( + hubId: string, + planId: string, + effectiveDate: string, + billingCycle: string, + notes?: string, + ) { + // Validate hub exists + const team = await this.teamsRepo.findHubById(hubId); + + if (!team) { + throw new NotFoundException("Hub not found"); + } + + // Validate plan exists + const plan = await this.teamsRepo.findPlanById(planId); + + if (!plan) { + throw new BadRequestException("Plan not found or inactive"); + } + //Prevent adding same plan again + const currentPlan = team.plan?.name; + + if (currentPlan === plan.name) { + throw new BadRequestException(`Hub already has ${plan.name} plan`); + } + + // Calculate proration + let proratedAmount = 0; + + const billingStart = new Date(team.billing?.current_period_start); + const billingEnd = new Date(team.billing?.current_period_end); + const effective = new Date(effectiveDate); + + const totalPeriod = billingEnd.getTime() - billingStart.getTime(); + + const remainingPeriod = billingEnd.getTime() - effective.getTime(); + + if (remainingPeriod > 0) { + const remainingRatio = remainingPeriod / totalPeriod; + + const planPrice = plan.price || 0; + + proratedAmount = Math.round(planPrice * remainingRatio); + } + // Get Stripe subscription + const stripeProvider = team.billing?.paymentProviders?.find( + (p: any) => p.provider === PaymentProvider.STRIPE, + ); + + const subscriptionId = stripeProvider?.subscriptionId; + + // If no Stripe subscription (community/self-host hubs) + if (!subscriptionId) { + proratedAmount = 0; + } + + // Update Stripe subscription with new plan + if (subscriptionId && !subscriptionId.startsWith("sub_test")) { + await this.stripeSubscriptionService["stripeService"].updateSubscription( + subscriptionId, + undefined, + { + hubId: hubId, + newPlan: plan.name, + billingCycle, + effectiveDate, + notes, + }, + ); + } + + // Update hub plan in database + await this.teamsRepo.updateHubPlan(hubId, plan); + + // Record billing audit + await this.billingAuditService.recordSubscriptionCreated( + hubId, + plan.name, + { + proratedAmount, + billingCycle, + effectiveDate, + }, + { + actor: { + type: BillingActorType.SYSTEM, + name: "Admin Plan Addition", + }, + source: BillingSource.API_CALL, + reason: notes || "Admin added plan", + }, + ); + + // Send notification email + try { + const owner = team.users?.find((u: any) => u.role === "owner"); + + if (owner) { + await this.paymentEmailService.sendPaymentEmail( + PaymentEmailType.PLAN_ADDED, + { + ownerEmail: owner.email, + ownerName: owner.name, + hubName: team.name, + planName: plan.name, + }, + ); + } + } catch (error) { + console.warn("Failed to send plan addition email", error); + } + + return { + hubId, + previousPlan: currentPlan, + newPlan: plan.name, + effectiveDate, + billingCycle, + proratedAmount, + }; + } } From 348f27f11b3c4d242309ef044a2b160c32d5be83 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 9 Mar 2026 16:46:12 +0530 Subject: [PATCH 03/25] feat: plan change per hub [SPRW-3087] --- .../controllers/user-admin.hubs.controller.ts | 28 ++++ .../payloads/change-plan.payload.ts | 24 ++++ .../services/user-admin.hubs.service.ts | 135 ++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/modules/user-admin/payloads/change-plan.payload.ts diff --git a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts index b95128d3c..ef28412f9 100644 --- a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts +++ b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts @@ -43,6 +43,7 @@ import { CreateOrUpdateAdminHubDto } from "../payloads/hub.payload"; import { SalesEmailService } from "@src/modules/workspace/services/sales-email.service"; import { ExtendTrialDto } from "../payloads/trial-extension.payload"; import { AddPlanDto } from "../payloads/add-plan.payload"; +import { ChangePlanDto } from "../payloads/change-plan.payload"; @Controller("api/admin") @ApiTags("admin hubs") @@ -436,4 +437,31 @@ export class AdminHubsController { return res.status(response.httpStatusCode).send(response); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Put("hubs/:hubId/plan/change") + @ApiOperation({ summary: "Change hub subscription plan" }) + async changePlan( + @Param("hubId") hubId: string, + @Body() body: ChangePlanDto, + @Req() request: any, + @Res() res: FastifyReply, + ) { + const result = await this.hubsService.changeHubPlan( + hubId, + body.currentPlanId, + body.newPlanId, + body.changeType, + body.effectiveDate, + body.prorate, + ); + + const response = new ApiResponseService( + "Plan changed successfully", + HttpStatusCode.OK, + result, + ); + + return res.status(response.httpStatusCode).send(response); + } } diff --git a/src/modules/user-admin/payloads/change-plan.payload.ts b/src/modules/user-admin/payloads/change-plan.payload.ts new file mode 100644 index 000000000..b2b5e2b28 --- /dev/null +++ b/src/modules/user-admin/payloads/change-plan.payload.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, IsBoolean, IsDateString } from "class-validator"; + +export class ChangePlanDto { + @ApiProperty({ example: "69a57d17ce77429c5623abcb" }) + @IsString() + currentPlanId: string; + + @ApiProperty({ example: "69a57d17ce77429c5623abcc" }) + @IsString() + newPlanId: string; + + @ApiProperty({ example: "upgrade" }) + @IsString() + changeType: string; + + @ApiProperty({ example: "2024-02-01" }) + @IsDateString() + effectiveDate: string; + + @ApiProperty({ example: true }) + @IsBoolean() + prorate: boolean; +} diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index 89a89d584..a628cdd2b 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -569,4 +569,139 @@ export class AdminHubsService { proratedAmount, }; } + + async changeHubPlan( + hubId: string, + currentPlanId: string, + newPlanId: string, + changeType: string, + effectiveDate: string, + prorate: boolean, + ) { + // Validate hub + const team = await this.teamsRepo.findHubById(hubId); + + if (!team) { + throw new NotFoundException("Hub not found"); + } + + // Fetch plans + const currentPlan = await this.teamsRepo.findPlanById(currentPlanId); + const newPlan = await this.teamsRepo.findPlanById(newPlanId); + + if (!currentPlan || !newPlan) { + throw new BadRequestException("Invalid plan"); + } + + // Validate hub current plan + if (team.plan?.id.toString() !== currentPlanId) { + throw new BadRequestException( + "Hub does not currently have the specified plan", + ); + } + + // Determine plan hierarchy + const currentPlanTier = currentPlan.limits?.workspacesPerHub?.value || 0; + + const newPlanTier = newPlan.limits?.workspacesPerHub?.value || 0; + + if (changeType === "upgrade" && newPlanTier <= currentPlanTier) { + throw new BadRequestException( + "New plan must be higher than current plan for upgrade", + ); + } + + if (changeType === "downgrade" && newPlanTier >= currentPlanTier) { + throw new BadRequestException( + "New plan must be lower than current plan for downgrade", + ); + } + + let proratedAmount = 0; + + if (prorate) { + const billingStart = new Date(team.billing?.current_period_start); + const billingEnd = new Date(team.billing?.current_period_end); + const effective = new Date(effectiveDate); + + const totalPeriod = billingEnd.getTime() - billingStart.getTime(); + + const remainingPeriod = billingEnd.getTime() - effective.getTime(); + + if (remainingPeriod > 0) { + const ratio = remainingPeriod / totalPeriod; + + const currentPrice = currentPlan.price || 0; + const newPrice = newPlan.price || 0; + + proratedAmount = Math.round((newPrice - currentPrice) * ratio); + } + } + + const stripeProvider = team.billing?.paymentProviders?.find( + (p: any) => p.provider === PaymentProvider.STRIPE, + ); + + const subscriptionId = stripeProvider?.subscriptionId; + + if (subscriptionId && !subscriptionId.startsWith("sub_test")) { + await this.stripeSubscriptionService["stripeService"].updateSubscription( + subscriptionId, + undefined, + { + hubId, + newPlan: newPlan.name, + changeType, + proratedAmount, + effectiveDate, + }, + ); + } + + await this.teamsRepo.updateHubPlan(hubId, newPlan); + + await this.billingAuditService.recordSubscriptionCreated( + hubId, + newPlan.name, + { + changeType, + proratedAmount, + effectiveDate, + }, + { + actor: { + type: BillingActorType.SYSTEM, + name: "Admin Plan Change", + }, + source: BillingSource.API_CALL, + reason: `Admin ${changeType}`, + }, + ); + try { + const owner = team.users?.find((u: any) => u.role === "owner"); + + if (owner) { + await this.paymentEmailService.sendPaymentEmail( + PaymentEmailType.PLAN_ADDED, + { + ownerEmail: owner.email, + ownerName: owner.name, + hubName: team.name, + planName: newPlan.name, + }, + ); + } + } catch (error) { + console.warn("Failed to send plan change email", error); + } + + return { + hubId, + previousPlan: currentPlan.name, + newPlan: newPlan.name, + changeType, + effectiveDate, + proratedAmount, + }; + } } From db5e1c438c1a317e1f09871703be2df8276a7725 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 9 Mar 2026 17:41:49 +0530 Subject: [PATCH 04/25] fix: extend trial fix [SPRW-3087] --- .../services/user-admin.hubs.service.ts | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index a628cdd2b..c4b0ca5c1 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -346,11 +346,29 @@ export class AdminHubsService { } // Validate extension limits - const MAX_TRIAL_EXTENSION_DAYS = 100; + const MAX_TOTAL_TRIAL_DAYS = 180; - if (extensionDays <= 0 || extensionDays > MAX_TRIAL_EXTENSION_DAYS) { + const currentTrialEnd = new Date(team.billing.current_period_end); + + if (!currentTrialEnd) { + throw new BadRequestException("Trial end date not found"); + } + + // Calculate new trial end + const newTrialEnd = new Date( + currentTrialEnd.getTime() + extensionDays * 24 * 60 * 60 * 1000, + ); + + // Validate total trial duration + const trialStart = new Date(team.billing.current_period_start); + + const maxAllowedTrialEnd = new Date( + trialStart.getTime() + MAX_TOTAL_TRIAL_DAYS * 24 * 60 * 60 * 1000, + ); + + if (newTrialEnd > maxAllowedTrialEnd) { throw new BadRequestException( - `Trial extension must be between 1 and ${MAX_TRIAL_EXTENSION_DAYS} days`, + `Trial cannot exceed ${MAX_TOTAL_TRIAL_DAYS} days from start`, ); } @@ -365,31 +383,10 @@ export class AdminHubsService { const subscriptionId = stripeProvider.subscriptionId; - let currentTrialEnd: Date; - - // Local development bypass - if (subscriptionId.startsWith("sub_test")) { - currentTrialEnd = new Date(team.billing.current_period_end); - } else { - const subscription = - await this.stripeSubscriptionService["stripeService"].getSubscription( - subscriptionId, - ); - - if (!subscription?.trial_end) { - throw new BadRequestException( - "Subscription does not have an active trial", - ); - } - - currentTrialEnd = new Date(subscription.trial_end * 1000); + if (!currentTrialEnd) { + throw new BadRequestException("Trial end date not found"); } - // Calculate new trial end - const newTrialEnd = new Date( - currentTrialEnd.getTime() + extensionDays * 24 * 60 * 60 * 1000, - ); - // Update Stripe if real subscription if (!subscriptionId.startsWith("sub_test")) { await this.stripeSubscriptionService["stripeService"].updateSubscription( From 8075e2441715d16cefbf09985f936da0727cd943 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 9 Mar 2026 19:19:55 +0530 Subject: [PATCH 05/25] fix: extend email fix [SPRW-3087] --- .../billing/services/payment-email.service.ts | 33 ++-- .../services/user-admin.hubs.service.ts | 20 +- .../views/trialExtendedEmail.handlebars | 187 ++++++++++++++++++ 3 files changed, 220 insertions(+), 20 deletions(-) create mode 100644 src/modules/views/trialExtendedEmail.handlebars diff --git a/src/modules/billing/services/payment-email.service.ts b/src/modules/billing/services/payment-email.service.ts index baa8ae46b..ad6f97056 100644 --- a/src/modules/billing/services/payment-email.service.ts +++ b/src/modules/billing/services/payment-email.service.ts @@ -657,24 +657,25 @@ export class PaymentEmailService { private async sendTrialExtendedEmail(data: PaymentEmailData): Promise { const transporter = this.emailService.createTransporter(); + const emailsToSend = data.sendEmails || [data.ownerEmail]; - const mailOptions = { - from: this.configService.get("app.senderEmail"), - to: data.ownerEmail, - text: "Trial Extended", - template: "trialExtendedEmail", - context: { - firstName: this.extractFirstName(data.ownerName), - hubName: data.hubName, - planName: data.planName, - newTrialEndDate: this.formatDate(data.billingPeriodEnd), - sparrowEmail: this.configService.get("support.sparrowEmail"), - sparrowWebsite: this.configService.get("support.sparrowWebsite"), - }, - subject: `Your trial for ${data.hubName} has been extended`, - }; + for (const email of emailsToSend) { + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: email, + template: "trialExtendedEmail", + context: { + hubName: data.hubName, + planName: data.planName, + trialStart: this.formatDate(data.billingPeriodStart), + trialEnd: this.formatDate(data.billingPeriodEnd), + seats: data.totalSeats, + }, + subject: `Your trial for ${data.hubName} has been extended`, + }; - await this.emailService.sendEmail(transporter, mailOptions); + await this.emailService.sendEmail(transporter, mailOptions); + } } private async sendPlanAddedEmail(data: PaymentEmailData): Promise { diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index c4b0ca5c1..fa99e6991 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -427,11 +427,23 @@ export class AdminHubsService { // Optional email notification if (notifyCustomer) { try { - await this.httpService.axiosRef.post( - `${this.configService.get("app.baseURL")}/api/user-trial-confirmation-mail/${hubId}`, - ); + const emails = team.users?.map((u: any) => u.email) || []; + + if (emails) { + await this.paymentEmailService.sendPaymentEmail( + PaymentEmailType.TRIAL_EXTENDED, + { + sendEmails: emails, + hubName: team.name, + planName: team.plan?.name, + billingPeriodStart: team.billing.current_period_start, + billingPeriodEnd: newTrialEnd, + totalSeats: team.billing?.seats || 1, + }, + ); + } } catch (error) { - console.warn("Failed to send trial confirmation email", error); + console.warn("Failed to send trial extension email", error); } } diff --git a/src/modules/views/trialExtendedEmail.handlebars b/src/modules/views/trialExtendedEmail.handlebars new file mode 100644 index 000000000..e5f4dca93 --- /dev/null +++ b/src/modules/views/trialExtendedEmail.handlebars @@ -0,0 +1,187 @@ +{{!< layoutName}} + + + + + + Trial Email + + + + + + +
+
+
+ + {{> headerV2}} + + + + +
+ + + + +
+
+

+ Your Sparrow Trial Has Been Extended +

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

+ Whatโ€™s Next? +

+

Click the button below to navigate to your hub and start exploring:

+
+ + + + +
+ +
+
+
+
+
+ + + + +
+ +
+ + +
+ + + {{> footerV2}} + + +
+
+
+ + + \ No newline at end of file From 9844043dbb3ceecd92e955fa68e0465f04dd0bba Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 10 Mar 2026 14:24:40 +0530 Subject: [PATCH 06/25] fix: plan addition and plan change email fix [SPRW-3087] --- .../billing/services/payment-email.service.ts | 14 +++++++-- .../services/user-admin.hubs.service.ts | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/modules/billing/services/payment-email.service.ts b/src/modules/billing/services/payment-email.service.ts index ad6f97056..88c9b9cef 100644 --- a/src/modules/billing/services/payment-email.service.ts +++ b/src/modules/billing/services/payment-email.service.ts @@ -52,6 +52,10 @@ export interface PaymentEmailData { workspaces?: any; users?: any; sendEmails?: string[]; + price?: number; + features?: string[]; + upgradeDate?: string; + nextBillingDate?: string; } @Injectable() @@ -685,13 +689,17 @@ export class PaymentEmailService { from: this.configService.get("app.senderEmail"), to: data.ownerEmail, text: "Plan Updated", - template: "planAddedEmail", + template: "planUpgradedEmail", context: { firstName: this.extractFirstName(data.ownerName), hubName: data.hubName, - planName: data.planName, + newPlanName: data.planName, + features: data.features, + price: data.price, + interval: data.interval, + upgradeDate: data.upgradeDate, + nextBillingDate: data.nextBillingDate, sparrowEmail: this.configService.get("support.sparrowEmail"), - sparrowWebsite: this.configService.get("support.sparrowWebsite"), }, subject: `Your hub ${data.hubName} has been upgraded to ${data.planName}`, }; diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index fa99e6991..aa96f43d5 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -562,6 +562,22 @@ export class AdminHubsService { ownerName: owner.name, hubName: team.name, planName: plan.name, + + price: plan.price || 0, + interval: billingCycle || "month", + + upgradeDate: new Date(effectiveDate).toDateString(), + + nextBillingDate: team.billing?.current_period_end + ? new Date(team.billing.current_period_end).toDateString() + : "", + + features: [ + `Up to ${plan.limits?.workspacesPerHub?.value || "multiple"} workspaces`, + "Unlimited collaborators", + "Private hubs", + "Unlimited collections", + ], }, ); } @@ -697,6 +713,21 @@ export class AdminHubsService { ownerName: owner.name, hubName: team.name, planName: newPlan.name, + price: newPlan.price || 0, + interval: "month", + + upgradeDate: new Date(effectiveDate).toDateString(), + + nextBillingDate: team.billing?.current_period_end + ? new Date(team.billing.current_period_end).toDateString() + : "", + + features: [ + `Up to ${newPlan.limits?.workspacesPerHub?.value || "multiple"} workspaces`, + "Unlimited collaborators", + "Private hubs", + "Unlimited collections", + ], }, ); } From a0e582e096a433e760fd1348a2e5a38d686d843c Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 10 Mar 2026 15:27:09 +0530 Subject: [PATCH 07/25] fix: swagger [SPRW-3087] --- .../controllers/user-admin.hubs.controller.ts | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts index ef28412f9..268008932 100644 --- a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts +++ b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts @@ -22,6 +22,7 @@ import { ApiConsumes, ApiBody, ApiResponse, + ApiParam, } from "@nestjs/swagger"; import { FastifyReply } from "fastify"; import { ApiResponseService } from "@src/modules/common/services/api-response.service"; @@ -385,8 +386,22 @@ export class AdminHubsController { @UseGuards(JwtAuthGuard, RolesGuard) // @Roles("admin") - @Post("hubs/:hubId/trial/extend") + @ApiBearerAuth() @ApiOperation({ summary: "Extend trial period for a hub" }) + @ApiParam({ + name: "hubId", + description: "Unique Hub ID", + example: "69ae736e7ef406283329e75d", + }) + @ApiBody({ + type: ExtendTrialDto, + description: "Trial extension request body", + }) + @ApiResponse({ + status: 200, + description: "Trial extended successfully", + }) + @Post("hubs/:hubId/trial/extend") async extendTrial( @Param("hubId") hubId: string, @Body() body: ExtendTrialDto, @@ -413,8 +428,22 @@ export class AdminHubsController { @UseGuards(JwtAuthGuard, RolesGuard) // @Roles("admin") + @ApiBearerAuth() + @ApiOperation({ summary: "Add a subscription plan to a hub" }) + @ApiParam({ + name: "hubId", + description: "Hub ID", + example: "69ae736e7ef406283329e75d", + }) + @ApiBody({ + type: AddPlanDto, + description: "Plan addition request body", + }) + @ApiResponse({ + status: 200, + description: "Plan added successfully", + }) @Post("hubs/:hubId/plans/add") - @ApiOperation({ summary: "Add a plan to a hub from backend" }) async addPlan( @Param("hubId") hubId: string, @Body() body: AddPlanDto, @@ -439,8 +468,22 @@ export class AdminHubsController { } @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth() + @ApiOperation({ summary: "Change hub subscription plan (upgrade/downgrade)" }) + @ApiParam({ + name: "hubId", + description: "Hub ID", + example: "69ae736e7ef406283329e75d", + }) + @ApiBody({ + type: ChangePlanDto, + description: "Plan change request body", + }) + @ApiResponse({ + status: 200, + description: "Plan changed successfully", + }) @Put("hubs/:hubId/plan/change") - @ApiOperation({ summary: "Change hub subscription plan" }) async changePlan( @Param("hubId") hubId: string, @Body() body: ChangePlanDto, From d09102f32568803444db3b9fc1c57090be483c9c Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 13 Mar 2026 18:39:58 +0530 Subject: [PATCH 08/25] feat: weekly-digest schedular and service infra [SPRW-3110] --- .../schedulers/weekly-digest.scheduler.ts | 24 ++++++++++++ .../services/weekly-digest.service.ts | 39 +++++++++++++++++++ src/modules/workspace/workspace.module.ts | 4 ++ 3 files changed, 67 insertions(+) create mode 100644 src/modules/workspace/schedulers/weekly-digest.scheduler.ts create mode 100644 src/modules/workspace/services/weekly-digest.service.ts diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts new file mode 100644 index 000000000..a7eb2c63a --- /dev/null +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -0,0 +1,24 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { Cron } from "@nestjs/schedule"; +import { WeeklyDigestService } from "../services/weekly-digest.service"; + +@Injectable() +export class WeeklyDigestScheduler { + private readonly logger = new Logger(WeeklyDigestScheduler.name); + + constructor(private readonly weeklyDigestService: WeeklyDigestService) {} + + /** + * Runs every Monday at 08:00 AM + */ + @Cron("*/10 * * * * *") + async handleWeeklyDigest() { + this.logger.log("Starting Weekly Digest Job..."); + + try { + await this.weeklyDigestService.processWeeklyDigest(); + } catch (error) { + this.logger.error("Weekly Digest job failed", error); + } + } +} diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts new file mode 100644 index 000000000..5a29b5763 --- /dev/null +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from "@nestjs/common"; + +@Injectable() +export class WeeklyDigestService { + private readonly logger = new Logger(WeeklyDigestService.name); + + private getLastWeekRange() { + const now = new Date(); + + // Start = last Monday + const start = new Date(now); + start.setDate(now.getDate() - now.getDay() - 6); + start.setHours(0, 0, 0, 0); + + // End = last Sunday + const end = new Date(now); + end.setDate(now.getDate() - now.getDay()); + end.setHours(23, 59, 59, 999); + + return { start, end }; + } + + async processWeeklyDigest() { + this.logger.log("Processing weekly digest emails..."); + + const { start, end } = this.getLastWeekRange(); + + this.logger.log( + `Weekly range: ${start.toISOString()} - ${end.toISOString()}`, + ); + + // next steps will use this + + // Step 1: get all users + // Step 2: fetch weekly metrics + // Step 3: build email payload + // Step 4: send email + } +} diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 10277409c..69c781444 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -58,6 +58,7 @@ import { TestflowService } from "./services/testflow.service"; import { TeamUserService } from "../identity/services/team-user.service"; import { SalesEmailService } from "./services/sales-email.service"; import { PricingService } from "./services/pricing.repository"; +import { WeeklyDigestService } from "./services/weekly-digest.service"; // ---- Gateway import { @@ -83,6 +84,7 @@ import { TestflowSchedulerService } from "./services/testflow-schedular.service" import { TestflowRunService } from "./services/testflow-run.service"; import { ScheduleModule } from "@nestjs/schedule"; import { TestflowDataSetService } from "./services/testflow-dataset.service"; +import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; /** * Workspace Module provides all necessary services, handlers, repositories, @@ -147,6 +149,8 @@ import { TestflowDataSetService } from "./services/testflow-dataset.service"; PricingService, PricingRepository, AiConsumptionScheduler, + WeeklyDigestScheduler, + WeeklyDigestService, ], exports: [ CollectionService, From 654c753bb47f5918097c1a4990ad22073a60ebd9 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 13 Mar 2026 19:24:06 +0530 Subject: [PATCH 09/25] feat: user fetch added [SPRW-3110] --- .../identity/repositories/user.repository.ts | 16 +++- .../services/weekly-digest.service.ts | 79 ++++++++++++++++--- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/modules/identity/repositories/user.repository.ts b/src/modules/identity/repositories/user.repository.ts index 3c7da9a91..2b27fd574 100644 --- a/src/modules/identity/repositories/user.repository.ts +++ b/src/modules/identity/repositories/user.repository.ts @@ -3,7 +3,11 @@ import { Db, InsertOneResult, ModifyResult, ObjectId, WithId } from "mongodb"; import { Collections } from "@src/modules/common/enum/database.collection.enum"; import { createHmac } from "crypto"; import { RegisterPayload } from "../payloads/register.payload"; -import { UpdateUserDto, UserDto, UserTourGuideDto } from "../payloads/user.payload"; +import { + UpdateUserDto, + UserDto, + UserTourGuideDto, +} from "../payloads/user.payload"; import { EarlyAccessEmail, EmailServiceProvider, @@ -468,4 +472,14 @@ export class UserRepository { return false; } } + + async getAllUsers(): Promise[]> { + return await this.db + .collection(Collections.USER) + .find( + { isEmailVerified: true }, // only verified users + { projection: { password: 0 } }, + ) + .toArray(); + } } diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 5a29b5763..ce9fbdc93 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -1,9 +1,46 @@ import { Injectable, Logger } from "@nestjs/common"; +import { UserRepository } from "@src/modules/identity/repositories/user.repository"; +import { TestflowRepository } from "../repositories/testflow.repository"; @Injectable() export class WeeklyDigestService { + constructor( + private readonly userRepository: UserRepository, + private readonly testflowRepository: TestflowRepository, + ) {} + private readonly logger = new Logger(WeeklyDigestService.name); + async processWeeklyDigest() { + this.logger.log("Processing weekly digest emails..."); + + const { start, end } = this.getLastWeekRange(); + + this.logger.log( + `Weekly range: ${start.toISOString()} - ${end.toISOString()}`, + ); + + const users = await this.userRepository.getAllUsers(); + + this.logger.log(`Total users found: ${users.length}`); + + for (const user of users) { + this.logger.log(`Preparing digest for: ${user.email}`); + } + + for (const user of users) { + const executionCount = await this.getExecutionCountForUser( + user._id.toString(), + start, + end, + ); + + this.logger.log( + `User: ${user.email} | Weekly Executions: ${executionCount}`, + ); + } + } + private getLastWeekRange() { const now = new Date(); @@ -20,20 +57,40 @@ export class WeeklyDigestService { return { start, end }; } - async processWeeklyDigest() { - this.logger.log("Processing weekly digest emails..."); + async getExecutionCountForUser( + userId: string, + start: Date, + end: Date, + ): Promise { + let testflows = []; - const { start, end } = this.getLastWeekRange(); + try { + testflows = await this.testflowRepository.getAll(); + } catch (error) { + // If no testflows exist, simply return 0 executions + this.logger.warn("No testflows found in database."); + return 0; + } - this.logger.log( - `Weekly range: ${start.toISOString()} - ${end.toISOString()}`, - ); + let executionCount = 0; + + for (const testflow of testflows) { + if (!testflow.schedules) continue; + + for (const schedule of testflow.schedules) { + if (!schedule.schedularRunHistory) continue; + + for (const run of schedule.schedularRunHistory) { + const runDate = new Date(run.createdAt); - // next steps will use this + if (runDate >= start && runDate <= end) { + executionCount += + (run.successRequests || 0) + (run.failedRequests || 0); + } + } + } + } - // Step 1: get all users - // Step 2: fetch weekly metrics - // Step 3: build email payload - // Step 4: send email + return executionCount; } } From e8958301857c2ff97d927e2c594120c0d5d29254 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 16 Mar 2026 18:50:53 +0530 Subject: [PATCH 10/25] feat: restrict admin hub APIs to super-admin users [SPRW-3087] --- .../services/stripe-subscription.service.ts | 16 +++++++++++++++- .../controllers/user-admin.hubs.controller.ts | 5 +++-- .../services/user-admin.hubs.service.ts | 2 ++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index c8eea425c..5d0926297 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -671,7 +671,21 @@ export class StripeSubscriptionService { ), }; - await this.updateTeamPlanWithBilling(metadata.hubId, plan, billingDetails); + // Get current team plan + const existingTeam = await this.stripeSubscriptionRepo.findTeamById( + metadata.hubId, + ); + + // Skip webhook overwrite ONLY if admin changed plan recently + if (existingTeam?.billing?.updatedBy === BillingSource.API_CALL) { + console.log("Skipping webhook overwrite due to admin plan change"); + } else { + await this.updateTeamPlanWithBilling( + metadata.hubId, + plan, + billingDetails, + ); + } if (!isDowngrading) { const teamIdObject = new ObjectId(metadata.hubId); const updateTeam = diff --git a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts index 268008932..433881a9e 100644 --- a/src/modules/user-admin/controllers/user-admin.hubs.controller.ts +++ b/src/modules/user-admin/controllers/user-admin.hubs.controller.ts @@ -385,7 +385,7 @@ export class AdminHubsController { } @UseGuards(JwtAuthGuard, RolesGuard) - // @Roles("admin") + @Roles("super-admin") @ApiBearerAuth() @ApiOperation({ summary: "Extend trial period for a hub" }) @ApiParam({ @@ -427,7 +427,7 @@ export class AdminHubsController { } @UseGuards(JwtAuthGuard, RolesGuard) - // @Roles("admin") + @Roles("super-admin") @ApiBearerAuth() @ApiOperation({ summary: "Add a subscription plan to a hub" }) @ApiParam({ @@ -468,6 +468,7 @@ export class AdminHubsController { } @UseGuards(JwtAuthGuard, RolesGuard) + @Roles("super-admin") @ApiBearerAuth() @ApiOperation({ summary: "Change hub subscription plan (upgrade/downgrade)" }) @ApiParam({ diff --git a/src/modules/user-admin/services/user-admin.hubs.service.ts b/src/modules/user-admin/services/user-admin.hubs.service.ts index aa96f43d5..85352bdce 100644 --- a/src/modules/user-admin/services/user-admin.hubs.service.ts +++ b/src/modules/user-admin/services/user-admin.hubs.service.ts @@ -669,6 +669,7 @@ export class AdminHubsService { const subscriptionId = stripeProvider?.subscriptionId; + // Update Stripe subscription if exists if (subscriptionId && !subscriptionId.startsWith("sub_test")) { await this.stripeSubscriptionService["stripeService"].updateSubscription( subscriptionId, @@ -679,6 +680,7 @@ export class AdminHubsService { changeType, proratedAmount, effectiveDate, + updatedByAdmin: true, }, ); } From 556bd854594d78d96a970759c6727b813d283fd8 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 17 Mar 2026 16:57:20 +0530 Subject: [PATCH 11/25] feat: fetch user data from db [SPRW-3110] --- .../repositories/collection.repository.ts | 33 +++++ .../repositories/testflow.repository.ts | 23 ++++ .../repositories/workspace.repository.ts | 21 +++ .../services/weekly-digest.service.ts | 124 ++++++++++++++---- 4 files changed, 175 insertions(+), 26 deletions(-) diff --git a/src/modules/workspace/repositories/collection.repository.ts b/src/modules/workspace/repositories/collection.repository.ts index 252c6d858..87153bd77 100644 --- a/src/modules/workspace/repositories/collection.repository.ts +++ b/src/modules/workspace/repositories/collection.repository.ts @@ -2107,4 +2107,37 @@ export class CollectionRepository { }; } } + + // New Collections Count + async getNewCollectionsCount(start: Date, end: Date): Promise { + return await this.db.collection(Collections.COLLECTION).countDocuments({ + createdAt: { $gte: start, $lte: end }, + }); + } + + // APIs Created Count + async getApisCreatedCount(start: Date, end: Date): Promise { + const collections = await this.db + .collection(Collections.COLLECTION) + .find({}) + .toArray(); + + let count = 0; + + for (const col of collections) { + for (const item of col.items || []) { + if ( + item.type !== "FOLDER" && + !item.isDeleted && + item.createdAt && + new Date(item.createdAt) >= start && + new Date(item.createdAt) <= end + ) { + count++; + } + } + } + + return count; + } } diff --git a/src/modules/workspace/repositories/testflow.repository.ts b/src/modules/workspace/repositories/testflow.repository.ts index c76cdae81..0c6025166 100644 --- a/src/modules/workspace/repositories/testflow.repository.ts +++ b/src/modules/workspace/repositories/testflow.repository.ts @@ -612,4 +612,27 @@ export class TestflowRepository { ); return { datasets: result.value?.datasets || null }; } + + async getTestflowsExecutionCount(start: Date, end: Date): Promise { + const testflows = await this.db + .collection(Collections.TESTFLOW) + .find({}) + .toArray(); + + let count = 0; + + for (const testflow of testflows) { + for (const schedule of testflow.schedules || []) { + for (const run of schedule.schedularRunHistory || []) { + const runDate = new Date(run.createdAt); + + if (runDate >= start && runDate <= end) { + count += (run.successRequests || 0) + (run.failedRequests || 0); + } + } + } + } + + return count; + } } diff --git a/src/modules/workspace/repositories/workspace.repository.ts b/src/modules/workspace/repositories/workspace.repository.ts index b8900f766..3de10e4d1 100644 --- a/src/modules/workspace/repositories/workspace.repository.ts +++ b/src/modules/workspace/repositories/workspace.repository.ts @@ -458,4 +458,25 @@ export class WorkspaceRepository { return { workspaces, total }; } + + async getNewWorkspacesCount(start: Date, end: Date): Promise { + return await this.db + .collection(Collections.WORKSPACE) + .countDocuments({ + createdAt: { $gte: start, $lte: end }, + isRestricted: { $ne: true }, + isFreezed: { $ne: true }, + }); + } + + async getActiveWorkspacesCount(start: Date, end: Date): Promise { + return await this.db.collection(Collections.WORKSPACE).countDocuments({ + $or: [ + { createdAt: { $gte: start, $lte: end } }, + { updatedAt: { $gte: start, $lte: end } }, + ], + isRestricted: { $ne: true }, + isFreezed: { $ne: true }, + }); + } } diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index ce9fbdc93..98f8ca8cd 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -1,12 +1,16 @@ import { Injectable, Logger } from "@nestjs/common"; import { UserRepository } from "@src/modules/identity/repositories/user.repository"; import { TestflowRepository } from "../repositories/testflow.repository"; +import { WorkspaceRepository } from "../repositories/workspace.repository"; +import { CollectionRepository } from "../repositories/collection.repository"; @Injectable() export class WeeklyDigestService { constructor( private readonly userRepository: UserRepository, private readonly testflowRepository: TestflowRepository, + private readonly workspaceRepository: WorkspaceRepository, + private readonly collectionRepository: CollectionRepository, ) {} private readonly logger = new Logger(WeeklyDigestService.name); @@ -15,29 +19,66 @@ export class WeeklyDigestService { this.logger.log("Processing weekly digest emails..."); const { start, end } = this.getLastWeekRange(); + const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); - this.logger.log( - `Weekly range: ${start.toISOString()} - ${end.toISOString()}`, - ); - + // Fetch users const users = await this.userRepository.getAllUsers(); this.logger.log(`Total users found: ${users.length}`); - for (const user of users) { - this.logger.log(`Preparing digest for: ${user.email}`); + let testflows: any[] = []; + + try { + testflows = await this.testflowRepository.getAll(); + } catch { + this.logger.warn("No testflows found in database."); } + // Workspace metric + const newWorkspaces = await this.workspaceRepository.getNewWorkspacesCount( + start, + end, + ); + + // Collection metrics + const newCollections = + await this.collectionRepository.getNewCollectionsCount(start, end); + + const apisCreated = await this.collectionRepository.getApisCreatedCount( + start, + end, + ); + + // Testflow executions + const testflowExecutions = + await this.testflowRepository.getTestflowsExecutionCount(start, end); + + // Active workspaces + const activeWorkspaces = + await this.workspaceRepository.getActiveWorkspacesCount(start, end); + + // Logs + this.logger.log(`Testflows Executed: ${testflowExecutions}`); + this.logger.log(`Active Workspaces: ${activeWorkspaces}`); + this.logger.log(`New Workspaces: ${newWorkspaces}`); + this.logger.log(`New Collections: ${newCollections}`); + this.logger.log(`APIs Created: ${apisCreated}`); + for (const user of users) { - const executionCount = await this.getExecutionCountForUser( + const trend = await this.getExecutionTrend( user._id.toString(), start, end, + prevStart, + prevEnd, + testflows, ); this.logger.log( - `User: ${user.email} | Weekly Executions: ${executionCount}`, + `User: ${user.email} | Total: ${trend.totalExecutions} | Change: ${trend.percentChange}%`, ); + + this.logger.log(`Daily: ${trend.dailyExecutions}`); } } @@ -57,22 +98,32 @@ export class WeeklyDigestService { return { start, end }; } - async getExecutionCountForUser( - userId: string, - start: Date, - end: Date, - ): Promise { - let testflows = []; + private getPreviousWeekRange() { + const now = new Date(); - try { - testflows = await this.testflowRepository.getAll(); - } catch (error) { - // If no testflows exist, simply return 0 executions - this.logger.warn("No testflows found in database."); - return 0; - } + const end = new Date(now); + end.setDate(now.getDate() - now.getDay() - 7); + end.setHours(23, 59, 59, 999); - let executionCount = 0; + const start = new Date(end); + start.setDate(end.getDate() - 6); + start.setHours(0, 0, 0, 0); + + return { start, end }; + } + + async getExecutionTrend( + userId: string, + currentStart: Date, + currentEnd: Date, + previousStart: Date, + previousEnd: Date, + testflows: any[], + ) { + let currentCount = 0; + let previousCount = 0; + + const dailyExecutions = Array(7).fill(0); // Mon โ†’ Sun for (const testflow of testflows) { if (!testflow.schedules) continue; @@ -83,14 +134,35 @@ export class WeeklyDigestService { for (const run of schedule.schedularRunHistory) { const runDate = new Date(run.createdAt); - if (runDate >= start && runDate <= end) { - executionCount += - (run.successRequests || 0) + (run.failedRequests || 0); + const count = (run.successRequests || 0) + (run.failedRequests || 0); + + // Current week + if (runDate >= currentStart && runDate <= currentEnd) { + currentCount += count; + + const dayIndex = (runDate.getDay() + 6) % 7; // convert Sun=0 โ†’ Mon=0 + dailyExecutions[dayIndex] += count; + } + + // Previous week + if (runDate >= previousStart && runDate <= previousEnd) { + previousCount += count; } } } } - return executionCount; + const percentChange = + previousCount === 0 + ? currentCount > 0 + ? 100 + : 0 + : Math.round(((currentCount - previousCount) / previousCount) * 100); + + return { + totalExecutions: currentCount, + percentChange, + dailyExecutions, + }; } } From 6b786b7283c94a8bbc41b8a31d317eaed15c0957 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 11:26:09 +0530 Subject: [PATCH 12/25] feat: email ui [SPRW-3110] --- .../views/weeklyDigestEmail.handlebars | 268 ++++++++++++++++++ .../repositories/updates.repository.ts | 19 ++ .../services/weekly-digest.service.ts | 89 +++++- 3 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 src/modules/views/weeklyDigestEmail.handlebars diff --git a/src/modules/views/weeklyDigestEmail.handlebars b/src/modules/views/weeklyDigestEmail.handlebars new file mode 100644 index 000000000..2bf352781 --- /dev/null +++ b/src/modules/views/weeklyDigestEmail.handlebars @@ -0,0 +1,268 @@ + + + + + + + + +
+ + + + + + + + + + + + + + + {{!-- Date --}} + + + + + + + + + + + + + + + + + + + + {{!-- Collaboration Updates --}} + + + + + + + + {{!-- Pending Actions --}} + + + + + + + + + + + + + + + + + + +{{> footer}} + +
+ Hey {{userName}}, how did your APIs behave this week? +
+ Sparrow โ€“ Weekly Digest +
+ {{dateRange}} +
+ + + + + + + + + +
+ +
+ Execution trend +
+ +
+ {{execution.total}} +
+ +
+ โ†‘ {{execution.percent}}% from last week +
+ +
+ + + + + {{#each execution.graph}} + + {{/each}} + + + + + + + + + + + + + + +
+
+
+
MTWTFSS
+ +
+ +
+ +
+ Additional highlights +
+ + + + + + {{!-- Repeat this block --}} + + + + + + + + + + + + + +
+ +
+ ๐Ÿ“ก +
+ +
+ {{metrics.apisCreated}} +
+ +
+ API's created +
+ +
+
+ โšก +
+
+ {{metrics.testflowsExecuted}} +
+
+ Test Flows Executed +
+
+
+ ๐Ÿ‘ค +
+
+ {{metrics.activeWorkspaces}} +
+
+ Active Workspaces +
+
+
+ ๐Ÿ“ +
+
+ {{metrics.newCollections}} +
+
+ New Collections +
+
+
+ โž• +
+
+ {{metrics.newWorkspaces}} +
+
+ New Workspace +
+
+ +
+ +
+ Collaboration Updates +
+ +
    +
  • Kartik joined Apple workspace
  • +
  • 2 members were active in the last 24 hours
  • +
+ +
+ +
+ Pending Actions +
+ +
    +
  • Review changes made to a shared collection
  • +
  • Respond to an invite from Techdome Team
  • +
+ +
+ +
+ Continue in Sparrow.. +
+ + + +
+ +
+ You're receiving this email because you're part of an active Sparrow workspace. +
+ + + +
+ +
+ + + \ No newline at end of file diff --git a/src/modules/workspace/repositories/updates.repository.ts b/src/modules/workspace/repositories/updates.repository.ts index cc9e97c5f..af31e78b2 100644 --- a/src/modules/workspace/repositories/updates.repository.ts +++ b/src/modules/workspace/repositories/updates.repository.ts @@ -52,4 +52,23 @@ export class UpdatesRepository { .toArray(); return resposne; } + + async getWeeklyActivity(start: Date, end: Date) { + return this.db + .collection(Collections.UPDATES) + .aggregate([ + { + $match: { + createdAt: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: { $dayOfWeek: "$createdAt" }, + count: { $sum: 1 }, + }, + }, + ]) + .toArray(); + } } diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 98f8ca8cd..e54ba850b 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -3,6 +3,9 @@ import { UserRepository } from "@src/modules/identity/repositories/user.reposito import { TestflowRepository } from "../repositories/testflow.repository"; import { WorkspaceRepository } from "../repositories/workspace.repository"; import { CollectionRepository } from "../repositories/collection.repository"; +import { EmailService } from "@src/modules/common/services/email.service"; +import { ConfigService } from "@nestjs/config"; +import { UpdatesRepository } from "../repositories/updates.repository"; @Injectable() export class WeeklyDigestService { @@ -11,6 +14,9 @@ export class WeeklyDigestService { private readonly testflowRepository: TestflowRepository, private readonly workspaceRepository: WorkspaceRepository, private readonly collectionRepository: CollectionRepository, + private readonly updatesRepository: UpdatesRepository, + private readonly emailService: EmailService, + private readonly configService: ConfigService, ) {} private readonly logger = new Logger(WeeklyDigestService.name); @@ -18,7 +24,9 @@ export class WeeklyDigestService { async processWeeklyDigest() { this.logger.log("Processing weekly digest emails..."); - const { start, end } = this.getLastWeekRange(); + // const { start, end } = this.getLastWeekRange(); + const start = new Date("2026-03-01"); + const end = new Date("2026-03-20"); const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); // Fetch users @@ -64,21 +72,59 @@ export class WeeklyDigestService { this.logger.log(`New Collections: ${newCollections}`); this.logger.log(`APIs Created: ${apisCreated}`); + const transporter = this.emailService.createTransporter(); + for (const user of users) { - const trend = await this.getExecutionTrend( - user._id.toString(), + const activityData = await this.updatesRepository.getWeeklyActivity( start, end, - prevStart, - prevEnd, - testflows, ); + const dailyExecutions = this.formatWeeklyGraph(activityData); - this.logger.log( - `User: ${user.email} | Total: ${trend.totalExecutions} | Change: ${trend.percentChange}%`, - ); + const totalExecutions = dailyExecutions.reduce((a, b) => a + b, 0); + + const percentChange = 0; + + const graphHeights = this.normalizeGraphData(dailyExecutions); + + const max = Math.max(...graphHeights); + + const graph = graphHeights.map((h) => ({ + height: h, + isMax: h === max, + })); + + const mailOptions = { + from: this.configService.get("app.senderEmail"), + to: user.email, + template: "weeklyDigestEmail", + subject: "Your Weekly Digest ๐Ÿ“Š", + context: { + userName: user.name || user.email, + + dateRange: `${start.toDateString()} - ${end.toDateString()}`, + + execution: { + total: totalExecutions, + percent: percentChange, + graph, + }, + + metrics: { + newWorkspaces, + newCollections, + apisCreated, + testflowsExecuted: testflowExecutions, + activeWorkspaces, + }, - this.logger.log(`Daily: ${trend.dailyExecutions}`); + ctaLink: "https://sparrowapp.dev", + }, + }; + + await this.emailService.sendEmail(transporter, mailOptions); + + this.logger.log(`Weekly digest sent to ${user.email}`); } } @@ -165,4 +211,27 @@ export class WeeklyDigestService { dailyExecutions, }; } + + private normalizeGraphData(data: number[]) { + const max = Math.max(...data, 1); + + return data.map((value) => { + if (max === 0) return 12; + + const height = Math.round((value / max) * 40); + return height < 10 ? 10 : height; + }); + } + + private formatWeeklyGraph(data: any[]) { + const result = Array(7).fill(0); + + data.forEach((item) => { + const mongoDay = item._id; + const index = (mongoDay + 5) % 7; + result[index] = item.count; + }); + + return result; + } } From c262e71edd0e73955474bbff9f86ee58d8b48f59 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 11:57:47 +0530 Subject: [PATCH 13/25] feat: collaboration and pending functionality [SPRW-3110] --- .../repositories/userInvites.repository.ts | 11 ++++ .../views/weeklyDigestEmail.handlebars | 58 +++++++++++-------- .../repositories/updates.repository.ts | 12 ++++ .../services/weekly-digest.service.ts | 22 +++++++ 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/src/modules/identity/repositories/userInvites.repository.ts b/src/modules/identity/repositories/userInvites.repository.ts index 2a5b84451..b03fb8e1a 100644 --- a/src/modules/identity/repositories/userInvites.repository.ts +++ b/src/modules/identity/repositories/userInvites.repository.ts @@ -71,4 +71,15 @@ export class UserInvitesRepository { .deleteOne({ email }); return result; } + + async getPendingInvites(start: Date, end: Date, email: string) { + return this.db + .collection(Collections.USERINVITES) + .find({ + createdAt: { $gte: start, $lte: end }, + email: email, + }) + .limit(5) + .toArray(); + } } diff --git a/src/modules/views/weeklyDigestEmail.handlebars b/src/modules/views/weeklyDigestEmail.handlebars index 2bf352781..d72289698 100644 --- a/src/modules/views/weeklyDigestEmail.handlebars +++ b/src/modules/views/weeklyDigestEmail.handlebars @@ -184,39 +184,49 @@ {{!-- Collaboration Updates --}} - - - -
- Collaboration Updates -
+ {{#if collaborationUpdates.length}} + + + +
+ Collaboration Updates +
+ +
    + {{#each collaborationUpdates}} +
  • {{this}}
  • + {{/each}} +
-
    -
  • Kartik joined Apple workspace
  • -
  • 2 members were active in the last 24 hours
  • -
+ + - - + + {{/if}} {{!-- Pending Actions --}} - - - -
- Pending Actions -
+ {{#if pendingActions.length}} + + + +
+ Pending Actions +
+ +
    + {{#each pendingActions}} +
  • {{this}}
  • + {{/each}} +
-
    -
  • Review changes made to a shared collection
  • -
  • Respond to an invite from Techdome Team
  • -
+ + - - + + {{/if}} diff --git a/src/modules/workspace/repositories/updates.repository.ts b/src/modules/workspace/repositories/updates.repository.ts index af31e78b2..016792c53 100644 --- a/src/modules/workspace/repositories/updates.repository.ts +++ b/src/modules/workspace/repositories/updates.repository.ts @@ -71,4 +71,16 @@ export class UpdatesRepository { ]) .toArray(); } + + async getUpdatesForEmail(start: Date, end: Date, userId: string) { + return this.db + .collection(Collections.UPDATES) + .find({ + createdAt: { $gte: start, $lte: end }, + createdBy: userId, + }) + .sort({ createdAt: -1 }) + .limit(5) + .toArray(); + } } diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index e54ba850b..4f109d2c7 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -6,6 +6,7 @@ import { CollectionRepository } from "../repositories/collection.repository"; import { EmailService } from "@src/modules/common/services/email.service"; import { ConfigService } from "@nestjs/config"; import { UpdatesRepository } from "../repositories/updates.repository"; +import { UserInvitesRepository } from "@src/modules/identity/repositories/userInvites.repository"; @Injectable() export class WeeklyDigestService { @@ -17,6 +18,7 @@ export class WeeklyDigestService { private readonly updatesRepository: UpdatesRepository, private readonly emailService: EmailService, private readonly configService: ConfigService, + private readonly userInvitesRepository: UserInvitesRepository, ) {} private readonly logger = new Logger(WeeklyDigestService.name); @@ -94,6 +96,24 @@ export class WeeklyDigestService { isMax: h === max, })); + const updates = await this.updatesRepository.getUpdatesForEmail( + start, + end, + user._id.toString(), + ); + + const collaborationUpdates = updates.map((u) => u.message); + + const pendingInvites = await this.userInvitesRepository.getPendingInvites( + start, + end, + user.email, + ); + + const pendingActions = pendingInvites.map( + (inv) => `Invitation sent to ${inv.email}`, + ); + const mailOptions = { from: this.configService.get("app.senderEmail"), to: user.email, @@ -119,6 +139,8 @@ export class WeeklyDigestService { }, ctaLink: "https://sparrowapp.dev", + collaborationUpdates, + pendingActions, }, }; From 1664a6e9388a4c9f9aebd1ef738fec0917705118 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 13:03:53 +0530 Subject: [PATCH 14/25] feat: unsubscribe functionality [SPRW-3110] --- src/modules/common/models/user.model.ts | 4 +++ .../identity/controllers/user.controller.ts | 26 +++++++++++++++++++ .../identity/repositories/user.repository.ts | 13 ++++++++++ src/modules/identity/services/user.service.ts | 7 +++++ .../views/weeklyDigestEmail.handlebars | 2 +- .../services/weekly-digest.service.ts | 9 +++++++ 6 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/modules/common/models/user.model.ts b/src/modules/common/models/user.model.ts index 68854fd7b..afba276a4 100644 --- a/src/modules/common/models/user.model.ts +++ b/src/modules/common/models/user.model.ts @@ -166,6 +166,10 @@ export class User { @ValidateNested() @Type(() => TourGuideDto) tourGuide?: TourGuideDto; + + @IsBoolean() + @IsOptional() + isWeeklyDigestEnabled?: boolean; } export class UserDto { diff --git a/src/modules/identity/controllers/user.controller.ts b/src/modules/identity/controllers/user.controller.ts index 0fbeb4262..9239ed304 100644 --- a/src/modules/identity/controllers/user.controller.ts +++ b/src/modules/identity/controllers/user.controller.ts @@ -10,6 +10,7 @@ import { Req, Res, UseGuards, + Query, } from "@nestjs/common"; import { ApiBearerAuth, @@ -614,4 +615,29 @@ export class UserController { result, ); } + + @Get("unsubscribe-weekly-digest") + @ApiOperation({ + summary: "Unsubscribe from weekly digest emails", + }) + async unsubscribeWeeklyDigest( + @Query("userId") userId: string, + @Res() res: FastifyReply, + ) { + await this.userService.disableWeeklyDigest(userId); + + return res.header("Content-Type", "text/html; charset=utf-8").send(` +
+

โœ… You have been unsubscribed

+

+ You will no longer receive weekly digest emails. +

+
+ `); + } } diff --git a/src/modules/identity/repositories/user.repository.ts b/src/modules/identity/repositories/user.repository.ts index 2b27fd574..81b9b5590 100644 --- a/src/modules/identity/repositories/user.repository.ts +++ b/src/modules/identity/repositories/user.repository.ts @@ -482,4 +482,17 @@ export class UserRepository { ) .toArray(); } + + async disableWeeklyDigest(userId: string) { + return this.db + .collection(Collections.USER) + .updateOne( + { _id: new ObjectId(userId) }, + { $set: { isWeeklyDigestEnabled: false } }, + ); + } + + async updateUserByQuery(filter: any, update: any) { + return this.db.collection(Collections.USER).updateOne(filter, update); + } } diff --git a/src/modules/identity/services/user.service.ts b/src/modules/identity/services/user.service.ts index 08c996653..2a79b87d7 100644 --- a/src/modules/identity/services/user.service.ts +++ b/src/modules/identity/services/user.service.ts @@ -840,4 +840,11 @@ export class UserService { }); return response; } + + async disableWeeklyDigest(userId: string) { + return this.userRepository.updateUserByQuery( + { _id: new ObjectId(userId) }, + { $set: { isWeeklyDigestEnabled: false } }, + ); + } } diff --git a/src/modules/views/weeklyDigestEmail.handlebars b/src/modules/views/weeklyDigestEmail.handlebars index d72289698..996a10171 100644 --- a/src/modules/views/weeklyDigestEmail.handlebars +++ b/src/modules/views/weeklyDigestEmail.handlebars @@ -258,7 +258,7 @@ diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 4f109d2c7..ed1f48030 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -77,6 +77,7 @@ export class WeeklyDigestService { const transporter = this.emailService.createTransporter(); for (const user of users) { + if (user.isWeeklyDigestEnabled === false) continue; const activityData = await this.updatesRepository.getWeeklyActivity( start, end, @@ -114,11 +115,18 @@ export class WeeklyDigestService { (inv) => `Invitation sent to ${inv.email}`, ); + const unsubscribeLink = `${this.configService.get("app.url")}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; + const mailOptions = { from: this.configService.get("app.senderEmail"), to: user.email, template: "weeklyDigestEmail", subject: "Your Weekly Digest ๐Ÿ“Š", + + headers: { + "List-Unsubscribe": `<${unsubscribeLink}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, context: { userName: user.name || user.email, @@ -141,6 +149,7 @@ export class WeeklyDigestService { ctaLink: "https://sparrowapp.dev", collaborationUpdates, pendingActions, + unsubscribeLink, }, }; From bae3ba752cb352d9d761e51c44e97638a37ab278 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 14:22:03 +0530 Subject: [PATCH 15/25] feat: percentage fixed [SPRW-3110] --- .../views/weeklyDigestEmail.handlebars | 2 +- .../schedulers/weekly-digest.scheduler.ts | 2 +- .../services/weekly-digest.service.ts | 29 +++++++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/modules/views/weeklyDigestEmail.handlebars b/src/modules/views/weeklyDigestEmail.handlebars index 996a10171..29dec296e 100644 --- a/src/modules/views/weeklyDigestEmail.handlebars +++ b/src/modules/views/weeklyDigestEmail.handlebars @@ -39,7 +39,7 @@ -
+
Execution trend
diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index a7eb2c63a..ec27c6a1e 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -11,7 +11,7 @@ export class WeeklyDigestScheduler { /** * Runs every Monday at 08:00 AM */ - @Cron("*/10 * * * * *") + @Cron("*/30 * * * * *") async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index ed1f48030..9155a94ad 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -27,9 +27,13 @@ export class WeeklyDigestService { this.logger.log("Processing weekly digest emails..."); // const { start, end } = this.getLastWeekRange(); - const start = new Date("2026-03-01"); - const end = new Date("2026-03-20"); - const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); + // const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); + + const end = new Date(); + const start = new Date(end.getTime() - 30 * 60 * 1000); // last 30 mins + + const prevEnd = new Date(start); + const prevStart = new Date(prevEnd.getTime() - 30 * 60 * 1000); // Fetch users const users = await this.userRepository.getAllUsers(); @@ -82,11 +86,26 @@ export class WeeklyDigestService { start, end, ); - const dailyExecutions = this.formatWeeklyGraph(activityData); + const prevActivityData = await this.updatesRepository.getWeeklyActivity( + prevStart, + prevEnd, + ); + const dailyExecutions = this.formatWeeklyGraph(activityData); const totalExecutions = dailyExecutions.reduce((a, b) => a + b, 0); - const percentChange = 0; + const prevDailyExecutions = this.formatWeeklyGraph(prevActivityData); + const previousCount = prevDailyExecutions.reduce((a, b) => a + b, 0); + + let percentChange = 0; + + if (previousCount === 0 && totalExecutions > 0) { + percentChange = 100; + } else if (previousCount > 0) { + percentChange = Math.round( + ((totalExecutions - previousCount) / previousCount) * 100, + ); + } const graphHeights = this.normalizeGraphData(dailyExecutions); From cf30aeaa20eb4ce8df882210a8517d509d5f409a Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 15:18:36 +0530 Subject: [PATCH 16/25] fix: testflow fixed [SPRW-3110] --- .../repositories/testflow.repository.ts | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/src/modules/workspace/repositories/testflow.repository.ts b/src/modules/workspace/repositories/testflow.repository.ts index 0c6025166..740aa5c10 100644 --- a/src/modules/workspace/repositories/testflow.repository.ts +++ b/src/modules/workspace/repositories/testflow.repository.ts @@ -614,25 +614,11 @@ export class TestflowRepository { } async getTestflowsExecutionCount(start: Date, end: Date): Promise { - const testflows = await this.db - .collection(Collections.TESTFLOW) - .find({}) - .toArray(); - - let count = 0; - - for (const testflow of testflows) { - for (const schedule of testflow.schedules || []) { - for (const run of schedule.schedularRunHistory || []) { - const runDate = new Date(run.createdAt); - - if (runDate >= start && runDate <= end) { - count += (run.successRequests || 0) + (run.failedRequests || 0); - } - } - } - } - - return count; + return this.db.collection(Collections.TESTFLOW).countDocuments({ + $or: [ + { createdAt: { $gte: start, $lte: end } }, + { updatedAt: { $gte: start, $lte: end } }, + ], + }); } } From ee60c3fc956a05a6730187674078c3b6b52038cb Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 16:20:43 +0530 Subject: [PATCH 17/25] fix: 5 min for test [SPRW-3110] --- src/modules/workspace/schedulers/weekly-digest.scheduler.ts | 2 +- src/modules/workspace/services/weekly-digest.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index ec27c6a1e..b79964ef4 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -11,7 +11,7 @@ export class WeeklyDigestScheduler { /** * Runs every Monday at 08:00 AM */ - @Cron("*/30 * * * * *") + @Cron("*/5 * * * * *") async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 9155a94ad..9a10078b2 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -30,10 +30,10 @@ export class WeeklyDigestService { // const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); const end = new Date(); - const start = new Date(end.getTime() - 30 * 60 * 1000); // last 30 mins + const start = new Date(end.getTime() - 5 * 60 * 1000); // last 30 mins const prevEnd = new Date(start); - const prevStart = new Date(prevEnd.getTime() - 30 * 60 * 1000); + const prevStart = new Date(prevEnd.getTime() - 5 * 60 * 1000); // Fetch users const users = await this.userRepository.getAllUsers(); From e8686957673d52b85c24eac1a56ea39646668bd2 Mon Sep 17 00:00:00 2001 From: Nayan Lakhwani Date: Wed, 18 Mar 2026 17:12:52 +0530 Subject: [PATCH 18/25] fix: change weekly digest cron expression from 5 sec to 5 minutes --- src/modules/workspace/schedulers/weekly-digest.scheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index b79964ef4..c54ee0dc2 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from "@nestjs/common"; -import { Cron } from "@nestjs/schedule"; +import { Cron, CronExpression } from "@nestjs/schedule"; import { WeeklyDigestService } from "../services/weekly-digest.service"; @Injectable() @@ -11,7 +11,7 @@ export class WeeklyDigestScheduler { /** * Runs every Monday at 08:00 AM */ - @Cron("*/5 * * * * *") + @Cron(CronExpression.EVERY_5_MINUTES) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); From e88ffc75abad55ea6e627b9747a3194ea407634f Mon Sep 17 00:00:00 2001 From: Nayan Lakhwani Date: Wed, 18 Mar 2026 18:10:25 +0530 Subject: [PATCH 19/25] fix: change weekly digest cron expression from 5 sec to 5 minutes --- .../identity/repositories/user.repository.ts | 20 +++ .../repositories/collection.repository.ts | 36 +++-- .../schedulers/weekly-digest.scheduler.ts | 7 +- .../services/weekly-digest.service.ts | 144 ++++++++---------- 4 files changed, 104 insertions(+), 103 deletions(-) diff --git a/src/modules/identity/repositories/user.repository.ts b/src/modules/identity/repositories/user.repository.ts index 81b9b5590..b78e14812 100644 --- a/src/modules/identity/repositories/user.repository.ts +++ b/src/modules/identity/repositories/user.repository.ts @@ -483,6 +483,26 @@ export class UserRepository { .toArray(); } + async getUsersForWeeklyDigest(email?: string): Promise[]> { + return await this.db + .collection(Collections.USER) + .find( + { + isEmailVerified: true, + isWeeklyDigestEnabled: { $ne: false }, + ...(email ? { email } : {}), + }, + { + projection: { + email: 1, + name: 1, + isWeeklyDigestEnabled: 1, + }, + }, + ) + .toArray(); + } + async disableWeeklyDigest(userId: string) { return this.db .collection(Collections.USER) diff --git a/src/modules/workspace/repositories/collection.repository.ts b/src/modules/workspace/repositories/collection.repository.ts index 87153bd77..172105311 100644 --- a/src/modules/workspace/repositories/collection.repository.ts +++ b/src/modules/workspace/repositories/collection.repository.ts @@ -2117,27 +2117,25 @@ export class CollectionRepository { // APIs Created Count async getApisCreatedCount(start: Date, end: Date): Promise { - const collections = await this.db + const [result] = await this.db .collection(Collections.COLLECTION) - .find({}) + .aggregate<{ count: number }>([ + { + $unwind: "$items", + }, + { + $match: { + "items.type": { $ne: "FOLDER" }, + "items.isDeleted": { $ne: true }, + "items.createdAt": { $gte: start, $lte: end }, + }, + }, + { + $count: "count", + }, + ]) .toArray(); - let count = 0; - - for (const col of collections) { - for (const item of col.items || []) { - if ( - item.type !== "FOLDER" && - !item.isDeleted && - item.createdAt && - new Date(item.createdAt) >= start && - new Date(item.createdAt) <= end - ) { - count++; - } - } - } - - return count; + return result?.count ?? 0; } } diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index c54ee0dc2..38aac319c 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,9 +9,12 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} /** - * Runs every Monday at 08:00 AM + * Runs every 5 minutes */ - @Cron(CronExpression.EVERY_5_MINUTES) + @Cron(CronExpression.EVERY_5_MINUTES, { + name: "weekly-digest", + waitForCompletion: true, + }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 9a10078b2..13413abfd 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -10,6 +10,8 @@ import { UserInvitesRepository } from "@src/modules/identity/repositories/userIn @Injectable() export class WeeklyDigestService { + private static readonly QA_DIGEST_EMAIL = "sanil.nayak@techdome.net.in"; + constructor( private readonly userRepository: UserRepository, private readonly testflowRepository: TestflowRepository, @@ -28,116 +30,94 @@ export class WeeklyDigestService { // const { start, end } = this.getLastWeekRange(); // const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); + const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; const end = new Date(); - const start = new Date(end.getTime() - 5 * 60 * 1000); // last 30 mins + const start = new Date(end.getTime() - 5 * 60 * 1000); // last 5 mins const prevEnd = new Date(start); const prevStart = new Date(prevEnd.getTime() - 5 * 60 * 1000); // Fetch users - const users = await this.userRepository.getAllUsers(); + const users = + await this.userRepository.getUsersForWeeklyDigest(qaDigestEmail); this.logger.log(`Total users found: ${users.length}`); - let testflows: any[] = []; - - try { - testflows = await this.testflowRepository.getAll(); - } catch { - this.logger.warn("No testflows found in database."); + if (users.length === 0) { + this.logger.warn(`No users found with weekly digest enabled`); + return; } // Workspace metric - const newWorkspaces = await this.workspaceRepository.getNewWorkspacesCount( - start, - end, - ); - - // Collection metrics - const newCollections = - await this.collectionRepository.getNewCollectionsCount(start, end); - - const apisCreated = await this.collectionRepository.getApisCreatedCount( - start, - end, - ); - - // Testflow executions - const testflowExecutions = - await this.testflowRepository.getTestflowsExecutionCount(start, end); - - // Active workspaces - const activeWorkspaces = - await this.workspaceRepository.getActiveWorkspacesCount(start, end); - - // Logs - this.logger.log(`Testflows Executed: ${testflowExecutions}`); - this.logger.log(`Active Workspaces: ${activeWorkspaces}`); - this.logger.log(`New Workspaces: ${newWorkspaces}`); - this.logger.log(`New Collections: ${newCollections}`); - this.logger.log(`APIs Created: ${apisCreated}`); - - const transporter = this.emailService.createTransporter(); - - for (const user of users) { - if (user.isWeeklyDigestEnabled === false) continue; - const activityData = await this.updatesRepository.getWeeklyActivity( - start, - end, - ); - const prevActivityData = await this.updatesRepository.getWeeklyActivity( - prevStart, - prevEnd, + const [ + newWorkspaces, + newCollections, + apisCreated, + testflowExecutions, + activeWorkspaces, + activityData, + prevActivityData, + ] = await Promise.all([ + this.workspaceRepository.getNewWorkspacesCount(start, end), + this.collectionRepository.getNewCollectionsCount(start, end), + this.collectionRepository.getApisCreatedCount(start, end), + this.testflowRepository.getTestflowsExecutionCount(start, end), + this.workspaceRepository.getActiveWorkspacesCount(start, end), + this.updatesRepository.getWeeklyActivity(start, end), + this.updatesRepository.getWeeklyActivity(prevStart, prevEnd), + ]); + + const dailyExecutions = this.formatWeeklyGraph(activityData); + const totalExecutions = dailyExecutions.reduce((a, b) => a + b, 0); + + const prevDailyExecutions = this.formatWeeklyGraph(prevActivityData); + const previousCount = prevDailyExecutions.reduce((a, b) => a + b, 0); + + let percentChange = 0; + + if (previousCount === 0 && totalExecutions > 0) { + percentChange = 100; + } else if (previousCount > 0) { + percentChange = Math.round( + ((totalExecutions - previousCount) / previousCount) * 100, ); + } - const dailyExecutions = this.formatWeeklyGraph(activityData); - const totalExecutions = dailyExecutions.reduce((a, b) => a + b, 0); - - const prevDailyExecutions = this.formatWeeklyGraph(prevActivityData); - const previousCount = prevDailyExecutions.reduce((a, b) => a + b, 0); - - let percentChange = 0; - - if (previousCount === 0 && totalExecutions > 0) { - percentChange = 100; - } else if (previousCount > 0) { - percentChange = Math.round( - ((totalExecutions - previousCount) / previousCount) * 100, - ); - } + const graphHeights = this.normalizeGraphData(dailyExecutions); - const graphHeights = this.normalizeGraphData(dailyExecutions); + const max = Math.max(...graphHeights); - const max = Math.max(...graphHeights); + const graph = graphHeights.map((height) => ({ + height, + isMax: height === max, + })); - const graph = graphHeights.map((h) => ({ - height: h, - isMax: h === max, - })); + const transporter = this.emailService.createTransporter(); + const senderEmail = this.configService.get("app.senderEmail"); + const appUrl = this.configService.get("app.url"); - const updates = await this.updatesRepository.getUpdatesForEmail( - start, - end, - user._id.toString(), - ); + for (const user of users) { + if (user.isWeeklyDigestEnabled === false) continue; + const [updates, pendingInvites] = await Promise.all([ + this.updatesRepository.getUpdatesForEmail( + start, + end, + user._id.toString(), + ), + this.userInvitesRepository.getPendingInvites(start, end, user.email), + ]); const collaborationUpdates = updates.map((u) => u.message); - const pendingInvites = await this.userInvitesRepository.getPendingInvites( - start, - end, - user.email, - ); - const pendingActions = pendingInvites.map( (inv) => `Invitation sent to ${inv.email}`, ); - const unsubscribeLink = `${this.configService.get("app.url")}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; + const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; const mailOptions = { - from: this.configService.get("app.senderEmail"), + from: senderEmail, to: user.email, template: "weeklyDigestEmail", subject: "Your Weekly Digest ๐Ÿ“Š", From ff4f5a42ca0d36307bcc6cdabc373cf5a95980ec Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 20:57:01 +0530 Subject: [PATCH 20/25] fix: cron commented [SPRW-3110] --- .../workspace/schedulers/weekly-digest.scheduler.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index 38aac319c..9d34fc8b6 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -10,11 +10,11 @@ export class WeeklyDigestScheduler { /** * Runs every 5 minutes - */ - @Cron(CronExpression.EVERY_5_MINUTES, { - name: "weekly-digest", - waitForCompletion: true, - }) + // */ + // @Cron(CronExpression.EVERY_5_MINUTES, { + // name: "weekly-digest", + // waitForCompletion: true, + // }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); From 1a06e8af789a2c069ae0189e45feabac625f7799 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 22:59:24 +0530 Subject: [PATCH 21/25] fix: batch calling and code fixes [SPRW-3110] --- .../identity/repositories/user.repository.ts | 38 ++ .../repositories/userInvites.repository.ts | 50 +++ .../repositories/testflow.repository.ts | 86 ++++ .../repositories/updates.repository.ts | 47 ++ .../repositories/workspace.repository.ts | 175 ++++++++ .../schedulers/weekly-digest.scheduler.ts | 4 +- .../services/weekly-digest.service.ts | 405 ++++++++++++++---- 7 files changed, 720 insertions(+), 85 deletions(-) diff --git a/src/modules/identity/repositories/user.repository.ts b/src/modules/identity/repositories/user.repository.ts index b78e14812..7070a32c4 100644 --- a/src/modules/identity/repositories/user.repository.ts +++ b/src/modules/identity/repositories/user.repository.ts @@ -515,4 +515,42 @@ export class UserRepository { async updateUserByQuery(filter: any, update: any) { return this.db.collection(Collections.USER).updateOne(filter, update); } + + /** + * Fetch users for weekly digest in batches using cursor-based pagination. + * @param batchSize Number of users to fetch per batch + * @param lastCursor The _id of the last user from the previous batch (for cursor-based pagination) + * @param qaEmail Optional email for QA testing (to fetch a single user) + * @returns Array of users for the current batch + */ + async getUsersBatchForWeeklyDigest( + batchSize: number, + lastCursor?: ObjectId, + qaEmail?: string, + ): Promise[]> { + const query: any = { + isEmailVerified: true, + isWeeklyDigestEnabled: { $ne: false }, + ...(qaEmail ? { email: qaEmail } : {}), + }; + + // Cursor-based pagination: fetch users with _id greater than lastCursor + if (lastCursor) { + query._id = { $gt: lastCursor }; + } + + return await this.db + .collection(Collections.USER) + .find(query, { + projection: { + _id: 1, + email: 1, + name: 1, + isWeeklyDigestEnabled: 1, + }, + }) + .sort({ _id: 1 }) + .limit(batchSize) + .toArray(); + } } diff --git a/src/modules/identity/repositories/userInvites.repository.ts b/src/modules/identity/repositories/userInvites.repository.ts index b03fb8e1a..362a6fa67 100644 --- a/src/modules/identity/repositories/userInvites.repository.ts +++ b/src/modules/identity/repositories/userInvites.repository.ts @@ -82,4 +82,54 @@ export class UserInvitesRepository { .limit(5) .toArray(); } + + /** + * Get pending invites for a batch of emails using aggregation. + * Returns invites grouped by email for efficient batch processing. + * @param start Start date range + * @param end End date range + * @param emails Array of email addresses to fetch pending invites for + * @returns Map of email to array of pending action strings + */ + async getPendingInvitesForBatch( + start: Date, + end: Date, + emails: string[], + ): Promise> { + const results = await this.db + .collection(Collections.USERINVITES) + .aggregate([ + { + $match: { + createdAt: { $gte: start, $lte: end }, + email: { $in: emails }, + }, + }, + { + $sort: { createdAt: -1 }, + }, + { + $group: { + _id: "$email", + invites: { $push: "$email" }, + }, + }, + { + $project: { + _id: 1, + invites: { $slice: ["$invites", 5] }, + }, + }, + ]) + .toArray(); + + const invitesMap = new Map(); + for (const result of results) { + const pendingActions = (result.invites || []).map( + (email: string) => `Invitation sent to ${email}`, + ); + invitesMap.set(result._id, pendingActions); + } + return invitesMap; + } } diff --git a/src/modules/workspace/repositories/testflow.repository.ts b/src/modules/workspace/repositories/testflow.repository.ts index 740aa5c10..e99ed5fc1 100644 --- a/src/modules/workspace/repositories/testflow.repository.ts +++ b/src/modules/workspace/repositories/testflow.repository.ts @@ -621,4 +621,90 @@ export class TestflowRepository { ], }); } + + /** + * Get testflow execution metrics for a batch of users. + * Aggregates testflow activity by workspaceId and maps to users. + * @param workspaceIds Array of workspace IDs the users have access to + * @param start Start date for activity range + * @param end End date for activity range + * @returns Map of workspaceId to testflow execution count + */ + async getTestflowMetricsForWorkspaces( + workspaceIds: string[], + start: Date, + end: Date, + ): Promise> { + const results = await this.db + .collection(Collections.TESTFLOW) + .aggregate([ + { + $match: { + workspaceId: { $in: workspaceIds }, + $or: [ + { createdAt: { $gte: start, $lte: end } }, + { updatedAt: { $gte: start, $lte: end } }, + ], + }, + }, + { + $group: { + _id: "$workspaceId", + executionCount: { $sum: 1 }, + }, + }, + ]) + .toArray(); + + const metricsMap = new Map(); + for (const result of results) { + metricsMap.set(result._id, result.executionCount || 0); + } + return metricsMap; + } + + /** + * Get testflow execution metrics for a batch of users. + * Uses the user's workspaces to compute aggregated testflow metrics. + * @param userWorkspacesMap Map of userId to array of workspaceIds + * @param start Start date for activity range + * @param end End date for activity range + * @returns Map of userId to testflow execution count + */ + async getTestflowMetricsForUserBatch( + userWorkspacesMap: Map, + start: Date, + end: Date, + ): Promise> { + // Collect all unique workspace IDs + const allWorkspaceIds = new Set(); + for (const workspaceIds of userWorkspacesMap.values()) { + for (const wsId of workspaceIds) { + allWorkspaceIds.add(wsId); + } + } + + if (allWorkspaceIds.size === 0) { + return new Map(); + } + + // Get testflow counts per workspace + const workspaceMetrics = await this.getTestflowMetricsForWorkspaces( + Array.from(allWorkspaceIds), + start, + end, + ); + + // Map workspace metrics back to users + const userMetrics = new Map(); + for (const [userId, workspaceIds] of userWorkspacesMap) { + let totalExecutions = 0; + for (const wsId of workspaceIds) { + totalExecutions += workspaceMetrics.get(wsId) || 0; + } + userMetrics.set(userId, totalExecutions); + } + + return userMetrics; + } } diff --git a/src/modules/workspace/repositories/updates.repository.ts b/src/modules/workspace/repositories/updates.repository.ts index 016792c53..39ac58769 100644 --- a/src/modules/workspace/repositories/updates.repository.ts +++ b/src/modules/workspace/repositories/updates.repository.ts @@ -83,4 +83,51 @@ export class UpdatesRepository { .limit(5) .toArray(); } + + /** + * Get updates for a batch of users using aggregation. + * Returns updates grouped by userId for efficient batch processing. + * @param start Start date range + * @param end End date range + * @param userIds Array of user IDs to fetch updates for + * @returns Map of userId to array of update messages + */ + async getUpdatesForBatch( + start: Date, + end: Date, + userIds: string[], + ): Promise> { + const results = await this.db + .collection(Collections.UPDATES) + .aggregate([ + { + $match: { + createdAt: { $gte: start, $lte: end }, + createdBy: { $in: userIds }, + }, + }, + { + $sort: { createdAt: -1 }, + }, + { + $group: { + _id: "$createdBy", + updates: { $push: "$message" }, + }, + }, + { + $project: { + _id: 1, + updates: { $slice: ["$updates", 5] }, + }, + }, + ]) + .toArray(); + + const updatesMap = new Map(); + for (const result of results) { + updatesMap.set(result._id, result.updates || []); + } + return updatesMap; + } } diff --git a/src/modules/workspace/repositories/workspace.repository.ts b/src/modules/workspace/repositories/workspace.repository.ts index 3de10e4d1..88681e5de 100644 --- a/src/modules/workspace/repositories/workspace.repository.ts +++ b/src/modules/workspace/repositories/workspace.repository.ts @@ -479,4 +479,179 @@ export class WorkspaceRepository { isFreezed: { $ne: true }, }); } + + /** + * Get aggregated workspace metrics for a batch of users. + * Uses MongoDB aggregation to compute per-user metrics efficiently. + * @param userIds Array of user IDs to fetch metrics for + * @param start Start date for activity range + * @param end End date for activity range + * @returns Map of userId to metrics object + */ + async getWorkspaceMetricsForUserBatch( + userIds: string[], + start: Date, + end: Date, + ): Promise< + Map< + string, + { + activeWorkspaces: number; + newWorkspaces: number; + collectionsCount: number; + apisCount: number; + } + > + > { + const results = await this.db + .collection(Collections.WORKSPACE) + .aggregate([ + // Match workspaces where user is a member (in users array) + { + $match: { + "users.id": { $in: userIds }, + isRestricted: { $ne: true }, + isFreezed: { $ne: true }, + }, + }, + // Unwind users to get individual user-workspace pairs + { + $unwind: "$users", + }, + // Filter only the users we care about + { + $match: { + "users.id": { $in: userIds }, + }, + }, + // Lookup collections for each workspace + { + $lookup: { + from: Collections.COLLECTION, + let: { collectionIds: "$collection" }, + pipeline: [ + { + $match: { + $expr: { + $in: [ + "$_id", + { + $ifNull: [ + { + $map: { + input: "$$collectionIds", + as: "c", + in: "$$c.id", + }, + }, + [], + ], + }, + ], + }, + }, + }, + // Count non-folder and non-deleted items (APIs) + { + $project: { + apisCount: { + $size: { + $filter: { + input: { $ifNull: ["$items", []] }, + as: "item", + cond: { + $and: [ + { $ne: ["$$item.type", "FOLDER"] }, + { $ne: ["$$item.isDeleted", true] }, + ], + }, + }, + }, + }, + }, + }, + ], + as: "collectionsData", + }, + }, + // Group by userId and compute metrics + { + $group: { + _id: "$users.id", + activeWorkspaces: { + $sum: { + $cond: [ + { + $or: [ + { + $and: [ + { $gte: ["$createdAt", start] }, + { $lte: ["$createdAt", end] }, + ], + }, + { + $and: [ + { $gte: ["$updatedAt", start] }, + { $lte: ["$updatedAt", end] }, + ], + }, + ], + }, + 1, + 0, + ], + }, + }, + newWorkspaces: { + $sum: { + $cond: [ + { + $and: [ + { $gte: ["$createdAt", start] }, + { $lte: ["$createdAt", end] }, + ], + }, + 1, + 0, + ], + }, + }, + collectionsCount: { + $sum: { $size: { $ifNull: ["$collection", []] } }, + }, + apisCount: { + $sum: { + $reduce: { + input: "$collectionsData", + initialValue: 0, + in: { $add: ["$$value", "$$this.apisCount"] }, + }, + }, + }, + }, + }, + ]) + .toArray(); + + const metricsMap = new Map< + string, + { + activeWorkspaces: number; + newWorkspaces: number; + collectionsCount: number; + apisCount: number; + } + >(); + + for (const result of results) { + metricsMap.set(result._id, { + activeWorkspaces: result.activeWorkspaces || 0, + newWorkspaces: result.newWorkspaces || 0, + collectionsCount: result.collectionsCount || 0, + apisCount: result.apisCount || 0, + }); + } + + return metricsMap; + } } diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index 38aac319c..98457ae0d 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,9 +9,9 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} /** - * Runs every 5 minutes + * Runs every 30 minutes */ - @Cron(CronExpression.EVERY_5_MINUTES, { + @Cron(CronExpression.EVERY_30_MINUTES, { name: "weekly-digest", waitForCompletion: true, }) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 13413abfd..3cda7d89c 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -7,10 +7,46 @@ import { EmailService } from "@src/modules/common/services/email.service"; import { ConfigService } from "@nestjs/config"; import { UpdatesRepository } from "../repositories/updates.repository"; import { UserInvitesRepository } from "@src/modules/identity/repositories/userInvites.repository"; +import { ObjectId, WithId } from "mongodb"; +import { User } from "@src/modules/common/models/user.model"; + +/** Configuration for batch processing and concurrency */ +interface BatchConfig { + userBatchSize: number; + emailConcurrency: number; +} + +/** Per-user metrics computed via batch aggregation */ +interface UserMetrics { + activeWorkspaces: number; + newWorkspaces: number; + collectionsCount: number; + apisCount: number; + testflowExecutions: number; +} + +/** Activity graph data for the digest */ +interface ActivityGraph { + totalExecutions: number; + percentChange: number; + graph: Array<{ height: number; isMax: boolean }>; +} + +/** Email data for a single user including their metrics */ +interface UserEmailData { + user: WithId; + metrics: UserMetrics; + collaborationUpdates: string[]; + pendingActions: string[]; +} @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = "sanil.nayak@techdome.net.in"; + private static readonly QA_DIGEST_EMAIL = "sanil0@yopmail.com"; + private static readonly DEFAULT_BATCH_SIZE = 100; + private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; + + private readonly logger = new Logger(WeeklyDigestService.name); constructor( private readonly userRepository: UserRepository, @@ -23,47 +59,128 @@ export class WeeklyDigestService { private readonly userInvitesRepository: UserInvitesRepository, ) {} - private readonly logger = new Logger(WeeklyDigestService.name); - - async processWeeklyDigest() { + /** + * Main entry point for processing weekly digest emails. + * Uses batching and cursor-based pagination to handle large user counts efficiently. + * All metrics are computed per-batch using MongoDB aggregation pipelines. + */ + async processWeeklyDigest(): Promise { this.logger.log("Processing weekly digest emails..."); - // const { start, end } = this.getLastWeekRange(); - // const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); + const config: BatchConfig = { + userBatchSize: WeeklyDigestService.DEFAULT_BATCH_SIZE, + emailConcurrency: WeeklyDigestService.DEFAULT_EMAIL_CONCURRENCY, + }; + const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; + // Time range for the digest (last 5 mins for testing, or use getLastWeekRange() for production) const end = new Date(); - const start = new Date(end.getTime() - 5 * 60 * 1000); // last 5 mins - + const start = new Date(end.getTime() - 5 * 60 * 1000); const prevEnd = new Date(start); const prevStart = new Date(prevEnd.getTime() - 5 * 60 * 1000); - // Fetch users - const users = - await this.userRepository.getUsersForWeeklyDigest(qaDigestEmail); + // Fetch lightweight global activity graph (only updates collection, not heavy) + const activityGraph = await this.fetchActivityGraph( + start, + end, + prevStart, + prevEnd, + ); + + // Process users in batches using cursor-based pagination + let lastCursor: ObjectId | undefined; + let totalUsersProcessed = 0; + let batchNumber = 0; + + while (true) { + batchNumber++; + this.logger.log(`Starting batch ${batchNumber}...`); + + // Fetch the next batch of users + const usersBatch = await this.getUsersBatch( + config.userBatchSize, + lastCursor, + qaDigestEmail, + ); - this.logger.log(`Total users found: ${users.length}`); + if (usersBatch.length === 0) { + this.logger.log(`No more users to process. Ending batch processing.`); + break; + } - if (users.length === 0) { - this.logger.warn(`No users found with weekly digest enabled`); - return; + this.logger.log( + `Batch ${batchNumber}: Processing ${usersBatch.length} users...`, + ); + + // Extract user IDs and emails for batch queries + const userIds = usersBatch.map((u) => u._id.toString()); + const emails = usersBatch.map((u) => u.email); + + // Fetch per-user data and metrics in bulk using aggregation + const userEmailDataMap = await this.getMetricsForUserBatch( + start, + end, + userIds, + emails, + usersBatch, + ); + + // Send emails with controlled concurrency + await this.sendEmailsBatch( + userEmailDataMap, + activityGraph, + start, + end, + config.emailConcurrency, + ); + + totalUsersProcessed += usersBatch.length; + this.logger.log( + `Batch ${batchNumber} complete. Total users processed: ${totalUsersProcessed}`, + ); + + // Update cursor for next batch + lastCursor = usersBatch[usersBatch.length - 1]._id; + + // If we got fewer users than the batch size, we've reached the end + if (usersBatch.length < config.userBatchSize) { + this.logger.log(`Reached end of users. Stopping batch processing.`); + break; + } } - // Workspace metric - const [ - newWorkspaces, - newCollections, - apisCreated, - testflowExecutions, - activeWorkspaces, - activityData, - prevActivityData, - ] = await Promise.all([ - this.workspaceRepository.getNewWorkspacesCount(start, end), - this.collectionRepository.getNewCollectionsCount(start, end), - this.collectionRepository.getApisCreatedCount(start, end), - this.testflowRepository.getTestflowsExecutionCount(start, end), - this.workspaceRepository.getActiveWorkspacesCount(start, end), + this.logger.log( + `Weekly digest processing complete. Total users processed: ${totalUsersProcessed}`, + ); + } + + /** + * Fetch a batch of users using cursor-based pagination. + */ + private async getUsersBatch( + batchSize: number, + lastCursor?: ObjectId, + qaEmail?: string, + ): Promise[]> { + return this.userRepository.getUsersBatchForWeeklyDigest( + batchSize, + lastCursor, + qaEmail, + ); + } + + /** + * Fetch lightweight activity graph data. + * Only queries the updates collection which is lightweight compared to workspace/collection scans. + */ + private async fetchActivityGraph( + start: Date, + end: Date, + prevStart: Date, + prevEnd: Date, + ): Promise { + const [activityData, prevActivityData] = await Promise.all([ this.updatesRepository.getWeeklyActivity(start, end), this.updatesRepository.getWeeklyActivity(prevStart, prevEnd), ]); @@ -75,7 +192,6 @@ export class WeeklyDigestService { const previousCount = prevDailyExecutions.reduce((a, b) => a + b, 0); let percentChange = 0; - if (previousCount === 0 && totalExecutions > 0) { percentChange = 100; } else if (previousCount > 0) { @@ -85,76 +201,199 @@ export class WeeklyDigestService { } const graphHeights = this.normalizeGraphData(dailyExecutions); - const max = Math.max(...graphHeights); - const graph = graphHeights.map((height) => ({ height, isMax: height === max, })); - const transporter = this.emailService.createTransporter(); - const senderEmail = this.configService.get("app.senderEmail"); - const appUrl = this.configService.get("app.url"); + return { + totalExecutions, + percentChange, + graph, + }; + } + /** + * Fetch per-user metrics for a batch of users using bulk aggregation queries. + * Computes workspace, collection, API, and testflow metrics using MongoDB aggregation. + * Avoids N+1 queries by fetching all data in bulk. + */ + private async getMetricsForUserBatch( + start: Date, + end: Date, + userIds: string[], + emails: string[], + users: WithId[], + ): Promise> { + // Build user-to-workspaces map for testflow metrics + const userWorkspacesMap = new Map(); for (const user of users) { - if (user.isWeeklyDigestEnabled === false) continue; - const [updates, pendingInvites] = await Promise.all([ - this.updatesRepository.getUpdatesForEmail( + const workspaceIds = (user.workspaces || []).map((w) => w.workspaceId); + userWorkspacesMap.set(user._id.toString(), workspaceIds); + } + + // Fetch all metrics in parallel using aggregation pipelines + const [workspaceMetricsMap, testflowMetricsMap, updatesMap, invitesMap] = + await Promise.all([ + this.workspaceRepository.getWorkspaceMetricsForUserBatch( + userIds, start, end, - user._id.toString(), ), - this.userInvitesRepository.getPendingInvites(start, end, user.email), + this.testflowRepository.getTestflowMetricsForUserBatch( + userWorkspacesMap, + start, + end, + ), + this.updatesRepository.getUpdatesForBatch(start, end, userIds), + this.userInvitesRepository.getPendingInvitesForBatch( + start, + end, + emails, + ), ]); - const collaborationUpdates = updates.map((u) => u.message); + // Build the email data map for each user + const userEmailDataMap = new Map(); - const pendingActions = pendingInvites.map( - (inv) => `Invitation sent to ${inv.email}`, - ); + for (const user of users) { + if (user.isWeeklyDigestEnabled === false) { + continue; + } - const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; - - const mailOptions = { - from: senderEmail, - to: user.email, - template: "weeklyDigestEmail", - subject: "Your Weekly Digest ๐Ÿ“Š", - - headers: { - "List-Unsubscribe": `<${unsubscribeLink}>`, - "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", - }, - context: { - userName: user.name || user.email, - - dateRange: `${start.toDateString()} - ${end.toDateString()}`, - - execution: { - total: totalExecutions, - percent: percentChange, - graph, - }, - - metrics: { - newWorkspaces, - newCollections, - apisCreated, - testflowsExecuted: testflowExecutions, - activeWorkspaces, - }, - - ctaLink: "https://sparrowapp.dev", - collaborationUpdates, - pendingActions, - unsubscribeLink, - }, + const userId = user._id.toString(); + const collaborationUpdates = updatesMap.get(userId) || []; + const pendingActions = invitesMap.get(user.email) || []; + + // Get workspace metrics for this user + const workspaceMetrics = workspaceMetricsMap.get(userId) || { + activeWorkspaces: 0, + newWorkspaces: 0, + collectionsCount: 0, + apisCount: 0, }; - await this.emailService.sendEmail(transporter, mailOptions); + // Get testflow metrics for this user + const testflowExecutions = testflowMetricsMap.get(userId) || 0; + + const metrics: UserMetrics = { + activeWorkspaces: workspaceMetrics.activeWorkspaces, + newWorkspaces: workspaceMetrics.newWorkspaces, + collectionsCount: workspaceMetrics.collectionsCount, + apisCount: workspaceMetrics.apisCount, + testflowExecutions, + }; + + userEmailDataMap.set(userId, { + user, + metrics, + collaborationUpdates, + pendingActions, + }); + } - this.logger.log(`Weekly digest sent to ${user.email}`); + return userEmailDataMap; + } + + /** + * Send emails to a batch of users with controlled concurrency. + * Uses a promise pool pattern to limit concurrent email sends. + */ + private async sendEmailsBatch( + userEmailDataMap: Map, + activityGraph: ActivityGraph, + start: Date, + end: Date, + concurrency: number, + ): Promise { + const transporter = this.emailService.createTransporter(); + const senderEmail = this.configService.get("app.senderEmail"); + const appUrl = this.configService.get("app.url"); + + const users = Array.from(userEmailDataMap.values()); + + // Process emails with controlled concurrency using a promise pool + await this.processWithConcurrency( + users, + concurrency, + async (userData: UserEmailData) => { + try { + const { user, metrics, collaborationUpdates, pendingActions } = + userData; + + const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; + + const mailOptions = { + from: senderEmail, + to: user.email, + template: "weeklyDigestEmail", + subject: "Your Weekly Digest ๐Ÿ“Š", + headers: { + "List-Unsubscribe": `<${unsubscribeLink}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + context: { + userName: user.name || user.email, + dateRange: `${start.toDateString()} - ${end.toDateString()}`, + // Activity graph is shared (lightweight global data) + execution: { + total: activityGraph.totalExecutions, + percent: activityGraph.percentChange, + graph: activityGraph.graph, + }, + // Per-user metrics computed via batch aggregation + metrics: { + newWorkspaces: metrics.newWorkspaces, + newCollections: metrics.collectionsCount, + apisCreated: metrics.apisCount, + testflowsExecuted: metrics.testflowExecutions, + activeWorkspaces: metrics.activeWorkspaces, + }, + ctaLink: "https://sparrowapp.dev", + collaborationUpdates, + pendingActions, + unsubscribeLink, + }, + }; + + await this.emailService.sendEmail(transporter, mailOptions); + this.logger.log(`Weekly digest sent to ${user.email}`); + } catch (error) { + this.logger.error( + `Failed to send weekly digest to ${userData.user.email}: ${error.message}`, + ); + } + }, + ); + } + + /** + * Process items with controlled concurrency using a promise pool pattern. + * This ensures we don't overwhelm the email service with too many concurrent requests. + */ + private async processWithConcurrency( + items: T[], + concurrency: number, + processor: (item: T) => Promise, + ): Promise { + const queue = [...items]; + const executing: Promise[] = []; + + while (queue.length > 0 || executing.length > 0) { + // Fill up to concurrency limit + while (executing.length < concurrency && queue.length > 0) { + const item = queue.shift()!; + const promise = processor(item).then(() => { + executing.splice(executing.indexOf(promise), 1); + }); + executing.push(promise); + } + + // Wait for at least one to complete + if (executing.length > 0) { + await Promise.race(executing); + } } } From a2d8ac18535a859580d638679b4fea80e543e2fb Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 18 Mar 2026 23:06:37 +0530 Subject: [PATCH 22/25] fix: minor fix [SPRW-3110] --- src/modules/workspace/services/weekly-digest.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 3cda7d89c..d037eb317 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -74,11 +74,11 @@ export class WeeklyDigestService { const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; - // Time range for the digest (last 5 mins for testing, or use getLastWeekRange() for production) + // Time range for the digest (last 30 mins for testing, or use getLastWeekRange() for production) const end = new Date(); - const start = new Date(end.getTime() - 5 * 60 * 1000); + const start = new Date(end.getTime() - 30 * 60 * 1000); const prevEnd = new Date(start); - const prevStart = new Date(prevEnd.getTime() - 5 * 60 * 1000); + const prevStart = new Date(prevEnd.getTime() - 30 * 60 * 1000); // Fetch lightweight global activity graph (only updates collection, not heavy) const activityGraph = await this.fetchActivityGraph( From 3550a4d0b8a2100ada4719e5c7d00f72d69b4f88 Mon Sep 17 00:00:00 2001 From: LordNayan Date: Thu, 19 Mar 2026 12:24:05 +0530 Subject: [PATCH 23/25] chore: disable weekly digest scheduler --- .../workspace/schedulers/weekly-digest.scheduler.ts | 9 +++++---- src/modules/workspace/services/weekly-digest.service.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index bc894a615..fc81a6194 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -8,10 +8,11 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} - @Cron(CronExpression.EVERY_30_MINUTES, { - name: "weekly-digest", - waitForCompletion: true, - }) + // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. + // @Cron(CronExpression.EVERY_30_MINUTES, { + // name: "weekly-digest", + // waitForCompletion: true, + // }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index d037eb317..432e303dd 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -42,7 +42,7 @@ interface UserEmailData { @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = "sanil0@yopmail.com"; + private static readonly QA_DIGEST_EMAIL = ""; private static readonly DEFAULT_BATCH_SIZE = 100; private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; From 32a893e03140f9e8749be4742484f352a6a7071f Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 20 Mar 2026 14:48:01 +0530 Subject: [PATCH 24/25] fix: trial extension fix and console [SPRW-3087] --- .../billing/services/stripe-subscription.service.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index 5d0926297..83f172e59 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -514,7 +514,17 @@ export class StripeSubscriptionService { const isTrialOngoing = trialEndDateStr && new Date(trialEndDateStr).getTime() > Date.now(); - const isTrialExtension = metadata?.trialExtension === "true"; + const isTrialExtension = + metadata?.trialExtension === "true" || + (team?.billing?.in_trial === true && metadata?.trial_end_date); + + console.log("TRIAL EXTENSION DEBUG:", { + hubId: metadata?.hubId, + metadataTrialExtension: metadata?.trialExtension, + metadataTrialEndDate: metadata?.trial_end_date, + billingInTrial: team?.billing?.in_trial, + isTrialExtension, + }); // Initialize or update the licenses object based on billing seats const currentSeats = latestSubscription?.quantity || metadata?.userCount || 1; From 2474e99b61b19a1d23964cea9df944b17f979178 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 20 Mar 2026 15:44:34 +0530 Subject: [PATCH 25/25] fix: console log remove for extension api [SPRW-3087] --- .../billing/services/stripe-subscription.service.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/modules/billing/services/stripe-subscription.service.ts b/src/modules/billing/services/stripe-subscription.service.ts index 83f172e59..80b904367 100644 --- a/src/modules/billing/services/stripe-subscription.service.ts +++ b/src/modules/billing/services/stripe-subscription.service.ts @@ -518,13 +518,6 @@ export class StripeSubscriptionService { metadata?.trialExtension === "true" || (team?.billing?.in_trial === true && metadata?.trial_end_date); - console.log("TRIAL EXTENSION DEBUG:", { - hubId: metadata?.hubId, - metadataTrialExtension: metadata?.trialExtension, - metadataTrialEndDate: metadata?.trial_end_date, - billingInTrial: team?.billing?.in_trial, - isTrialExtension, - }); // Initialize or update the licenses object based on billing seats const currentSeats = latestSubscription?.quantity || metadata?.userCount || 1;