Skip to content

Commit 8095ae9

Browse files
committed
feat: autodown grade with email template
1 parent 5b1f05d commit 8095ae9

14 files changed

+1390
-30
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"crypto-js": "^4.2.0",
8383
"curlconverter": "^4.9.0",
8484
"dotenv": "16.0.1",
85+
"exceljs": "^4.4.0",
8586
"fastify": "4.28.1",
8687
"form-data": "^4.0.1",
8788
"gravatar": "1.8.2",

pnpm-lock.yaml

Lines changed: 427 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/modules/billing/billing.module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DownGradeTeamRepository } from "./repositories/downgradeTeam.repository
2626
import { DownGradeUserRepository } from "./repositories/downgradeUser.repository";
2727
import { DownGradeWorkspaceRepository } from "./repositories/downgradeWorkspace.repository";
2828
import { DownGradeService } from "./services/downgrade.service";
29+
import { ExcelEmailService } from "./services/excel-email.service";
2930

3031
// Try to import the Stripe module, but don't crash if it's not available
3132
let StripeModule: any;
@@ -56,6 +57,7 @@ export class BillingModule {
5657
DownGradeUserRepository,
5758
DownGradeWorkspaceRepository,
5859
DownGradeService,
60+
ExcelEmailService,
5961
BillingAuditService,
6062
PromoCodeService,
6163
PricingService,
@@ -85,6 +87,7 @@ export class BillingModule {
8587
DownGradeUserRepository,
8688
DownGradeWorkspaceRepository,
8789
DownGradeService,
90+
ExcelEmailService,
8891
PricingService,
8992
PaymentEmailService,
9093
PaymentEmailHelper,

src/modules/billing/helpers/payment-email.helper.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
} from "../services/payment-email.service";
77
import { StripeCustomerService } from "../services/stripe-customer.service";
88
import { PlanName } from "@src/modules/common/enum/plan.enum";
9+
import {
10+
UserExcelDto,
11+
WorkspaceExcelDto,
12+
} from "../payloads/downgrade-user.payload";
913

1014
// Dynamically import payment methods service
1115
let PaymentMethodsService: any;
@@ -187,6 +191,64 @@ export class PaymentEmailHelper {
187191
}
188192
}
189193

194+
/**
195+
* Send plan downgraded emails to existing users in Hub.
196+
*/
197+
async sendHubDowngradedEmail(
198+
team: any,
199+
startDate: Date,
200+
previousPlan: string,
201+
newPlan: string,
202+
sendEmails: string[],
203+
workspaces: WorkspaceExcelDto[],
204+
users: UserExcelDto[],
205+
): Promise<void> {
206+
try {
207+
const emailData = await this.buildPlanDowngradeEmailData(
208+
team,
209+
startDate,
210+
previousPlan,
211+
newPlan,
212+
);
213+
if (!emailData) return;
214+
const updateEmailData = { ...emailData, sendEmails, workspaces, users };
215+
await this.paymentEmailService.sendPaymentEmail(
216+
PaymentEmailType.HUB_DOWNGRADED,
217+
updateEmailData,
218+
);
219+
} catch (error) {
220+
console.error("Error sending plan downgraded email:", error);
221+
}
222+
}
223+
224+
/**
225+
* Send plan downgraded emails to Removed users in Hub.
226+
*/
227+
async sendHubDowngradeRemoveUserEmail(
228+
team: any,
229+
startDate: Date,
230+
previousPlan: string,
231+
newPlan: string,
232+
sendEmails: string[],
233+
): Promise<void> {
234+
try {
235+
const emailData = await this.buildPlanDowngradeEmailData(
236+
team,
237+
startDate,
238+
previousPlan,
239+
newPlan,
240+
);
241+
if (!emailData) return;
242+
const updateEmailData = { ...emailData, sendEmails };
243+
await this.paymentEmailService.sendPaymentEmail(
244+
PaymentEmailType.HUB_DOWNGRADED_REMOVE_USER,
245+
updateEmailData,
246+
);
247+
} catch (error) {
248+
console.error("Error sending plan downgraded email:", error);
249+
}
250+
}
251+
190252
/**
191253
* Send upcoming payment email using customer's default payment method email
192254
*/

src/modules/billing/payloads/downgrade-user.payload.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,16 @@ export class DownGradeUserTourGuideDto extends DownGradeUserGenerateVariableDto
112112
@Type(() => TourGuideDto)
113113
tourGuide?: TourGuideDto;
114114
}
115+
116+
export interface WorkspaceExcelDto {
117+
name: string;
118+
created_at: string;
119+
collections: number;
120+
testflow: number;
121+
}
122+
123+
export interface UserExcelDto {
124+
name: string;
125+
email: string;
126+
}
127+

src/modules/billing/repositories/downgradeUser.repository.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable, Inject } from "@nestjs/common";
1+
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
22
import { Db } from "mongodb";
33
import { ObjectId, WithId } from "mongodb";
44
import { User } from "@src/modules/common/models/user.model";
@@ -38,4 +38,20 @@ export class DownGradeUserRepository {
3838
.toArray();
3939
return response;
4040
}
41+
42+
async findUsersByStringIds(ids: string[]): Promise<WithId<User>[]> {
43+
try {
44+
const objectIds = ids.map((id) => new ObjectId(id));
45+
const users = await this.db
46+
.collection<User>(Collections.USER)
47+
.find({ _id: { $in: objectIds } })
48+
.toArray();
49+
if (!users || users.length === 0) {
50+
return;
51+
}
52+
return users;
53+
} catch (error) {
54+
console.error("Error fetching users by string IDs:", error);
55+
}
56+
}
4157
}

src/modules/billing/repositories/downgradeWorkspace.repository.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
22
import { Collections } from "@src/modules/common/enum/database.collection.enum";
3-
import { Workspace } from "@src/modules/common/models/workspace.model";
3+
import { UserDto, Workspace } from "@src/modules/common/models/workspace.model";
44
import { Db, DeleteResult } from "mongodb";
55
import { ObjectId, WithId } from "mongodb";
66

@@ -22,6 +22,26 @@ export class DownGradeWorkspaceRepository {
2222
return data;
2323
}
2424

25+
async getWorkspacesByIds(ids: string[]): Promise<WithId<Workspace>[]> {
26+
if (!ids || ids.length === 0) {
27+
throw new BadRequestException("workspaceIds are required.");
28+
}
29+
const objectIds = ids.map((id) => new ObjectId(id));
30+
try {
31+
const data = await this.db
32+
.collection<Workspace>(Collections.WORKSPACE)
33+
.find({ _id: { $in: objectIds } })
34+
.toArray();
35+
if (!data || data.length === 0) {
36+
throw new BadRequestException("No workspaces found.");
37+
}
38+
return data;
39+
} catch (error) {
40+
console.error("Error fetching workspaces:", error);
41+
throw new BadRequestException("Failed to fetch workspaces.");
42+
}
43+
}
44+
2545
delete(id: string): Promise<DeleteResult> {
2646
const _id = new ObjectId(id);
2747
return this.db
@@ -46,4 +66,33 @@ export class DownGradeWorkspaceRepository {
4666
}
4767
return result.value;
4868
}
69+
70+
async updateWorkspaceUsers(
71+
workspaceId: string,
72+
updatedUsers: UserDto[],
73+
): Promise<any> {
74+
if (!workspaceId) {
75+
throw new BadRequestException("workspaceId is required.");
76+
}
77+
if (!Array.isArray(updatedUsers)) {
78+
throw new BadRequestException("updatedUsers must be an array.");
79+
}
80+
const _id = new ObjectId(workspaceId);
81+
const updatePayload: Partial<Workspace> = {
82+
users: updatedUsers,
83+
updatedAt: new Date(),
84+
};
85+
try {
86+
const result = await this.db
87+
.collection<Workspace>(Collections.WORKSPACE)
88+
.updateOne({ _id }, { $set: updatePayload });
89+
if (result.matchedCount === 0) {
90+
throw new BadRequestException("Workspace not found.");
91+
}
92+
return result;
93+
} catch (error) {
94+
console.error("Error updating workspace users:", error);
95+
throw new BadRequestException("Failed to update workspace users.");
96+
}
97+
}
4998
}

src/modules/billing/services/downgrade.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ export class DownGradeService {
5656
const userFilteredWorkspaces = userData.workspaces.filter(
5757
(workspace) => workspace.teamId !== payload.teamId,
5858
);
59+
const workspaces = teamData.workspaces;
60+
for (let workspace of workspaces) {
61+
const workspaceData = await this.downgradeWorkspaceReposiory.get(
62+
workspace.id.toString(),
63+
);
64+
const updatedUsers = workspaceData.users.filter(
65+
(user) => user.id !== payload.userId,
66+
);
67+
await this.downgradeWorkspaceReposiory.updateWorkspaceUsers(
68+
workspace.id.toString(),
69+
updatedUsers,
70+
);
71+
}
5972
const userUpdatedParams = {
6073
teams: userFilteredTeams,
6174
workspaces: userFilteredWorkspaces,
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// src/excel/excel.service.ts
2+
import { Injectable } from "@nestjs/common";
3+
import * as ExcelJS from "exceljs";
4+
import {
5+
UserExcelDto,
6+
WorkspaceExcelDto,
7+
} from "../payloads/downgrade-user.payload";
8+
9+
@Injectable()
10+
export class ExcelEmailService {
11+
/**
12+
* Generate downgrade summary Excel with deleted workspaces and members
13+
*/
14+
async generateDowngradeSummaryExcel(
15+
workspaces: WorkspaceExcelDto[],
16+
users: UserExcelDto[],
17+
): Promise<Buffer> {
18+
const workbook = new ExcelJS.Workbook();
19+
// Set workbook properties
20+
workbook.creator = "Sparrow";
21+
workbook.created = new Date();
22+
workbook.modified = new Date();
23+
// ==================== Deleted Workspaces Sheet ====================
24+
const workspacesSheet = workbook.addWorksheet("Deleted Workspaces", {
25+
properties: { tabColor: { argb: "FF316CF6" } },
26+
});
27+
// Define columns for workspaces
28+
workspacesSheet.columns = [
29+
{ header: "Workspace Name", key: "name", width: 35 },
30+
{ header: "Created At", key: "created_at", width: 20 },
31+
{ header: "Collections", key: "collections", width: 15 },
32+
{ header: "Test Flows", key: "testflow", width: 15 },
33+
];
34+
// Style header row for workspaces
35+
const workspaceHeaderRow = workspacesSheet.getRow(1);
36+
workspaceHeaderRow.font = {
37+
bold: true,
38+
color: { argb: "FFFFFFFF" },
39+
size: 12,
40+
};
41+
workspaceHeaderRow.fill = {
42+
type: "pattern",
43+
pattern: "solid",
44+
fgColor: { argb: "FF316CF6" },
45+
};
46+
workspaceHeaderRow.alignment = {
47+
vertical: "middle",
48+
horizontal: "center",
49+
};
50+
workspaceHeaderRow.height = 25;
51+
// Add border to header
52+
workspaceHeaderRow.eachCell((cell) => {
53+
cell.border = {
54+
top: { style: "thin" },
55+
left: { style: "thin" },
56+
bottom: { style: "thin" },
57+
right: { style: "thin" },
58+
};
59+
});
60+
// Add workspace data
61+
workspaces.forEach((workspace, index) => {
62+
const row = workspacesSheet.addRow({
63+
name: workspace.name,
64+
created_at: workspace.created_at || "N/A",
65+
collections: workspace.collections,
66+
testflow: workspace.testflow,
67+
});
68+
// Alternate row colors for better readability
69+
if (index % 2 === 0) {
70+
row.fill = {
71+
type: "pattern",
72+
pattern: "solid",
73+
fgColor: { argb: "FFF8F9FA" },
74+
};
75+
}
76+
// Add borders to data cells
77+
row.eachCell((cell) => {
78+
cell.border = {
79+
top: { style: "thin", color: { argb: "FFE0E0E0" } },
80+
left: { style: "thin", color: { argb: "FFE0E0E0" } },
81+
bottom: { style: "thin", color: { argb: "FFE0E0E0" } },
82+
right: { style: "thin", color: { argb: "FFE0E0E0" } },
83+
};
84+
cell.alignment = { vertical: "middle" };
85+
});
86+
});
87+
// ==================== Deleted Members Sheet ====================
88+
const membersSheet = workbook.addWorksheet("Deleted Members", {
89+
properties: { tabColor: { argb: "FF316CF6" } },
90+
});
91+
// Define columns for members
92+
membersSheet.columns = [
93+
{ header: "Name", key: "name", width: 30 },
94+
{ header: "Email", key: "email", width: 40 },
95+
{ header: "Role", key: "role", width: 15 },
96+
];
97+
// Style header row for members
98+
const memberHeaderRow = membersSheet.getRow(1);
99+
memberHeaderRow.font = {
100+
bold: true,
101+
color: { argb: "FFFFFFFF" },
102+
size: 12,
103+
};
104+
memberHeaderRow.fill = {
105+
type: "pattern",
106+
pattern: "solid",
107+
fgColor: { argb: "FF316CF6" },
108+
};
109+
memberHeaderRow.alignment = {
110+
vertical: "middle",
111+
horizontal: "center",
112+
};
113+
memberHeaderRow.height = 25;
114+
115+
// Add border to header
116+
memberHeaderRow.eachCell((cell) => {
117+
cell.border = {
118+
top: { style: "thin" },
119+
left: { style: "thin" },
120+
bottom: { style: "thin" },
121+
right: { style: "thin" },
122+
};
123+
});
124+
125+
// Add member data
126+
users.forEach((user, index) => {
127+
const row = membersSheet.addRow({
128+
name: user.name,
129+
email: user.email,
130+
});
131+
132+
// Alternate row colors
133+
if (index % 2 === 0) {
134+
row.fill = {
135+
type: "pattern",
136+
pattern: "solid",
137+
fgColor: { argb: "FFF8F9FA" },
138+
};
139+
}
140+
141+
// Add borders to data cells
142+
row.eachCell((cell) => {
143+
cell.border = {
144+
top: { style: "thin", color: { argb: "FFE0E0E0" } },
145+
left: { style: "thin", color: { argb: "FFE0E0E0" } },
146+
bottom: { style: "thin", color: { argb: "FFE0E0E0" } },
147+
right: { style: "thin", color: { argb: "FFE0E0E0" } },
148+
};
149+
cell.alignment = { vertical: "middle" };
150+
});
151+
});
152+
153+
// Generate and return buffer
154+
const buffer = await workbook.xlsx.writeBuffer();
155+
return Buffer.from(buffer);
156+
}
157+
}

0 commit comments

Comments
 (0)