mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat(server): role changed email (#9227)
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']>;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user