feat(server): role changed email (#9227)

This commit is contained in:
darkskygit
2024-12-23 10:13:04 +00:00
parent 067469aa98
commit aacdb71ee2
21 changed files with 238 additions and 155 deletions

View File

@@ -166,7 +166,7 @@ function generateErrorArgs(name: string, args: ErrorArgs) {
export function generateUserFriendlyErrors() {
const output = [
'/* eslint-disable */',
'/* oxlint-disable */',
'// AUTO GENERATED FILE',
`import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';`,
'',
@@ -374,10 +374,6 @@ export const USER_FRIENDLY_ERRORS = {
args: { spaceId: 'string' },
message: ({ spaceId }) => `Owner of Space ${spaceId} not found.`,
},
cant_change_space_owner: {
type: 'action_forbidden',
message: 'You are not allowed to change the owner of a Space.',
},
doc_not_found: {
type: 'resource_not_found',
args: { spaceId: 'string', docId: 'string' },

View File

@@ -1,4 +1,4 @@
/* eslint-disable */
/* oxlint-disable */
// AUTO GENERATED FILE
import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';
@@ -240,12 +240,6 @@ export class SpaceOwnerNotFound extends UserFriendlyError {
super('internal_server_error', 'space_owner_not_found', message, args);
}
}
export class CantChangeSpaceOwner extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'cant_change_space_owner', message);
}
}
@ObjectType()
class DocNotFoundDataType {
@Field() spaceId!: string
@@ -630,7 +624,6 @@ export enum ErrorNames {
ALREADY_IN_SPACE,
SPACE_ACCESS_DENIED,
SPACE_OWNER_NOT_FOUND,
CANT_CHANGE_SPACE_OWNER,
DOC_NOT_FOUND,
DOC_ACCESS_DENIED,
VERSION_REJECTED,

View File

@@ -10,6 +10,12 @@ export interface WorkspaceEvents {
workspaceId: Workspace['id'];
}>;
requestApproved: Payload<{ inviteId: string }>;
roleChanged: Payload<{
userId: User['id'];
workspaceId: Workspace['id'];
permission: number;
}>;
ownerTransferred: Payload<{ email: string; workspaceId: Workspace['id'] }>;
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
};
deleted: Payload<Workspace['id']>;

View File

@@ -6,7 +6,12 @@ import { URLHelper } from '../helpers';
import { metrics } from '../metrics';
import type { MailerService, Options } from './mailer';
import { MAILER_SERVICE } from './mailer';
import { emailTemplate } from './template';
import {
emailTemplate,
getRoleChangedTemplate,
type RoleChangedMailParams,
} from './template';
@Injectable()
export class MailService {
constructor(
@@ -311,4 +316,22 @@ export class MailService {
});
return this.sendMail({ to, subject: title, html });
}
async sendRoleChangedEmail(to: string, ws: RoleChangedMailParams) {
const { subject, title, content } = getRoleChangedTemplate(ws);
const html = emailTemplate({ title, content });
console.log({ subject, title, content, to });
return this.sendMail({ to, subject, html });
}
async sendOwnerTransferred(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `Your ownership of ${workspaceName} has been transferred`;
const html = emailTemplate({
title: 'Ownership transferred',
content: `You have transferred ownership of ${workspaceName}. You are now a admin in this workspace.`,
});
return this.sendMail({ to, subject: title, html });
}
}

View File

@@ -219,3 +219,38 @@ export const emailTemplate = ({
</table>
</body>`;
};
type RoleChangedMail = {
subject: string;
title: string;
content: string;
};
export type RoleChangedMailParams = {
name: string;
role: 'owner' | 'admin' | 'member' | 'readonly';
};
export const getRoleChangedTemplate = (
ws: RoleChangedMailParams
): RoleChangedMail => {
const { name, role } = ws;
let subject = `You are now an ${role} of ${name}`;
let title = 'Role update in workspace';
let content = `Your role in ${name} has been changed to ${role}. You can continue to collaborate in this workspace.`;
switch (role) {
case 'owner':
title = 'Welcome, new workspace owner!';
content = `You have been assigned as the owner of ${name}. As a workspace owner, you have full control over this team workspace.`;
break;
case 'admin':
title = `You've been promoted to admin.`;
content = `You have been promoted to admin of ${name}. As an admin, you can help the workspace owner manage members in this workspace.`;
break;
default:
subject = `Your role has been changed in ${name}`;
break;
}
return { subject, title, content };
};

View File

@@ -322,10 +322,6 @@ export class PermissionService {
this.prisma.workspaceUserPermission.update({
where: {
workspaceId_userId: { workspaceId: ws, userId: user },
// only update permission:
// 1. if the new permission is owner and original permission is admin
// 2. if the original permission is not owner
type: toBeOwner ? Permission.Admin : { not: Permission.Owner },
},
data: { type: permission },
}),

View File

@@ -2,9 +2,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { Cache, MailService } from '../../../base';
import { Cache, MailService, UserNotFound } from '../../../base';
import { DocContentService } from '../../doc-renderer';
import { PermissionService } from '../../permission';
import { Permission, PermissionService } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage';
import { UserService } from '../../user';
@@ -17,6 +17,13 @@ export type InviteInfo = {
inviteeUserId?: string;
};
const PermissionToRole = {
[Permission.Read]: 'readonly' as const,
[Permission.Write]: 'member' as const,
[Permission.Admin]: 'admin' as const,
[Permission.Owner]: 'owner' as const,
};
@Injectable()
export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
@@ -78,6 +85,27 @@ export class WorkspaceService {
};
}
private async getInviteeEmailTarget(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.user.findUserById(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
);
return;
}
return {
email: invitee.email,
workspace,
};
}
async sendAcceptedEmail(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId);
@@ -167,24 +195,21 @@ export class WorkspaceService {
await this.mailer.sendReviewDeclinedEmail(email, { name: workspaceName });
}
private async getInviteeEmailTarget(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.user.findUserById(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
);
return;
}
async sendRoleChangedEmail(
userId: string,
ws: { id: string; role: Permission }
) {
const user = await this.user.findUserById(userId);
if (!user) throw new UserNotFound();
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendRoleChangedEmail(user?.email, {
name: workspace.name,
role: PermissionToRole[ws.role],
});
}
return {
email: invitee.email,
workspace,
};
async sendOwnerTransferred(email: string, ws: { id: string }) {
const workspace = await this.getWorkspaceInfo(ws.id);
await this.mailer.sendOwnerTransferred(email, { name: workspace.name });
}
}

View File

@@ -18,6 +18,7 @@ import {
RequestMutex,
TooManyRequest,
URLHelper,
UserFriendlyError,
} from '../../../base';
import { CurrentUser } from '../../auth';
import { Permission, PermissionService } from '../../permission';
@@ -311,7 +312,17 @@ export class TeamWorkspaceResolver {
);
if (result) {
// TODO(@darkskygit): send team role changed mail
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
permission,
});
if (permission === Permission.Owner) {
this.event.emit('workspace.members.ownerTransferred', {
email: user.email,
workspaceId,
});
}
}
return result;
@@ -320,6 +331,10 @@ export class TeamWorkspaceResolver {
}
} catch (e) {
this.logger.error('failed to invite user', e);
// pass through user friendly error
if (e instanceof UserFriendlyError) {
return e;
}
return new TooManyRequest();
}
}
@@ -353,4 +368,28 @@ export class TeamWorkspaceResolver {
// send approve mail
await this.workspaceService.sendReviewApproveEmail(inviteId);
}
@OnEvent('workspace.members.roleChanged')
async onRoleChanged({
userId,
workspaceId,
permission,
}: EventPayload<'workspace.members.roleChanged'>) {
// send role changed mail
await this.workspaceService.sendRoleChangedEmail(userId, {
id: workspaceId,
role: permission,
});
}
@OnEvent('workspace.members.ownerTransferred')
async onOwnerTransferred({
email,
workspaceId,
}: EventPayload<'workspace.members.ownerTransferred'>) {
// send role changed mail
await this.workspaceService.sendOwnerTransferred(email, {
id: workspaceId,
});
}
}

View File

@@ -17,7 +17,6 @@ import type { FileUpload } from '../../../base';
import {
AlreadyInSpace,
Cache,
CantChangeSpaceOwner,
DocNotFound,
EventEmitter,
InternalServerError,
@@ -383,8 +382,13 @@ export class WorkspaceResolver {
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('permission', { type: () => Permission }) permission: Permission,
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean
@Args('sendInviteMail', { nullable: true }) sendInviteMail: boolean,
@Args('permission', {
type: () => Permission,
nullable: true,
deprecationReason: 'never used',
})
_permission?: Permission
) {
await this.permissions.checkWorkspace(
workspaceId,
@@ -392,10 +396,6 @@ export class WorkspaceResolver {
Permission.Admin
);
if (permission === Permission.Owner) {
throw new CantChangeSpaceOwner();
}
try {
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`;
@@ -428,7 +428,7 @@ export class WorkspaceResolver {
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
Permission.Write
);
if (sendInviteMail) {
try {

View File

@@ -220,7 +220,6 @@ enum ErrorNames {
BLOB_QUOTA_EXCEEDED
CANNOT_DELETE_ALL_ADMIN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT
CANT_CHANGE_SPACE_OWNER
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION
CAPTCHA_VERIFICATION_FAILED
COPILOT_ACTION_TAKEN
@@ -526,7 +525,7 @@ type Mutation {
"""Create a chat session"""
forkCopilotSession(options: ForkChatSessionInput!): String!
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
invite(email: String!, permission: Permission @deprecated(reason: "never used"), sendInviteMail: Boolean, workspaceId: String!): String!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!

View File

@@ -147,13 +147,7 @@ test('should create session correctly', async t => {
);
});
const inviteId = await inviteUser(
app,
token,
id,
'darksky@affine.pro',
'Admin'
);
const inviteId = await inviteUser(app, token, id, 'darksky@affine.pro');
await acceptInviteById(app, id, inviteId, false);
await assertCreateSession(
id,
@@ -240,13 +234,7 @@ test('should fork session correctly', async t => {
}
);
const inviteId = await inviteUser(
app,
token,
id,
'test@affine.pro',
'Admin'
);
const inviteId = await inviteUser(app, token, id, 'test@affine.pro');
await acceptInviteById(app, id, inviteId, false);
await assertForkSession(
newToken,
@@ -609,8 +597,7 @@ test('should reject request that user have not permission', async t => {
app,
anotherToken,
workspaceId,
'darksky@affine.pro',
'Admin'
'darksky@affine.pro'
);
await acceptInviteById(app, workspaceId, inviteId, false);

View File

@@ -44,14 +44,7 @@ test('should send invite email', async t => {
const stub = Sinon.stub(mail, 'sendMail');
await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin',
true
);
await inviteUser(app, u1.token.token, workspace.id, u2.email, true);
t.true(stub.calledOnce);

View File

@@ -120,7 +120,6 @@ const init = async (
owner.token.token,
workspace.id,
member.email,
permission,
shouldSendEmail
);
await acceptInviteById(app, workspace.id, inviteId, shouldSendEmail);
@@ -133,10 +132,16 @@ const init = async (
owner.token.token,
teamWorkspace.id,
member.email,
permission,
shouldSendEmail
);
await acceptInviteById(app, teamWorkspace.id, inviteId, shouldSendEmail);
await grantMember(
app,
owner.token.token,
teamWorkspace.id,
member.id,
permission
);
}
return member;
@@ -437,8 +442,11 @@ test('should be able to manage invite link', async t => {
read,
} = await init(app, 4);
for (const workspace of [ws, tws]) {
for (const manager of [owner, admin]) {
for (const [workspace, managers] of [
[ws, [owner]],
[tws, [owner, admin]],
] as const) {
for (const manager of managers) {
const { link } = await createInviteLink(
app,
manager.token.token,
@@ -646,16 +654,21 @@ test('should be able to emit events', async t => {
const { teamWorkspace: tws, inviteBatch } = await init(app, 4);
await inviteBatch(['m1@affine.pro', 'm2@affine.pro']);
t.true(
event.emit.calledOnceWith('workspace.members.updated', {
const [membersUpdated] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(membersUpdated, [
'workspace.members.updated',
{
workspaceId: tws.id,
count: 6,
})
);
},
]);
}
{
const { teamWorkspace: tws, owner, createInviteLink } = await init(app, 10);
const { teamWorkspace: tws, owner, createInviteLink } = await init(app);
const [, invite] = await createInviteLink(tws);
const user = await invite('m3@affine.pro');
const { members } = await getWorkspace(app, owner.token.token, tws.id);
@@ -679,4 +692,39 @@ test('should be able to emit events', async t => {
'should emit review requested event'
);
}
{
const { teamWorkspace: tws, owner, read } = await init(app);
await grantMember(app, owner.token.token, tws.id, read.id, 'Admin');
t.deepEqual(
event.emit.lastCall.args,
[
'workspace.members.roleChanged',
{ userId: read.id, workspaceId: tws.id, permission: Permission.Admin },
],
'should emit role changed event'
);
await grantMember(app, owner.token.token, tws.id, read.id, 'Owner');
const [ownerTransferred, roleChanged] = event.emit
.getCalls()
.map(call => call.args)
.toReversed();
t.deepEqual(
roleChanged,
[
'workspace.members.roleChanged',
{ userId: read.id, workspaceId: tws.id, permission: Permission.Owner },
],
'should emit role changed event'
);
t.deepEqual(
ownerTransferred,
[
'workspace.members.ownerTransferred',
{ email: owner.email, workspaceId: tws.id },
],
'should emit owner transferred event'
);
}
});

View File

@@ -3,14 +3,12 @@ import request from 'supertest';
import type { InvitationType } from '../../src/core/workspaces';
import { gql } from './common';
import { PermissionEnum } from './utils';
export async function inviteUser(
app: INestApplication,
token: string,
workspaceId: string,
email: string,
permission: PermissionEnum,
sendInviteMail = false
): Promise<string> {
const res = await request(app.getHttpServer())
@@ -20,7 +18,7 @@ export async function inviteUser(
.send({
query: `
mutation {
invite(workspaceId: "${workspaceId}", email: "${email}", permission: ${permission}, sendInviteMail: ${sendInviteMail})
invite(workspaceId: "${workspaceId}", email: "${email}", sendInviteMail: ${sendInviteMail})
}
`,
})

View File

@@ -52,13 +52,7 @@ test('should invite a user', async t => {
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const invite = await inviteUser(app, u1.token.token, workspace.id, u2.email);
t.truthy(invite, 'failed to invite user');
});
@@ -68,13 +62,7 @@ test('should leave a workspace', async t => {
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const id = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const id = await inviteUser(app, u1.token.token, workspace.id, u2.email);
await acceptInviteById(app, workspace.id, id, false);
const leave = await leaveWorkspace(app, u2.token.token, workspace.id);
@@ -89,7 +77,7 @@ test('should revoke a user', async t => {
const u2 = await signUp(app, 'u2', 'u2@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin');
await inviteUser(app, u1.token.token, workspace.id, u2.email);
const currWorkspace = await getWorkspace(app, u1.token.token, workspace.id);
t.is(currWorkspace.members.length, 2, 'failed to invite user');
@@ -104,7 +92,7 @@ test('should create user if not exist', async t => {
const workspace = await createWorkspace(app, u1.token.token);
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro', 'Admin');
await inviteUser(app, u1.token.token, workspace.id, 'u2@affine.pro');
const u2 = await user.findUserByEmail('u2@affine.pro');
t.not(u2, undefined, 'failed to create user');
@@ -118,24 +106,12 @@ test('should invite a user by link', async t => {
const workspace = await createWorkspace(app, u1.token.token);
const invite = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const invite = await inviteUser(app, u1.token.token, workspace.id, u2.email);
const accept = await acceptInviteById(app, workspace.id, invite);
t.true(accept, 'failed to accept invite');
const invite1 = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const invite1 = await inviteUser(app, u1.token.token, workspace.id, u2.email);
t.is(invite, invite1, 'repeat the invitation must return same id');
@@ -159,7 +135,6 @@ test('should send email', async t => {
u1.token.token,
workspace.id,
u2.email,
'Admin',
true
);
@@ -224,20 +199,8 @@ test('should support pagination for member', async t => {
const u3 = await signUp(app, 'u3', 'u3@affine.pro', '1');
const workspace = await createWorkspace(app, u1.token.token);
const invite1 = await inviteUser(
app,
u1.token.token,
workspace.id,
u2.email,
'Admin'
);
const invite2 = await inviteUser(
app,
u1.token.token,
workspace.id,
u3.email,
'Admin'
);
const invite1 = await inviteUser(app, u1.token.token, workspace.id, u2.email);
const invite2 = await inviteUser(app, u1.token.token, workspace.id, u3.email);
await acceptInviteById(app, workspace.id, invite1, false);
await acceptInviteById(app, workspace.id, invite2, false);
@@ -267,13 +230,7 @@ test('should limit member count correctly', async t => {
const workspace = await createWorkspace(app, u1.token.token);
await Promise.allSettled(
Array.from({ length: 10 }).map(async (_, i) =>
inviteUser(
app,
u1.token.token,
workspace.id,
`u${i}@affine.pro`,
'Admin'
)
inviteUser(app, u1.token.token, workspace.id, `u${i}@affine.pro`)
)
);

View File

@@ -134,7 +134,7 @@ test('should share a page', async t => {
await acceptInviteById(
app,
workspace.id,
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin')
await inviteUser(app, u1.token.token, workspace.id, u2.email)
);
const invited = await publishPage(app, u2.token.token, workspace.id, 'page2');
t.is(invited.id, 'page2', 'failed to share page');
@@ -211,7 +211,7 @@ test('should can get workspace doc', async t => {
await acceptInviteById(
app,
workspace.id,
await inviteUser(app, u1.token.token, workspace.id, u2.email, 'Admin')
await inviteUser(app, u1.token.token, workspace.id, u2.email)
);
const res2 = await request(app.getHttpServer())