From 1116a1d74e3ccfa2ef2c69cf7257220a8bec82c6 Mon Sep 17 00:00:00 2001 From: darkskygit Date: Tue, 21 Jan 2025 05:18:02 +0000 Subject: [PATCH] feat(server): supplement team email remind (#9483) fix PD-2047 AF-1996 --- .../backend/server/src/__tests__/team.e2e.ts | 29 +++++ .../server/src/__tests__/utils/workspace.ts | 24 ++++ packages/backend/server/src/base/event/def.ts | 1 + .../server/src/base/mailer/mail.service.ts | 115 +++++++++++++++++- .../server/src/core/permission/service.ts | 4 + .../server/src/core/workspaces/index.ts | 1 + .../src/core/workspaces/resolvers/service.ts | 70 ++++++++++- .../src/core/workspaces/resolvers/team.ts | 6 +- .../core/workspaces/resolvers/workspace.ts | 2 +- .../server/src/plugins/payment/cron.ts | 71 +++++++++++ .../server/src/plugins/payment/index.ts | 9 +- .../server/src/plugins/payment/quota.ts | 25 +++- .../server/src/plugins/payment/types.ts | 5 + 13 files changed, 345 insertions(+), 17 deletions(-) diff --git a/packages/backend/server/src/__tests__/team.e2e.ts b/packages/backend/server/src/__tests__/team.e2e.ts index c2e425c1ce..15444977e4 100644 --- a/packages/backend/server/src/__tests__/team.e2e.ts +++ b/packages/backend/server/src/__tests__/team.e2e.ts @@ -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' + ); } }); diff --git a/packages/backend/server/src/__tests__/utils/workspace.ts b/packages/backend/server/src/__tests__/utils/workspace.ts index e63b8fb685..0e5ae9acb8 100644 --- a/packages/backend/server/src/__tests__/utils/workspace.ts +++ b/packages/backend/server/src/__tests__/utils/workspace.ts @@ -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; +} diff --git a/packages/backend/server/src/base/event/def.ts b/packages/backend/server/src/base/event/def.ts index 870643404a..63407dfceb 100644 --- a/packages/backend/server/src/base/event/def.ts +++ b/packages/backend/server/src/base/event/def.ts @@ -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; blob: { diff --git a/packages/backend/server/src/base/mailer/mail.service.ts b/packages/backend/server/src/base/mailer/mail.service.ts index f078851fe6..4022a5647f 100644 --- a/packages/backend/server/src/base/mailer/mail.service.ts +++ b/packages/backend/server/src/base/mailer/mail.service.ts @@ -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: +
  • Renew your subscription to restore team features
  • +
  • Export your workspace data from Workspace Settings > Export Workspace
  • `; + } 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: +
  • Renew your subscription to restore team features
  • +
  • Export your workspace data from Workspace Settings > Export Workspace
  • `; + } + } 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 }); + } } diff --git a/packages/backend/server/src/core/permission/service.ts b/packages/backend/server/src/core/permission/service.ts index 717be512eb..8e15ec6fe3 100644 --- a/packages/backend/server/src/core/permission/service.ts +++ b/packages/backend/server/src/core/permission/service.ts @@ -460,6 +460,10 @@ export class PermissionService { workspaceId, count, }); + this.event.emit('workspace.members.removed', { + workspaceId, + userId: user, + }); if ( permission.status === 'UnderReview' || diff --git a/packages/backend/server/src/core/workspaces/index.ts b/packages/backend/server/src/core/workspaces/index.ts index b0ff055819..2aaa12a974 100644 --- a/packages/backend/server/src/core/workspaces/index.ts +++ b/packages/backend/server/src/core/workspaces/index.ts @@ -38,6 +38,7 @@ import { WorkspaceBlobResolver, WorkspaceService, ], + exports: [WorkspaceService], }) export class WorkspaceModule {} diff --git a/packages/backend/server/src/core/workspaces/resolvers/service.ts b/packages/backend/server/src/core/workspaces/resolvers/service.ts index a852c7767d..9f7654293a 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/service.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/service.ts @@ -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, + }); } } diff --git a/packages/backend/server/src/core/workspaces/resolvers/team.ts b/packages/backend/server/src/core/workspaces/resolvers/team.ts index 5bfcc04242..350e716b75 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/team.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/team.ts @@ -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, }); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index 73ecf9170b..3e483c0368 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -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, diff --git a/packages/backend/server/src/plugins/payment/cron.ts b/packages/backend/server/src/plugins/payment/cron.ts index 6c8028cba0..22118c76de 100644 --- a/packages/backend/server/src/plugins/payment/cron.ts +++ b/packages/backend/server/src/plugins/payment/cron.ts @@ -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({ diff --git a/packages/backend/server/src/plugins/payment/index.ts b/packages/backend/server/src/plugins/payment/index.ts index 1538843a2f..8d01b152ae 100644 --- a/packages/backend/server/src/plugins/payment/index.ts +++ b/packages/backend/server/src/plugins/payment/index.ts @@ -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, diff --git a/packages/backend/server/src/plugins/payment/quota.ts b/packages/backend/server/src/plugins/payment/quota.ts index 00ae9ce53a..7b0dbe3487 100644 --- a/packages/backend/server/src/plugins/payment/quota.ts +++ b/packages/backend/server/src/plugins/payment/quota.ts @@ -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; } diff --git a/packages/backend/server/src/plugins/payment/types.ts b/packages/backend/server/src/plugins/payment/types.ts index 337b08896b..6ea4736462 100644 --- a/packages/backend/server/src/plugins/payment/types.ts +++ b/packages/backend/server/src/plugins/payment/types.ts @@ -78,6 +78,11 @@ declare module '../../base/event/def' { plan: SubscriptionPlan; recurring: SubscriptionRecurring; }>; + notify: Payload<{ + workspaceId: Workspace['id']; + expirationDate: Date; + deletionDate: Date | undefined; + }>; }; } }