Skip to content

Commit

Permalink
feat(server): role changed email (#9227)
Browse files Browse the repository at this point in the history
  • Loading branch information
darkskygit committed Dec 23, 2024
1 parent 067469a commit aacdb71
Show file tree
Hide file tree
Showing 21 changed files with 238 additions and 155 deletions.
6 changes: 1 addition & 5 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';`,
'',
Expand Down Expand Up @@ -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' },
Expand Down
9 changes: 1 addition & 8 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/* eslint-disable */
/* oxlint-disable */
// AUTO GENERATED FILE
import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/server/src/base/event/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']>;
Expand Down
25 changes: 24 additions & 1 deletion packages/backend/server/src/base/mailer/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 });
}
}
35 changes: 35 additions & 0 deletions packages/backend/server/src/base/mailer/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
4 changes: 0 additions & 4 deletions packages/backend/server/src/core/permission/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}),
Expand Down
65 changes: 45 additions & 20 deletions packages/backend/server/src/core/workspaces/resolvers/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
}
}
41 changes: 40 additions & 1 deletion packages/backend/server/src/core/workspaces/resolvers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
RequestMutex,
TooManyRequest,
URLHelper,
UserFriendlyError,
} from '../../../base';
import { CurrentUser } from '../../auth';
import { Permission, PermissionService } from '../../permission';
Expand Down Expand Up @@ -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;
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import type { FileUpload } from '../../../base';
import {
AlreadyInSpace,
Cache,
CantChangeSpaceOwner,
DocNotFound,
EventEmitter,
InternalServerError,
Expand Down Expand Up @@ -383,19 +382,20 @@ 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,
user.id,
Permission.Admin
);

if (permission === Permission.Owner) {
throw new CantChangeSpaceOwner();
}

try {
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`;
Expand Down Expand Up @@ -428,7 +428,7 @@ export class WorkspaceResolver {
const inviteId = await this.permissions.grant(
workspaceId,
target.id,
permission
Permission.Write
);
if (sendInviteMail) {
try {
Expand Down
3 changes: 1 addition & 2 deletions packages/backend/server/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down
Loading

0 comments on commit aacdb71

Please sign in to comment.