Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): role changed email #9227

Merged
merged 1 commit into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
darkskygit marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading