chore(server): cleanup team impl (#9171)
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -79,9 +79,6 @@ declare module '../../base/event/def' {
|
|||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
members: {
|
|
||||||
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user