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

View File

@@ -3,13 +3,11 @@ import type { Snapshot, User, Workspace } from '@prisma/client';
import { Flatten, Payload } from './types'; import { Flatten, Payload } from './types';
export interface WorkspaceEvents { export interface WorkspaceEvents {
team: { members: {
seatAvailable: Payload<{ inviteId: string; email: string }[]>; reviewRequested: Payload<{ inviteId: string }>;
reviewRequest: Payload<{ inviteIds: string[] }>; requestDeclined: Payload<{ inviteId: string }>;
declineRequest: Payload<{ requestApproved: Payload<{ inviteId: string }>;
workspaceId: Workspace['id']; updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
inviteeId: User['id'];
}>;
}; };
deleted: Payload<Workspace['id']>; deleted: Payload<Workspace['id']>;
blob: { blob: {

View File

@@ -6,19 +6,11 @@ import { groupBy } from 'lodash-es';
import { import {
DocAccessDenied, DocAccessDenied,
EventEmitter, EventEmitter,
PrismaTransaction,
SpaceAccessDenied, SpaceAccessDenied,
SpaceOwnerNotFound, SpaceOwnerNotFound,
} from '../../base'; } from '../../base';
import { FeatureKind } from '../features/types';
import { QuotaType } from '../quota/types';
import { Permission, PublicPageMode } from './types'; import { Permission, PublicPageMode } from './types';
const NeedUpdateStatus = new Set<WorkspaceMemberStatus>([
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
]);
@Injectable() @Injectable()
export class PermissionService { export class PermissionService {
constructor( constructor(
@@ -377,21 +369,6 @@ export class PermissionService {
.then(p => p.id); .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( async acceptWorkspaceInvitation(
invitationId: string, invitationId: string,
workspaceId: string, workspaceId: string,
@@ -410,91 +387,94 @@ export class PermissionService {
} }
async refreshSeatStatus(workspaceId: string, memberLimit: number) { 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({ const members = await tx.workspaceUserPermission.findMany({
where: { workspaceId }, select: { id: true, status: true },
select: { userId: true, status: true, updatedAt: true }, where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
}); });
const memberCount = members.filter(
m => m.status === WorkspaceMemberStatus.Accepted const needChange = members.slice(0, availableCount);
).length;
const needChange = members
.filter(m => NeedUpdateStatus.has(m.status))
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
.slice(0, memberLimit - memberCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy( const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange, needChange,
m => m.status 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 const toPendings = NeedMoreSeat ?? [];
.findMany({ if (toPendings.length > 0) {
where: { await tx.workspaceUserPermission.updateMany({
workspaceId, where: { id: { in: toPendings.map(m => m.id) } },
userId: { in: inviteByLink }, data: { status: WorkspaceMemberStatus.Pending },
status: WorkspaceMemberStatus.Pending, });
}, }
select: { id: true, user: { select: { email: true } } },
}) const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
.then(r => r.map(m => ({ inviteId: m.id, email: m.user.email }))); if (toUnderReviewUserIds.length > 0) {
const underReview = await tx.workspaceUserPermission await tx.workspaceUserPermission.updateMany({
.findMany({ where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
where: { data: { status: WorkspaceMemberStatus.UnderReview },
workspaceId, });
userId: { in: inviteByLink }, }
status: WorkspaceMemberStatus.UnderReview,
}, return [toPendings, toUnderReviewUserIds] as const;
select: { id: true },
})
.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) { async revokeWorkspace(workspaceId: string, user: string) {
return await this.prisma.$transaction(async tx => { const permission = await this.prisma.workspaceUserPermission.findUnique({
const result = await tx.workspaceUserPermission.deleteMany({ where: { workspaceId_userId: { workspaceId, userId: user } },
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;
}); });
// 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 /// End regin: workspace permission

View File

@@ -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) { async sendAcceptedEmail(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } = const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId); await this.getInviteInfo(inviteId);
@@ -117,7 +103,7 @@ export class WorkspaceService {
return true; return true;
} }
async sendReviewRequestMail(inviteId: string) { async sendReviewRequestedMail(inviteId: string) {
const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId); const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) { if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); 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) { 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); const { workspaceId, inviteeUserId } = await this.getInviteInfo(inviteId);
if (!inviteeUserId) { if (!inviteeUserId) {
this.logger.error(`Invitee user not found for inviteId: ${inviteId}`); this.logger.error(`Invitee user not found for inviteId: ${inviteId}`);
return; 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 workspace = await this.getWorkspaceInfo(workspaceId);
const invitee = await this.user.findUserById(inviteeUserId); const invitee = await this.user.findUserById(inviteeUserId);
if (!invitee) { if (!invitee) {
@@ -172,6 +184,9 @@ export class WorkspaceService {
return; return;
} }
await this.mailer.sendReviewDeclinedEmail(invitee.email, workspace); return {
email: invitee.email,
workspace,
};
} }
} }

View File

@@ -123,7 +123,7 @@ export class TeamWorkspaceResolver {
// after user click the invite link, we can check again and reject if charge failed // after user click the invite link, we can check again and reject if charge failed
if (sendInviteMail) { if (sendInviteMail) {
try { try {
await this.workspaceService.sendInviteMail(ret.inviteId, email); await this.workspaceService.sendInviteMail(ret.inviteId);
ret.sentSuccess = true; ret.sentSuccess = true;
} catch (e) { } catch (e) {
this.logger.warn( this.logger.warn(
@@ -253,8 +253,9 @@ export class TeamWorkspaceResolver {
); );
if (result) { if (result) {
// send approve mail this.event.emit('workspace.members.requestApproved', {
await this.workspaceService.sendReviewApproveEmail(result); inviteId: result,
});
} }
return result; return result;
} }
@@ -314,22 +315,27 @@ export class TeamWorkspaceResolver {
} }
} }
@OnEvent('workspace.team.reviewRequest') @OnEvent('workspace.members.reviewRequested')
async onReviewRequest({ async onReviewRequested({
inviteIds, inviteId,
}: EventPayload<'workspace.team.reviewRequest'>) { }: EventPayload<'workspace.members.reviewRequested'>) {
// send review request mail to owner and admin // send review request mail to owner and admin
for (const inviteId of inviteIds) { await this.workspaceService.sendReviewRequestedMail(inviteId);
await this.workspaceService.sendReviewRequestMail(inviteId);
}
} }
@OnEvent('workspace.team.declineRequest') @OnEvent('workspace.members.requestDeclined')
async onDeclineRequest({ async onDeclineRequest({
workspaceId, inviteId,
inviteeId, }: EventPayload<'workspace.members.requestDeclined'>) {
}: EventPayload<'workspace.team.declineRequest'>) {
// send decline mail // 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);
} }
} }

View File

@@ -432,7 +432,7 @@ export class WorkspaceResolver {
); );
if (sendInviteMail) { if (sendInviteMail) {
try { try {
await this.workspaceService.sendInviteMail(inviteId, email); await this.workspaceService.sendInviteMail(inviteId);
} catch (e) { } catch (e) {
const ret = await this.permissions.revokeWorkspace( const ret = await this.permissions.revokeWorkspace(
workspaceId, workspaceId,
@@ -567,8 +567,8 @@ export class WorkspaceResolver {
} else { } else {
const inviteId = await this.permissions.grant(workspaceId, user.id); const inviteId = await this.permissions.grant(workspaceId, user.id);
if (isTeam) { if (isTeam) {
this.event.emit('workspace.team.reviewRequest', { this.event.emit('workspace.members.reviewRequested', {
inviteIds: [inviteId], inviteId,
}); });
} }
// invite by link need admin to approve // invite by link need admin to approve

View File

@@ -79,9 +79,6 @@ declare module '../../base/event/def' {
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
}>; }>;
}; };
members: {
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
};
} }
} }