chore(server): cleanup team impl (#9171)

This commit is contained in:
forehalo
2024-12-16 08:41:11 +00:00
parent de2dab32c0
commit 83618e38a2
6 changed files with 148 additions and 152 deletions
@@ -3,13 +3,11 @@ 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'];
}>;
members: {
reviewRequested: Payload<{ inviteId: string }>;
requestDeclined: Payload<{ inviteId: string }>;
requestApproved: Payload<{ inviteId: string }>;
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
};
deleted: Payload<Workspace['id']>;
blob: {
@@ -6,19 +6,11 @@ import { groupBy } from 'lodash-es';
import {
DocAccessDenied,
EventEmitter,
PrismaTransaction,
SpaceAccessDenied,
SpaceOwnerNotFound,
} from '../../base';
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(
@@ -377,21 +369,6 @@ export class PermissionService {
.then(p => p.id);
}
private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) {
return await tx.workspaceFeature
.count({
where: {
workspaceId,
activated: true,
feature: {
feature: QuotaType.TeamPlanV1,
type: FeatureKind.Feature,
},
},
})
.then(count => count > 0);
}
async acceptWorkspaceInvitation(
invitationId: string,
workspaceId: string,
@@ -410,91 +387,94 @@ export class PermissionService {
}
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
const [pending, underReview] = await this.prisma.$transaction(async tx => {
const usedCount = await this.prisma.workspaceUserPermission.count({
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
});
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
await this.prisma.$transaction(async tx => {
const members = await tx.workspaceUserPermission.findMany({
where: { workspaceId },
select: { userId: true, status: true, updatedAt: true },
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
});
const memberCount = members.filter(
m => m.status === WorkspaceMemberStatus.Accepted
).length;
const needChange = members
.filter(m => NeedUpdateStatus.has(m.status))
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
.slice(0, memberLimit - memberCount);
const needChange = members.slice(0, availableCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange,
m => m.status
);
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: {
workspaceId,
userId: { in: inviteByLink },
status: WorkspaceMemberStatus.Pending,
},
select: { id: true, user: { select: { email: true } } },
})
.then(r => r.map(m => ({ inviteId: m.id, email: m.user.email })));
const underReview = await tx.workspaceUserPermission
.findMany({
where: {
workspaceId,
userId: { in: inviteByLink },
status: WorkspaceMemberStatus.UnderReview,
},
select: { id: true },
})
.then(r => ({ inviteIds: r.map(m => m.id) }));
return [pending, underReview] as const;
const toPendings = NeedMoreSeat ?? [];
if (toPendings.length > 0) {
await tx.workspaceUserPermission.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
if (toUnderReviewUserIds.length > 0) {
await tx.workspaceUserPermission.updateMany({
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
return [toPendings, toUnderReviewUserIds] as const;
});
this.event.emit('workspace.team.seatAvailable', pending);
this.event.emit('workspace.team.reviewRequest', underReview);
}
async revokeWorkspace(workspaceId: string, user: string) {
return await this.prisma.$transaction(async tx => {
const result = await tx.workspaceUserPermission.deleteMany({
where: {
workspaceId,
userId: user,
// We shouldn't revoke owner permission
// should auto deleted by workspace/user delete cascading
type: { not: Permission.Owner },
},
});
const success = result.count > 0;
if (success) {
const isTeam = await this.isTeamWorkspace(tx, workspaceId);
if (isTeam) {
const count = await tx.workspaceUserPermission.count({
where: { workspaceId },
});
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
this.event.emit('workspace.team.declineRequest', {
workspaceId,
inviteeId: user,
});
}
}
return success;
const permission = await this.prisma.workspaceUserPermission.findUnique({
where: { workspaceId_userId: { workspaceId, userId: user } },
});
// We shouldn't revoke owner permission
// should auto deleted by workspace/user delete cascading
if (!permission || permission.type === Permission.Owner) {
return false;
}
await this.prisma.workspaceUserPermission.deleteMany({
where: {
workspaceId,
userId: user,
},
});
const count = await this.prisma.workspaceUserPermission.count({
where: { workspaceId },
});
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
if (
permission.status === 'UnderReview' ||
permission.status === 'NeedMoreSeatAndReview'
) {
this.event.emit('workspace.members.requestDeclined', {
inviteId: permission.id,
});
}
return true;
}
/// End regin: workspace permission
@@ -78,20 +78,6 @@ export class WorkspaceService {
};
}
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);
@@ -117,7 +103,7 @@ export class WorkspaceService {
return true;
}
async sendReviewRequestMail(inviteId: string) {
async sendReviewRequestedMail(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
@@ -145,24 +131,50 @@ export class WorkspaceService {
}
}
async sendInviteMail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
const owner = await this.permission.getWorkspaceOwner(target.workspace.id);
await this.mailer.sendInviteEmail(target.email, inviteId, {
workspace: target.workspace,
user: {
avatar: owner.avatarUrl || '',
name: owner.name || '',
},
});
}
async sendReviewApproveEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
await this.mailer.sendReviewApproveEmail(target.email, target.workspace);
}
async sendReviewDeclinedEmail(inviteId: string) {
const target = await this.getInviteeEmailTarget(inviteId);
if (!target) {
return;
}
await this.mailer.sendReviewDeclinedEmail(target.email, target.workspace);
}
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 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) {
@@ -172,6 +184,9 @@ export class WorkspaceService {
return;
}
await this.mailer.sendReviewDeclinedEmail(invitee.email, workspace);
return {
email: invitee.email,
workspace,
};
}
}
@@ -123,7 +123,7 @@ export class TeamWorkspaceResolver {
// after user click the invite link, we can check again and reject if charge failed
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteMail(ret.inviteId, email);
await this.workspaceService.sendInviteMail(ret.inviteId);
ret.sentSuccess = true;
} catch (e) {
this.logger.warn(
@@ -253,8 +253,9 @@ export class TeamWorkspaceResolver {
);
if (result) {
// send approve mail
await this.workspaceService.sendReviewApproveEmail(result);
this.event.emit('workspace.members.requestApproved', {
inviteId: result,
});
}
return result;
}
@@ -314,22 +315,27 @@ export class TeamWorkspaceResolver {
}
}
@OnEvent('workspace.team.reviewRequest')
async onReviewRequest({
inviteIds,
}: EventPayload<'workspace.team.reviewRequest'>) {
@OnEvent('workspace.members.reviewRequested')
async onReviewRequested({
inviteId,
}: EventPayload<'workspace.members.reviewRequested'>) {
// send review request mail to owner and admin
for (const inviteId of inviteIds) {
await this.workspaceService.sendReviewRequestMail(inviteId);
}
await this.workspaceService.sendReviewRequestedMail(inviteId);
}
@OnEvent('workspace.team.declineRequest')
@OnEvent('workspace.members.requestDeclined')
async onDeclineRequest({
workspaceId,
inviteeId,
}: EventPayload<'workspace.team.declineRequest'>) {
inviteId,
}: EventPayload<'workspace.members.requestDeclined'>) {
// send decline mail
await this.workspaceService.sendReviewDeclinedEmail(workspaceId, inviteeId);
await this.workspaceService.sendReviewDeclinedEmail(inviteId);
}
@OnEvent('workspace.members.requestApproved')
async onApproveRequest({
inviteId,
}: EventPayload<'workspace.members.requestApproved'>) {
// send approve mail
await this.workspaceService.sendReviewApproveEmail(inviteId);
}
}
@@ -432,7 +432,7 @@ export class WorkspaceResolver {
);
if (sendInviteMail) {
try {
await this.workspaceService.sendInviteMail(inviteId, email);
await this.workspaceService.sendInviteMail(inviteId);
} catch (e) {
const ret = await this.permissions.revokeWorkspace(
workspaceId,
@@ -567,8 +567,8 @@ export class WorkspaceResolver {
} else {
const inviteId = await this.permissions.grant(workspaceId, user.id);
if (isTeam) {
this.event.emit('workspace.team.reviewRequest', {
inviteIds: [inviteId],
this.event.emit('workspace.members.reviewRequested', {
inviteId,
});
}
// invite by link need admin to approve
@@ -79,9 +79,6 @@ declare module '../../base/event/def' {
recurring: SubscriptionRecurring;
}>;
};
members: {
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
};
}
}