feat(server): team mail sender (#9104)

fix AF-1914
This commit is contained in:
darkskygit
2024-12-12 07:33:31 +00:00
parent 350696c861
commit 69e5997608
19 changed files with 416 additions and 208 deletions

View File

@@ -14,6 +14,11 @@ import { FeatureKind } from '../features/types';
import { QuotaType } from '../quota/types';
import { Permission, PublicPageMode } from './types';
const NeedUpdateStatus = new Set<WorkspaceMemberStatus>([
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
]);
@Injectable()
export class PermissionService {
constructor(
@@ -94,6 +99,20 @@ export class PermissionService {
return owner.user;
}
async getWorkspaceAdmin(workspaceId: string) {
const admin = await this.prisma.workspaceUserPermission.findMany({
where: {
workspaceId,
type: Permission.Admin,
},
include: {
user: true,
},
});
return admin.map(({ user }) => user);
}
async getWorkspaceMemberCount(workspaceId: string) {
return this.prisma.workspaceUserPermission.count({
where: {
@@ -351,18 +370,6 @@ export class PermissionService {
.then(p => p.id);
}
async getWorkspaceInvitation(invitationId: string, workspaceId: string) {
return this.prisma.workspaceUserPermission.findUniqueOrThrow({
where: {
id: invitationId,
workspaceId,
},
include: {
user: true,
},
});
}
private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) {
return await tx.workspaceFeature
.count({
@@ -396,24 +403,14 @@ export class PermissionService {
}
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
return this.prisma.$transaction(async tx => {
const [pending, underReview] = await this.prisma.$transaction(async tx => {
const members = await tx.workspaceUserPermission.findMany({
where: {
workspaceId,
},
select: {
userId: true,
status: true,
updatedAt: true,
},
where: { workspaceId },
select: { userId: true, status: true, updatedAt: true },
});
const memberCount = members.filter(
m => m.status === WorkspaceMemberStatus.Accepted
).length;
const NeedUpdateStatus = new Set<WorkspaceMemberStatus>([
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
]);
const needChange = members
.filter(m => NeedUpdateStatus.has(m.status))
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
@@ -422,32 +419,41 @@ export class PermissionService {
needChange,
m => m.status
);
const approvedCount = await tx.workspaceUserPermission
.updateMany({
const inviteByMail = NeedMoreSeat?.map(m => m.userId) ?? [];
await tx.workspaceUserPermission.updateMany({
where: { workspaceId, userId: { in: inviteByMail } },
data: { status: WorkspaceMemberStatus.Pending },
});
const inviteByLink = NeedMoreSeatAndReview?.map(m => m.userId) ?? [];
await tx.workspaceUserPermission.updateMany({
where: { workspaceId, userId: { in: inviteByLink } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
const pending = await tx.workspaceUserPermission
.findMany({
where: {
userId: {
in: NeedMoreSeat?.map(m => m.userId) ?? [],
},
},
data: {
status: WorkspaceMemberStatus.Accepted,
workspaceId,
userId: { in: inviteByLink },
status: WorkspaceMemberStatus.Pending,
},
select: { id: true, user: { select: { email: true } } },
})
.then(r => r.count);
const needReviewCount = await tx.workspaceUserPermission
.updateMany({
.then(r => r.map(m => ({ inviteId: m.id, email: m.user.email })));
const underReview = await tx.workspaceUserPermission
.findMany({
where: {
userId: {
in: NeedMoreSeatAndReview?.map(m => m.userId) ?? [],
},
},
data: {
workspaceId,
userId: { in: inviteByLink },
status: WorkspaceMemberStatus.UnderReview,
},
select: { id: true },
})
.then(r => r.count);
return approvedCount + needReviewCount === needChange.length;
.then(r => ({ inviteIds: r.map(m => m.id) }));
return [pending, underReview] as const;
});
this.event.emit('workspace.team.seatAvailable', pending);
this.event.emit('workspace.team.reviewRequest', underReview);
}
async revokeWorkspace(workspaceId: string, user: string) {
@@ -474,6 +480,10 @@ export class PermissionService {
workspaceId,
count,
});
this.event.emit('workspace.team.declineRequest', {
workspaceId,
inviteeId: user,
});
}
}
return success;

View File

@@ -15,6 +15,7 @@ import {
TeamWorkspaceResolver,
WorkspaceBlobResolver,
WorkspaceResolver,
WorkspaceService,
} from './resolvers';
@Module({
@@ -35,6 +36,7 @@ import {
PagePermissionResolver,
DocHistoryResolver,
WorkspaceBlobResolver,
WorkspaceService,
],
})
export class WorkspaceModule {}

View File

@@ -1,5 +1,6 @@
export * from './blob';
export * from './history';
export * from './page';
export * from './service';
export * from './team';
export * from './workspace';

View File

@@ -0,0 +1,177 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { Cache, MailService } from '../../../fundamentals';
import { DocContentService } from '../../doc-renderer';
import { PermissionService } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage';
import { UserService } from '../../user';
export const defaultWorkspaceAvatar =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
export type InviteInfo = {
workspaceId: string;
inviterUserId?: string;
inviteeUserId?: string;
};
@Injectable()
export class WorkspaceService {
private readonly logger = new Logger(WorkspaceService.name);
constructor(
private readonly blobStorage: WorkspaceBlobStorage,
private readonly cache: Cache,
private readonly doc: DocContentService,
private readonly mailer: MailService,
private readonly permission: PermissionService,
private readonly prisma: PrismaClient,
private readonly user: UserService
) {}
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
// invite link
const invite = await this.cache.get<InviteInfo>(
`workspace:inviteLinkId:${inviteId}`
);
if (typeof invite?.workspaceId === 'string') {
return invite;
}
return await this.prisma.workspaceUserPermission
.findUniqueOrThrow({
where: {
id: inviteId,
},
select: {
workspaceId: true,
userId: true,
},
})
.then(r => ({
workspaceId: r.workspaceId,
inviteeUserId: r.userId,
}));
}
async getWorkspaceInfo(workspaceId: string) {
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
let avatar = defaultWorkspaceAvatar;
if (workspaceContent?.avatarKey) {
const avatarBlob = await this.blobStorage.get(
workspaceId,
workspaceContent.avatarKey
);
if (avatarBlob.body) {
avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64');
}
}
return {
avatar,
id: workspaceId,
name: workspaceContent?.name ?? '',
};
}
async sendInviteMail(inviteId: string, email: string) {
const { workspaceId } = await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
await this.mailer.sendInviteEmail(email, inviteId, {
workspace,
user: {
avatar: owner.avatarUrl || '',
name: owner.name || '',
},
});
}
async sendAcceptedEmail(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId);
const workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = inviteeUserId
? await this.user.findUserById(inviteeUserId)
: null;
const inviter = inviterUserId
? await this.user.findUserById(inviterUserId)
: await this.permission.getWorkspaceOwner(workspaceId);
if (!inviter || !invitee) {
this.logger.error(
`Inviter or invitee user not found for inviteId: ${inviteId}`
);
return false;
}
await this.mailer.sendAcceptedEmail(inviter.email, {
inviteeName: invitee.name,
workspaceName: workspace.name,
});
return true;
}
async sendReviewRequestMail(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
return;
}
const invitee = await this.user.findUserById(inviteeUserId);
if (!invitee) {
this.logger.error(
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
);
return;
}
const workspace = await this.getWorkspaceInfo(workspaceId);
const owner = await this.permission.getWorkspaceOwner(workspaceId);
const admin = await this.permission.getWorkspaceAdmin(workspaceId);
for (const user of [owner, ...admin]) {
await this.mailer.sendReviewRequestMail(
user.email,
invitee.email,
workspace
);
}
}
async sendReviewApproveEmail(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 for inviteId: ${inviteId}, userId: ${inviteeUserId}`
);
return;
}
await this.mailer.sendReviewApproveEmail(invitee.email, workspace);
}
async sendReviewDeclinedEmail(workspaceId: string, inviteeUserId: string) {
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;
}
await this.mailer.sendReviewDeclinedEmail(invitee.email, workspace);
}
}

View File

@@ -12,8 +12,9 @@ import { nanoid } from 'nanoid';
import {
Cache,
EventEmitter,
MailService,
type EventPayload,
NotInSpace,
OnEvent,
RequestMutex,
TooManyRequest,
URLHelper,
@@ -28,7 +29,7 @@ import {
WorkspaceInviteLinkExpireTime,
WorkspaceType,
} from '../types';
import { WorkspaceResolver } from './workspace';
import { WorkspaceService } from './service';
/**
* Workspace team resolver
@@ -42,14 +43,13 @@ export class TeamWorkspaceResolver {
constructor(
private readonly cache: Cache,
private readonly event: EventEmitter,
private readonly mailer: MailService,
private readonly url: URLHelper,
private readonly prisma: PrismaClient,
private readonly permissions: PermissionService,
private readonly users: UserService,
private readonly quota: QuotaManagementService,
private readonly mutex: RequestMutex,
private readonly workspace: WorkspaceResolver
private readonly workspaceService: WorkspaceService
) {}
@ResolveField(() => Boolean, {
@@ -119,20 +119,8 @@ export class TeamWorkspaceResolver {
: WorkspaceMemberStatus.Pending
);
if (!needMoreSeat && sendInviteMail) {
const inviteInfo = await this.workspace.getInviteInfo(ret.inviteId);
try {
await this.mailer.sendInviteEmail(email, ret.inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
await this.workspaceService.sendInviteMail(ret.inviteId, email);
ret.sentSuccess = true;
} catch (e) {
this.logger.warn(
@@ -182,7 +170,7 @@ export class TeamWorkspaceResolver {
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink | null> {
): Promise<InviteLink> {
await this.permissions.checkWorkspace(
workspaceId,
user.id,
@@ -205,7 +193,7 @@ export class TeamWorkspaceResolver {
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviteeUserId: user.id },
{ workspaceId, inviterUserId: user.id },
{ ttl: expireTime }
);
return {
@@ -262,7 +250,8 @@ export class TeamWorkspaceResolver {
);
if (result) {
// TODO(@darkskygit): send team approve mail
// send approve mail
await this.workspaceService.sendReviewApproveEmail(result);
}
return result;
}
@@ -321,4 +310,31 @@ export class TeamWorkspaceResolver {
return new TooManyRequest();
}
}
@OnEvent('workspace.team.seatAvailable')
async onSeatAvailable(payload: EventPayload<'workspace.team.seatAvailable'>) {
// send invite mail when seat is available for NeedMoreSeat member
for (const { inviteId, email } of payload) {
await this.workspaceService.sendInviteMail(inviteId, email);
}
}
@OnEvent('workspace.team.reviewRequest')
async onReviewRequest({
inviteIds,
}: EventPayload<'workspace.team.reviewRequest'>) {
// send review request mail to owner and admin
for (const inviteId of inviteIds) {
await this.workspaceService.sendReviewRequestMail(inviteId);
}
}
@OnEvent('workspace.team.declineRequest')
async onDeclineRequest({
workspaceId,
inviteeId,
}: EventPayload<'workspace.team.declineRequest'>) {
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(workspaceId, inviteeId);
}
}

View File

@@ -11,7 +11,6 @@ import {
Resolver,
} from '@nestjs/graphql';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../fundamentals';
@@ -32,10 +31,8 @@ import {
} from '../../../fundamentals';
import { CurrentUser, Public } from '../../auth';
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
import { DocContentService } from '../../doc-renderer';
import { Permission, PermissionService } from '../../permission';
import { QuotaManagementService, QuotaQueryType } from '../../quota';
import { WorkspaceBlobStorage } from '../../storage';
import { UserService, UserType } from '../../user';
import {
InvitationType,
@@ -43,7 +40,7 @@ import {
UpdateWorkspaceInput,
WorkspaceType,
} from '../types';
import { defaultWorkspaceAvatar } from '../utils';
import { WorkspaceService } from './service';
@ObjectType()
export class EditorType implements Partial<Editor> {
@@ -86,9 +83,8 @@ export class WorkspaceResolver {
private readonly quota: QuotaManagementService,
private readonly users: UserService,
private readonly event: EventEmitter,
private readonly blobStorage: WorkspaceBlobStorage,
private readonly mutex: RequestMutex,
private readonly doc: DocContentService,
private readonly workspaceService: WorkspaceService,
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
) {}
@@ -433,20 +429,8 @@ export class WorkspaceResolver {
permission
);
if (sendInviteMail) {
const inviteInfo = await this.getInviteInfo(inviteId);
try {
await this.mailer.sendInviteEmail(email, inviteId, {
workspace: {
id: inviteInfo.workspace.id,
name: inviteInfo.workspace.name,
avatar: inviteInfo.workspace.avatar,
},
user: {
avatar: inviteInfo.user?.avatarUrl || '',
name: inviteInfo.user?.name || '',
},
});
await this.workspaceService.sendInviteMail(inviteId, email);
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
@@ -483,63 +467,20 @@ export class WorkspaceResolver {
@Query(() => InvitationType, {
description: 'send workspace invitation',
})
async getInviteInfo(@Args('inviteId') inviteId: string) {
let workspaceId = null;
let invitee = null;
// invite link
const invite = await this.cache.get<{
workspaceId: string;
inviteeUserId: string;
}>(`workspace:inviteLinkId:${inviteId}`);
if (typeof invite?.workspaceId === 'string') {
workspaceId = invite.workspaceId;
invitee = { user: await this.users.findUserById(invite.inviteeUserId) };
}
if (!workspaceId) {
workspaceId = await this.prisma.workspaceUserPermission
.findUniqueOrThrow({
where: {
id: inviteId,
},
select: {
workspaceId: true,
},
})
.then(({ workspaceId }) => workspaceId);
}
const workspaceContent = await this.doc.getWorkspaceContent(workspaceId);
async getInviteInfo(
@CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string
) {
const { workspaceId, inviteeUserId } =
await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
if (!invitee) {
invitee = await this.permissions.getWorkspaceInvitation(
inviteId,
workspaceId
);
}
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.users.findUserById(inviteeId);
let avatar = '';
if (workspaceContent?.avatarKey) {
const avatarBlob = await this.blobStorage.get(
workspaceId,
workspaceContent.avatarKey
);
if (avatarBlob.body) {
avatar = (await getStreamAsBuffer(avatarBlob.body)).toString('base64');
}
}
return {
workspace: {
name: workspaceContent?.name ?? '',
avatar: avatar || defaultWorkspaceAvatar,
id: workspaceId,
},
user: owner,
invitee: invitee.user,
};
return { workspace, user: owner, invitee };
}
@Mutation(() => Boolean)
@@ -569,13 +510,7 @@ export class WorkspaceResolver {
);
}
const result = await this.permissions.revokeWorkspace(workspaceId, userId);
if (result && isTeam) {
// TODO(@darkskygit): send team revoke mail
}
return result;
return await this.permissions.revokeWorkspace(workspaceId, userId);
}
@Mutation(() => Boolean)
@@ -615,8 +550,11 @@ export class WorkspaceResolver {
return true;
} else {
const inviteId = await this.permissions.grant(workspaceId, user.id);
this.event.emit('workspace.team.reviewRequest', {
inviteIds: [inviteId],
});
// invite by link need admin to approve
return this.permissions.acceptWorkspaceInvitation(
return await this.permissions.acceptWorkspaceInvitation(
inviteId,
workspaceId,
WorkspaceMemberStatus.UnderReview
@@ -629,36 +567,31 @@ export class WorkspaceResolver {
// so we need to check seat again here
await this.quota.checkWorkspaceSeat(workspaceId, true);
const {
invitee,
user: inviter,
workspace,
} = await this.getInviteInfo(inviteId);
if (!inviter || !invitee) {
throw new UserNotFound();
}
if (sendAcceptMail) {
// TODO(@darkskygit): team accept mail
await this.mailer.sendAcceptedEmail(inviter.email, {
inviteeName: invitee.name,
workspaceName: workspace.name,
});
const success = await this.workspaceService.sendAcceptedEmail(inviteId);
if (!success) throw new UserNotFound();
}
return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId);
return await this.permissions.acceptWorkspaceInvitation(
inviteId,
workspaceId
);
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('workspaceName') workspaceName: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail: boolean
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail?: boolean,
@Args('workspaceName', {
nullable: true,
deprecationReason: 'no longer used',
})
_workspaceName?: string
) {
await this.permissions.checkWorkspace(workspaceId, user.id);
const { name: workspaceName } =
await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
if (sendLeaveMail) {

View File

@@ -1,2 +0,0 @@
export const defaultWorkspaceAvatar =
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';

View File

@@ -3,6 +3,14 @@ import type { Snapshot, User, Workspace } from '@prisma/client';
import { Flatten, Payload } from './types';
export interface WorkspaceEvents {
team: {
seatAvailable: Payload<{ inviteId: string; email: string }[]>;
reviewRequest: Payload<{ inviteIds: string[] }>;
declineRequest: Payload<{
workspaceId: Workspace['id'];
inviteeId: User['id'];
}>;
};
deleted: Payload<Workspace['id']>;
blob: {
deleted: Payload<{

View File

@@ -166,6 +166,7 @@ export class MailService {
html,
});
}
async sendChangeEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your current email for AFFiNE',
@@ -180,6 +181,7 @@ export class MailService {
html,
});
}
async sendVerifyChangeEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your new email address',
@@ -194,6 +196,7 @@ export class MailService {
html,
});
}
async sendVerifyEmail(to: string, url: string) {
const html = emailTemplate({
title: 'Verify your email address',
@@ -208,6 +211,7 @@ export class MailService {
html,
});
}
async sendNotificationChangeEmail(to: string) {
const html = emailTemplate({
title: 'Email change successful',
@@ -219,6 +223,7 @@ export class MailService {
html,
});
}
async sendAcceptedEmail(
to: string,
{
@@ -241,6 +246,7 @@ export class MailService {
html,
});
}
async sendLeaveWorkspaceEmail(
to: string,
{
@@ -263,4 +269,46 @@ export class MailService {
html,
});
}
// =================== Team Workspace Mails ===================
async sendReviewRequestMail(
to: string,
invitee: string,
ws: { id: string; name: string }
) {
const { id: workspaceId, name: workspaceName } = ws;
const title = `New request to join ${workspaceName}`;
const html = emailTemplate({
title: 'Request to join your workspace',
content: `${invitee} has requested to join ${workspaceName}. As a workspace owner/admin, you can approve or decline this request.`,
buttonContent: 'Review request',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: title, html });
}
async sendReviewApproveEmail(to: string, ws: { id: string; name: string }) {
const { id: workspaceId, name: workspaceName } = ws;
const title = `Your request to join ${workspaceName} has been approved`;
const html = emailTemplate({
title: 'Welcome to the workspace!',
content: `Your request to join ${workspaceName} has been accepted. You can now access the team workspace and collaborate with other members.`,
buttonContent: 'Open Workspace',
buttonUrl: this.url.link(`/workspace/${workspaceId}`),
});
return this.sendMail({ to, subject: title, html });
}
async sendReviewDeclinedEmail(to: string, ws: { name: string }) {
const { name: workspaceName } = ws;
const title = `Your request to join ${workspaceName} was declined`;
const html = emailTemplate({
title: 'Request declined',
content: `Your request to join ${workspaceName} has been declined by the workspace admin.`,
});
return this.sendMail({ to, subject: title, html });
}
}

View File

@@ -504,7 +504,7 @@ type Mutation {
"""Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String!
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
"""Create a new user"""
createUser(input: CreateUserInput!): UserType!
@@ -523,7 +523,7 @@ type Mutation {
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String!): Boolean!
leaveWorkspace(sendLeaveMail: Boolean, workspaceId: String!, workspaceName: String @deprecated(reason: "no longer used")): Boolean!
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
releaseDeletedBlobs(workspaceId: String!): Boolean!