Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ee913f3
feat: trial-extension-per-hub [SPRW-3087]
mayankjha-eng Mar 6, 2026
8db822d
feat: backed plan adddition per hub [SPRW-3087]
mayankjha-eng Mar 9, 2026
348f27f
feat: plan change per hub [SPRW-3087]
mayankjha-eng Mar 9, 2026
db5e1c4
fix: extend trial fix [SPRW-3087]
mayankjha-eng Mar 9, 2026
8075e24
fix: extend email fix [SPRW-3087]
mayankjha-eng Mar 9, 2026
9844043
fix: plan addition and plan change email fix [SPRW-3087]
mayankjha-eng Mar 10, 2026
a0e582e
fix: swagger [SPRW-3087]
mayankjha-eng Mar 10, 2026
d09102f
feat: weekly-digest schedular and service infra [SPRW-3110]
mayankjha-eng Mar 13, 2026
763f6b9
Merge pull request #954 from mayankjha-eng/feat/SPRW-3087/simplifying…
LordNayan Mar 16, 2026
654c753
feat: user fetch added [SPRW-3110]
mayankjha-eng Mar 13, 2026
e895830
feat: restrict admin hub APIs to super-admin users [SPRW-3087]
mayankjha-eng Mar 16, 2026
8a0c8c6
Merge pull request #955 from mayankjha-eng/feat/SPRW-3087/simplifying…
LordNayan Mar 16, 2026
556bd85
feat: fetch user data from db [SPRW-3110]
mayankjha-eng Mar 17, 2026
6b786b7
feat: email ui [SPRW-3110]
mayankjha-eng Mar 18, 2026
c262e71
feat: collaboration and pending functionality [SPRW-3110]
mayankjha-eng Mar 18, 2026
1664a6e
feat: unsubscribe functionality [SPRW-3110]
mayankjha-eng Mar 18, 2026
bae3ba7
feat: percentage fixed [SPRW-3110]
mayankjha-eng Mar 18, 2026
cf30aea
fix: testflow fixed [SPRW-3110]
mayankjha-eng Mar 18, 2026
ee60c3f
fix: 5 min for test [SPRW-3110]
mayankjha-eng Mar 18, 2026
8dc400f
Merge pull request #956 from mayankjha-eng/feat/SPRW-3110/weekly-dige…
LordNayan Mar 18, 2026
e868695
fix: change weekly digest cron expression from 5 sec to 5 minutes
Mar 18, 2026
af1073d
Merge pull request #957 from LordNayan/fix/cron-expression
jatinlodhi2002 Mar 18, 2026
e88ffc7
fix: change weekly digest cron expression from 5 sec to 5 minutes
Mar 18, 2026
6004aa6
Merge branch 'development' into fix/cron-expression
LordNayan Mar 18, 2026
dfb041f
Merge pull request #958 from LordNayan/fix/cron-expression
LordNayan Mar 18, 2026
ff4f5a4
fix: cron commented [SPRW-3110]
mayankjha-eng Mar 18, 2026
e7cf531
Merge pull request #959 from mayankjha-eng/fix/SPRW-3110/cron-fix
LordNayan Mar 18, 2026
1a06e8a
fix: batch calling and code fixes [SPRW-3110]
mayankjha-eng Mar 18, 2026
a2d8ac1
fix: minor fix [SPRW-3110]
mayankjha-eng Mar 18, 2026
77c4f78
Merge branch 'development' into fix/SPRW-3110/weekly-digest-email-fix
LordNayan Mar 18, 2026
10c9479
Merge pull request #960 from mayankjha-eng/fix/SPRW-3110/weekly-diges…
LordNayan Mar 18, 2026
3550a4d
chore: disable weekly digest scheduler
LordNayan Mar 19, 2026
0997274
Merge pull request #961 from LordNayan/feat/disable-weekly-digest
jatinlodhi2002 Mar 19, 2026
32a893e
fix: trial extension fix and console [SPRW-3087]
mayankjha-eng Mar 20, 2026
098cc86
Merge pull request #962 from mayankjha-eng/fix/SPRW-3087/trial-extens…
LordNayan Mar 20, 2026
2474e99
fix: console log remove for extension api [SPRW-3087]
mayankjha-eng Mar 20, 2026
517705b
Merge pull request #963 from mayankjha-eng/fix/SPRW-3087/trial-extens…
LordNayan Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/modules/billing/services/payment-email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export enum PaymentEmailType {
SUBSCRIPTION_EXPIRED = "subscription_expired",
PAYMENT_INFO_UPDATED = "payment_info_updated",
DOWNGRADED_TO_COMMUNITY = "downgraded_to_community",
TRIAL_EXTENDED = "trial_extended",
PLAN_ADDED = "plan_added",
}

export interface PaymentEmailData {
Expand Down Expand Up @@ -50,6 +52,10 @@ export interface PaymentEmailData {
workspaces?: any;
users?: any;
sendEmails?: string[];
price?: number;
features?: string[];
upgradeDate?: string;
nextBillingDate?: string;
}

@Injectable()
Expand Down Expand Up @@ -108,6 +114,12 @@ export class PaymentEmailService {
case PaymentEmailType.DOWNGRADED_TO_COMMUNITY:
await this.sendDowngradedToCommunityEmail(data);
break;
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}`);
}
Expand Down Expand Up @@ -634,7 +646,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);
Expand All @@ -646,4 +658,52 @@ export class PaymentEmailService {
day: "numeric",
});
}

private async sendTrialExtendedEmail(data: PaymentEmailData): Promise<void> {
const transporter = this.emailService.createTransporter();
const emailsToSend = data.sendEmails || [data.ownerEmail];

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);
}
}

private async sendPlanAddedEmail(data: PaymentEmailData): Promise<void> {
const transporter = this.emailService.createTransporter();

const mailOptions = {
from: this.configService.get("app.senderEmail"),
to: data.ownerEmail,
text: "Plan Updated",
template: "planUpgradedEmail",
context: {
firstName: this.extractFirstName(data.ownerName),
hubName: data.hubName,
newPlanName: data.planName,
features: data.features,
price: data.price,
interval: data.interval,
upgradeDate: data.upgradeDate,
nextBillingDate: data.nextBillingDate,
sparrowEmail: this.configService.get("support.sparrowEmail"),
},
subject: `Your hub ${data.hubName} has been upgraded to ${data.planName}`,
};

await this.emailService.sendEmail(transporter, mailOptions);
}
}
35 changes: 30 additions & 5 deletions src/modules/billing/services/stripe-subscription.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,10 @@ export class StripeSubscriptionService {
const isTrialOngoing =
trialEndDateStr && new Date(trialEndDateStr).getTime() > Date.now();

const isTrialExtension =
metadata?.trialExtension === "true" ||
(team?.billing?.in_trial === true && metadata?.trial_end_date);

// Initialize or update the licenses object based on billing seats
const currentSeats =
latestSubscription?.quantity || metadata?.userCount || 1;
Expand Down Expand Up @@ -630,10 +634,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,
Expand Down Expand Up @@ -663,7 +674,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 =
Expand Down
4 changes: 4 additions & 0 deletions src/modules/common/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ export class User {
@ValidateNested()
@Type(() => TourGuideDto)
tourGuide?: TourGuideDto;

@IsBoolean()
@IsOptional()
isWeeklyDigestEnabled?: boolean;
}

export class UserDto {
Expand Down
26 changes: 26 additions & 0 deletions src/modules/identity/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Req,
Res,
UseGuards,
Query,
} from "@nestjs/common";
import {
ApiBearerAuth,
Expand Down Expand Up @@ -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(`
<div style="
font-family: Arial, sans-serif;
text-align: center;
margin-top: 100px;
color: #111827;
">
<h2>✅ You have been unsubscribed</h2>
<p style="color:#6b7280;">
You will no longer receive weekly digest emails.
</p>
</div>
`);
}
}
87 changes: 86 additions & 1 deletion src/modules/identity/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -468,4 +472,85 @@ export class UserRepository {
return false;
}
}

async getAllUsers(): Promise<WithId<User>[]> {
return await this.db
.collection<User>(Collections.USER)
.find(
{ isEmailVerified: true }, // only verified users
{ projection: { password: 0 } },
)
.toArray();
}

async getUsersForWeeklyDigest(email?: string): Promise<WithId<User>[]> {
return await this.db
.collection<User>(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)
.updateOne(
{ _id: new ObjectId(userId) },
{ $set: { isWeeklyDigestEnabled: false } },
);
}

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<WithId<User>[]> {
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<User>(Collections.USER)
.find(query, {
projection: {
_id: 1,
email: 1,
name: 1,
isWeeklyDigestEnabled: 1,
},
})
.sort({ _id: 1 })
.limit(batchSize)
.toArray();
}
}
61 changes: 61 additions & 0 deletions src/modules/identity/repositories/userInvites.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,65 @@ 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();
}

/**
* 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<Map<string, string[]>> {
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<string, string[]>();
for (const result of results) {
const pendingActions = (result.invites || []).map(
(email: string) => `Invitation sent to ${email}`,
);
invitesMap.set(result._id, pendingActions);
}
return invitesMap;
}
}
7 changes: 7 additions & 0 deletions src/modules/identity/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,4 +840,11 @@ export class UserService {
});
return response;
}

async disableWeeklyDigest(userId: string) {
return this.userRepository.updateUserByQuery(
{ _id: new ObjectId(userId) },
{ $set: { isWeeklyDigestEnabled: false } },
);
}
}
Loading
Loading