Skip to content

Commit

Permalink
feat(server): team mail sender (#9104)
Browse files Browse the repository at this point in the history
  • Loading branch information
darkskygit committed Dec 12, 2024
1 parent 350696c commit 69e5997
Show file tree
Hide file tree
Showing 19 changed files with 416 additions and 208 deletions.
96 changes: 53 additions & 43 deletions packages/backend/server/src/core/permission/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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))
Expand All @@ -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) {
Expand All @@ -474,6 +480,10 @@ export class PermissionService {
workspaceId,
count,
});
this.event.emit('workspace.team.declineRequest', {
workspaceId,
inviteeId: user,
});
}
}
return success;
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/server/src/core/workspaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TeamWorkspaceResolver,
WorkspaceBlobResolver,
WorkspaceResolver,
WorkspaceService,
} from './resolvers';

@Module({
Expand All @@ -35,6 +36,7 @@ import {
PagePermissionResolver,
DocHistoryResolver,
WorkspaceBlobResolver,
WorkspaceService,
],
})
export class WorkspaceModule {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './blob';
export * from './history';
export * from './page';
export * from './service';
export * from './team';
export * from './workspace';
177 changes: 177 additions & 0 deletions packages/backend/server/src/core/workspaces/resolvers/service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading

0 comments on commit 69e5997

Please sign in to comment.