feat(server): supplement team email remind (#9483)

fix PD-2047 AF-1996
This commit is contained in:
darkskygit
2025-01-21 05:18:02 +00:00
parent 42428fbf99
commit 1116a1d74e
13 changed files with 345 additions and 17 deletions

View File

@@ -31,6 +31,7 @@ import {
leaveWorkspace,
PermissionEnum,
revokeInviteLink,
revokeMember,
revokeUser,
signUp,
sleep,
@@ -722,5 +723,33 @@ test('should be able to emit events', async t => {
],
'should emit owner transferred event'
);
await revokeMember(app, read.token.token, tws.id, owner.id);
const [memberRemoved, memberUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
memberRemoved,
[
'workspace.members.removed',
{
userId: owner.id,
workspaceId: tws.id,
},
],
'should emit owner transferred event'
);
t.deepEqual(
memberUpdated,
[
'workspace.members.updated',
{
count: 3,
workspaceId: tws.id,
},
],
'should emit role changed event'
);
}
});

View File

@@ -180,3 +180,27 @@ export async function grantMember(
}
return res.body.data?.grantMember;
}
export async function revokeMember(
app: INestApplication,
token: string,
workspaceId: string,
userId: string
) {
const res = await request(app.getHttpServer())
.post(gql)
.auth(token, { type: 'bearer' })
.set({ 'x-request-id': 'test', 'x-operation-name': 'test' })
.send({
query: `
mutation {
revoke(workspaceId: "${workspaceId}", userId: "${userId}")
}
`,
})
.expect(200);
if (res.body.errors) {
throw new Error(res.body.errors[0].message);
}
return res.body.data?.revokeMember;
}

View File

@@ -17,6 +17,7 @@ export interface WorkspaceEvents {
}>;
ownerTransferred: Payload<{ email: string; workspaceId: Workspace['id'] }>;
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
removed: Payload<{ workspaceId: Workspace['id']; userId: User['id'] }>;
};
deleted: Payload<Workspace['id']>;
blob: {

View File

@@ -276,7 +276,39 @@ export class MailService {
}
// =================== Team Workspace Mails ===================
async sendReviewRequestMail(
async sendTeamWorkspaceUpgradedEmail(
to: string,
ws: { id: string; name: string; isOwner: boolean }
) {
const { id: workspaceId, name: workspaceName, isOwner } = ws;
const baseContent = {
subject: `${workspaceName} has been upgraded to team workspace! 🎉`,
title: 'Welcome to the team workspace!',
content: `Great news! ${workspaceName} has been upgraded to team workspace by the workspace owner. You now have access to the following enhanced features:`,
};
if (isOwner) {
baseContent.subject =
'Your workspace has been upgraded to team workspace! 🎉';
baseContent.title = 'Welcome to the team workspace!';
baseContent.content = `${workspaceName} has been upgraded to team workspace with the following benefits:`;
}
const html = emailTemplate({
title: baseContent.title,
content: `${baseContent.content}
✓ 100 GB initial storage + 20 GB per seat
✓ 500 MB of maximum file size
✓ Unlimited team members (10+ seats)
✓ Multiple admin roles
✓ Priority customer support`,
buttonContent: 'Open Workspace',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: baseContent.subject, html });
}
async sendReviewRequestEmail(
to: string,
invitee: string,
ws: { id: string; name: string }
@@ -324,7 +356,7 @@ export class MailService {
return this.sendMail({ to, subject, html });
}
async sendOwnerTransferred(to: string, ws: { name: string }) {
async sendOwnershipTransferredEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `Your ownership of ${workspaceName} has been transferred`;
@@ -334,4 +366,83 @@ export class MailService {
});
return this.sendMail({ to, subject: title, html });
}
async sendMemberRemovedEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `You have been removed from ${workspaceName}`;
const html = emailTemplate({
title: 'Workspace access removed',
content: `You have been removed from {workspace name}. You no longer have access to this workspace.`,
});
return this.sendMail({ to, subject: title, html });
}
async sendWorkspaceExpireRemindEmail(
to: string,
ws: {
id: string;
name: string;
expirationDate: Date;
deletionDate?: Date;
}
) {
const {
id: workspaceId,
name: workspaceName,
expirationDate,
deletionDate,
} = ws;
const baseContent: {
subject: string;
title: string;
content: string;
button?: { buttonContent: string; buttonUrl: string };
} = {
subject: `[Action Required] Your ${workspaceName} team workspace is expiring soon`,
title: 'Team workspace expiring soon',
content: `Your ${workspaceName} team workspace will expire on ${expirationDate}. After expiration, you won't be able to sync or collaborate with team members. Please renew your subscription to continue using all team features.`,
button: {
buttonContent: 'Go to Billing',
// TODO(@darkskygit): use real billing path
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
},
};
if (deletionDate) {
if (deletionDate.getTime() > Date.now()) {
// in 24 hours
if (deletionDate.getTime() - Date.now() < 24 * 60 * 60 * 1000) {
baseContent.subject = `[Action Required] Final warning: Your ${workspaceName} data will be deleted in 24 hours`;
baseContent.title = 'Urgent: Last chance to prevent data loss';
baseContent.content = `Your ${workspaceName} team workspace data will be permanently deleted in 24 hours on ${deletionDate}. To prevent data loss, please take immediate action:
<li>Renew your subscription to restore team features</li>
<li>Export your workspace data from Workspace Settings > Export Workspace</li>`;
} else {
baseContent.subject = `[Action Required] Important: Your ${workspaceName} data will be deleted soon`;
baseContent.title = 'Take action to prevent data loss';
baseContent.content = `Your ${workspaceName} team workspace expired on ${expirationDate}. All workspace data will be permanently deleted on ${deletionDate} (180 days after expiration). To prevent data loss, please either:
<li>Renew your subscription to restore team features</li>
<li>Export your workspace data from Workspace Settings > Export Workspace</li>`;
}
} else {
baseContent.subject = `Data deletion completed for ${workspaceName}`;
baseContent.title = 'Workspace data deleted';
baseContent.content = `All data in ${workspaceName} has been permanently deleted as the workspace remained expired for 180 days. This action cannot be undone.
Thank you for your support of AFFiNE. We hope to see you again in the future.`;
baseContent.button = undefined;
}
} else if (expirationDate.getTime() < Date.now()) {
baseContent.subject = `Your ${workspaceName} team workspace has expired`;
baseContent.title = 'Team workspace expired';
baseContent.content = `Your ${workspaceName} team workspace expired on ${expirationDate}. Your workspace can't sync or collaborate with team members. Please renew your subscription to restore all team features.`;
}
const html = emailTemplate({
title: baseContent.title,
content: baseContent.content,
...baseContent.button,
});
return this.sendMail({ to, subject: baseContent.subject, html });
}
}

View File

@@ -460,6 +460,10 @@ export class PermissionService {
workspaceId,
count,
});
this.event.emit('workspace.members.removed', {
workspaceId,
userId: user,
});
if (
permission.status === 'UnderReview' ||

View File

@@ -38,6 +38,7 @@ import {
WorkspaceBlobResolver,
WorkspaceService,
],
exports: [WorkspaceService],
})
export class WorkspaceModule {}

View File

@@ -2,7 +2,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { Cache, MailService, UserNotFound } from '../../../base';
import {
Cache,
type EventPayload,
MailService,
OnEvent,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { DocContentService } from '../../doc-renderer';
import { Permission, PermissionService } from '../../permission';
@@ -131,7 +137,24 @@ export class WorkspaceService {
return true;
}
async sendReviewRequestedMail(inviteId: string) {
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
await this.mailer.sendTeamWorkspaceUpgradedEmail(owner.email, {
...workspace,
isOwner: true,
});
for (const user of admin) {
await this.mailer.sendTeamWorkspaceUpgradedEmail(user.email, {
...workspace,
isOwner: false,
});
}
}
async sendReviewRequestedEmail(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
@@ -151,7 +174,7 @@ export class WorkspaceService {
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
for (const user of [owner, ...admin]) {
await this.mailer.sendReviewRequestMail(
await this.mailer.sendReviewRequestEmail(
user.email,
invitee.email,
workspace
@@ -159,7 +182,7 @@ export class WorkspaceService {
}
}
async sendInviteMail(inviteId: string) {
async sendInviteEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
@@ -208,8 +231,43 @@ export class WorkspaceService {
});
}
async sendOwnerTransferred(email: string, ws: { id: string }) {
async sendOwnerTransferredEmail(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendOwnerTransferred(email, { name: workspace.name });
await this.mailer.sendOwnershipTransferredEmail(email, {
name: workspace.name,
});
}
async sendMemberRemoved(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendMemberRemovedEmail(email, {
name: workspace.name,
});
}
@OnEvent('workspace.members.removed')
async onMemberRemoved({
userId,
workspaceId,
}: EventPayload<'workspace.members.requestDeclined'>) {
const user = await this.models.user.get(userId);
if (!user) return;
await this.sendMemberRemoved(user.email, { id: workspaceId });
}
@OnEvent('workspace.subscription.notify')
async onSubscriptionNotify({
workspaceId,
expirationDate,
deletionDate,
}: EventPayload<'workspace.subscription.notify'>) {
const owner = await this.permission.getWorkspaceOwner(workspaceId);
if (!owner) return;
const workspace = await this.getWorkspaceInfo(workspaceId);
await this.mailer.sendWorkspaceExpireRemindEmail(owner.email, {
...workspace,
expirationDate,
deletionDate,
});
}
}

View File

@@ -124,7 +124,7 @@ export class TeamWorkspaceResolver {
// after user click the invite link, we can check again and reject if charge failed
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteMail(ret.inviteId);
await this.workspaceService.sendInviteEmail(ret.inviteId);
ret.sentSuccess = true;
} catch (e) {
this.logger.warn(
@@ -344,7 +344,7 @@ export class TeamWorkspaceResolver {
inviteId,
}: EventPayload<'workspace.members.reviewRequested'>) {
// send review request mail to owner and admin
await this.workspaceService.sendReviewRequestedMail(inviteId);
await this.workspaceService.sendReviewRequestedEmail(inviteId);
}
@OnEvent('workspace.members.requestDeclined')
@@ -388,7 +388,7 @@ export class TeamWorkspaceResolver {
workspaceId,
}: EventPayload<'workspace.members.ownerTransferred'>) {
// send role changed mail
await this.workspaceService.sendOwnerTransferred(email, {
await this.workspaceService.sendOwnerTransferredEmail(email, {
id: workspaceId,
});
}

View File

@@ -433,7 +433,7 @@ export class WorkspaceResolver {
);
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteMail(inviteId);
await this.workspaceService.sendInviteEmail(inviteId);
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,

View File

@@ -17,6 +17,77 @@ export class SubscriptionCronJobs {
private readonly event: EventEmitter
) {}
private getDateRange(after: number, base: number | Date = Date.now()) {
const start = new Date(base);
start.setDate(start.getDate() + after);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setHours(23, 59, 59, 999);
return { start, end };
}
// TODO(@darkskygit): enable this after the cluster event system is ready
// @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */)
async notifyExpiredWorkspace() {
const { start: after30DayStart, end: after30DayEnd } =
this.getDateRange(30);
const { start: todayStart, end: todayEnd } = this.getDateRange(0);
const { start: before150DaysStart, end: before150DaysEnd } =
this.getDateRange(-150);
const { start: before180DaysStart, end: before180DaysEnd } =
this.getDateRange(-180);
const subscriptions = await this.db.subscription.findMany({
where: {
plan: SubscriptionPlan.Team,
OR: [
{
// subscription will cancel after 30 days
status: 'active',
canceledAt: { not: null },
end: { gte: after30DayStart, lte: after30DayEnd },
},
{
// subscription will cancel today
status: 'active',
canceledAt: { not: null },
end: { gte: todayStart, lte: todayEnd },
},
{
// subscription has been canceled for 150 days
// workspace becomes delete after 180 days
status: 'canceled',
canceledAt: { gte: before150DaysStart, lte: before150DaysEnd },
},
{
// subscription has been canceled for 180 days
// workspace becomes delete after 180 days
status: 'canceled',
canceledAt: { gte: before180DaysStart, lte: before180DaysEnd },
},
],
},
});
for (const subscription of subscriptions) {
const end = subscription.end;
if (!end) {
// should not reach here
continue;
}
this.event.emit('workspace.subscription.notify', {
workspaceId: subscription.targetId,
expirationDate: end,
deletionDate:
subscription.status === 'canceled'
? this.getDateRange(180, end).end
: undefined,
});
}
}
@Cron(CronExpression.EVERY_HOUR)
async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.subscription.findMany({

View File

@@ -5,6 +5,7 @@ import { FeatureModule } from '../../core/features';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
import { WorkspaceModule } from '../../core/workspaces';
import { Plugin } from '../registry';
import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron';
@@ -24,7 +25,13 @@ import { StripeWebhook } from './webhook';
@Plugin({
name: 'payment',
imports: [FeatureModule, QuotaModule, UserModule, PermissionModule],
imports: [
FeatureModule,
QuotaModule,
UserModule,
PermissionModule,
WorkspaceModule,
],
providers: [
StripeProvider,
SubscriptionService,

View File

@@ -1,15 +1,22 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import type { EventPayload } from '../../base';
import { type EventPayload } from '../../base';
import { PermissionService } from '../../core/permission';
import { QuotaManagementService, QuotaType } from '../../core/quota';
import {
QuotaManagementService,
QuotaService,
QuotaType,
} from '../../core/quota';
import { WorkspaceService } from '../../core/workspaces/resolvers';
@Injectable()
export class TeamQuotaOverride {
constructor(
private readonly quota: QuotaService,
private readonly manager: QuotaManagementService,
private readonly permission: PermissionService
private readonly permission: PermissionService,
private readonly workspace: WorkspaceService
) {}
@OnEvent('workspace.subscription.activated')
@@ -20,7 +27,11 @@ export class TeamQuotaOverride {
quantity,
}: EventPayload<'workspace.subscription.activated'>) {
switch (plan) {
case 'team':
case 'team': {
const hasTeamWorkspace = await this.quota.hasWorkspaceQuota(
workspaceId,
QuotaType.TeamPlanV1
);
await this.manager.addTeamWorkspace(
workspaceId,
`${recurring} team subscription activated`
@@ -31,7 +42,13 @@ export class TeamQuotaOverride {
{ memberLimit: quantity }
);
await this.permission.refreshSeatStatus(workspaceId, quantity);
if (!hasTeamWorkspace) {
// this event will triggered when subscription is activated or changed
// we only send emails when the team workspace is activated
await this.workspace.sendTeamWorkspaceUpgradedEmail(workspaceId);
}
break;
}
default:
break;
}

View File

@@ -78,6 +78,11 @@ declare module '../../base/event/def' {
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>;
notify: Payload<{
workspaceId: Workspace['id'];
expirationDate: Date;
deletionDate: Date | undefined;
}>;
};
}
}