mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +00:00
@@ -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'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +460,10 @@ export class PermissionService {
|
||||
workspaceId,
|
||||
count,
|
||||
});
|
||||
this.event.emit('workspace.members.removed', {
|
||||
workspaceId,
|
||||
userId: user,
|
||||
});
|
||||
|
||||
if (
|
||||
permission.status === 'UnderReview' ||
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceService,
|
||||
],
|
||||
exports: [WorkspaceService],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ declare module '../../base/event/def' {
|
||||
plan: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
}>;
|
||||
notify: Payload<{
|
||||
workspaceId: Workspace['id'];
|
||||
expirationDate: Date;
|
||||
deletionDate: Date | undefined;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user