mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 09:21:24 +08:00
@@ -14,6 +14,11 @@ import { FeatureKind } from '../features/types';
|
|||||||
import { QuotaType } from '../quota/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(
|
||||||
@@ -94,6 +99,20 @@ export class PermissionService {
|
|||||||
return owner.user;
|
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) {
|
async getWorkspaceMemberCount(workspaceId: string) {
|
||||||
return this.prisma.workspaceUserPermission.count({
|
return this.prisma.workspaceUserPermission.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -351,18 +370,6 @@ export class PermissionService {
|
|||||||
.then(p => p.id);
|
.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) {
|
private async isTeamWorkspace(tx: PrismaTransaction, workspaceId: string) {
|
||||||
return await tx.workspaceFeature
|
return await tx.workspaceFeature
|
||||||
.count({
|
.count({
|
||||||
@@ -396,24 +403,14 @@ export class PermissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshSeatStatus(workspaceId: string, memberLimit: number) {
|
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({
|
const members = await tx.workspaceUserPermission.findMany({
|
||||||
where: {
|
where: { workspaceId },
|
||||||
workspaceId,
|
select: { userId: true, status: true, updatedAt: true },
|
||||||
},
|
|
||||||
select: {
|
|
||||||
userId: true,
|
|
||||||
status: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const memberCount = members.filter(
|
const memberCount = members.filter(
|
||||||
m => m.status === WorkspaceMemberStatus.Accepted
|
m => m.status === WorkspaceMemberStatus.Accepted
|
||||||
).length;
|
).length;
|
||||||
const NeedUpdateStatus = new Set<WorkspaceMemberStatus>([
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeat,
|
|
||||||
WorkspaceMemberStatus.NeedMoreSeatAndReview,
|
|
||||||
]);
|
|
||||||
const needChange = members
|
const needChange = members
|
||||||
.filter(m => NeedUpdateStatus.has(m.status))
|
.filter(m => NeedUpdateStatus.has(m.status))
|
||||||
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
|
.toSorted((a, b) => Number(a.updatedAt) - Number(b.updatedAt))
|
||||||
@@ -422,32 +419,41 @@ export class PermissionService {
|
|||||||
needChange,
|
needChange,
|
||||||
m => m.status
|
m => m.status
|
||||||
);
|
);
|
||||||
const approvedCount = await tx.workspaceUserPermission
|
const inviteByMail = NeedMoreSeat?.map(m => m.userId) ?? [];
|
||||||
.updateMany({
|
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: {
|
where: {
|
||||||
userId: {
|
workspaceId,
|
||||||
in: NeedMoreSeat?.map(m => m.userId) ?? [],
|
userId: { in: inviteByLink },
|
||||||
},
|
status: WorkspaceMemberStatus.Pending,
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: WorkspaceMemberStatus.Accepted,
|
|
||||||
},
|
},
|
||||||
|
select: { id: true, user: { select: { email: true } } },
|
||||||
})
|
})
|
||||||
.then(r => r.count);
|
.then(r => r.map(m => ({ inviteId: m.id, email: m.user.email })));
|
||||||
const needReviewCount = await tx.workspaceUserPermission
|
const underReview = await tx.workspaceUserPermission
|
||||||
.updateMany({
|
.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: {
|
workspaceId,
|
||||||
in: NeedMoreSeatAndReview?.map(m => m.userId) ?? [],
|
userId: { in: inviteByLink },
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
status: WorkspaceMemberStatus.UnderReview,
|
status: WorkspaceMemberStatus.UnderReview,
|
||||||
},
|
},
|
||||||
|
select: { id: true },
|
||||||
})
|
})
|
||||||
.then(r => r.count);
|
.then(r => ({ inviteIds: r.map(m => m.id) }));
|
||||||
return approvedCount + needReviewCount === needChange.length;
|
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) {
|
||||||
@@ -474,6 +480,10 @@ export class PermissionService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
count,
|
count,
|
||||||
});
|
});
|
||||||
|
this.event.emit('workspace.team.declineRequest', {
|
||||||
|
workspaceId,
|
||||||
|
inviteeId: user,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
TeamWorkspaceResolver,
|
TeamWorkspaceResolver,
|
||||||
WorkspaceBlobResolver,
|
WorkspaceBlobResolver,
|
||||||
WorkspaceResolver,
|
WorkspaceResolver,
|
||||||
|
WorkspaceService,
|
||||||
} from './resolvers';
|
} from './resolvers';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
PagePermissionResolver,
|
PagePermissionResolver,
|
||||||
DocHistoryResolver,
|
DocHistoryResolver,
|
||||||
WorkspaceBlobResolver,
|
WorkspaceBlobResolver,
|
||||||
|
WorkspaceService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class WorkspaceModule {}
|
export class WorkspaceModule {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './blob';
|
export * from './blob';
|
||||||
export * from './history';
|
export * from './history';
|
||||||
export * from './page';
|
export * from './page';
|
||||||
|
export * from './service';
|
||||||
export * from './team';
|
export * from './team';
|
||||||
export * from './workspace';
|
export * from './workspace';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,9 @@ import { nanoid } from 'nanoid';
|
|||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
MailService,
|
type EventPayload,
|
||||||
NotInSpace,
|
NotInSpace,
|
||||||
|
OnEvent,
|
||||||
RequestMutex,
|
RequestMutex,
|
||||||
TooManyRequest,
|
TooManyRequest,
|
||||||
URLHelper,
|
URLHelper,
|
||||||
@@ -28,7 +29,7 @@ import {
|
|||||||
WorkspaceInviteLinkExpireTime,
|
WorkspaceInviteLinkExpireTime,
|
||||||
WorkspaceType,
|
WorkspaceType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { WorkspaceResolver } from './workspace';
|
import { WorkspaceService } from './service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workspace team resolver
|
* Workspace team resolver
|
||||||
@@ -42,14 +43,13 @@ export class TeamWorkspaceResolver {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly cache: Cache,
|
private readonly cache: Cache,
|
||||||
private readonly event: EventEmitter,
|
private readonly event: EventEmitter,
|
||||||
private readonly mailer: MailService,
|
|
||||||
private readonly url: URLHelper,
|
private readonly url: URLHelper,
|
||||||
private readonly prisma: PrismaClient,
|
private readonly prisma: PrismaClient,
|
||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
private readonly users: UserService,
|
private readonly users: UserService,
|
||||||
private readonly quota: QuotaManagementService,
|
private readonly quota: QuotaManagementService,
|
||||||
private readonly mutex: RequestMutex,
|
private readonly mutex: RequestMutex,
|
||||||
private readonly workspace: WorkspaceResolver
|
private readonly workspaceService: WorkspaceService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ResolveField(() => Boolean, {
|
@ResolveField(() => Boolean, {
|
||||||
@@ -119,20 +119,8 @@ export class TeamWorkspaceResolver {
|
|||||||
: WorkspaceMemberStatus.Pending
|
: WorkspaceMemberStatus.Pending
|
||||||
);
|
);
|
||||||
if (!needMoreSeat && sendInviteMail) {
|
if (!needMoreSeat && sendInviteMail) {
|
||||||
const inviteInfo = await this.workspace.getInviteInfo(ret.inviteId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mailer.sendInviteEmail(email, ret.inviteId, {
|
await this.workspaceService.sendInviteMail(ret.inviteId, email);
|
||||||
workspace: {
|
|
||||||
id: inviteInfo.workspace.id,
|
|
||||||
name: inviteInfo.workspace.name,
|
|
||||||
avatar: inviteInfo.workspace.avatar,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
avatar: inviteInfo.user?.avatarUrl || '',
|
|
||||||
name: inviteInfo.user?.name || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
ret.sentSuccess = true;
|
ret.sentSuccess = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -182,7 +170,7 @@ export class TeamWorkspaceResolver {
|
|||||||
@Args('workspaceId') workspaceId: string,
|
@Args('workspaceId') workspaceId: string,
|
||||||
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
|
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
|
||||||
expireTime: WorkspaceInviteLinkExpireTime
|
expireTime: WorkspaceInviteLinkExpireTime
|
||||||
): Promise<InviteLink | null> {
|
): Promise<InviteLink> {
|
||||||
await this.permissions.checkWorkspace(
|
await this.permissions.checkWorkspace(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
user.id,
|
user.id,
|
||||||
@@ -205,7 +193,7 @@ export class TeamWorkspaceResolver {
|
|||||||
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
|
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
|
||||||
await this.cache.set(
|
await this.cache.set(
|
||||||
cacheInviteId,
|
cacheInviteId,
|
||||||
{ workspaceId, inviteeUserId: user.id },
|
{ workspaceId, inviterUserId: user.id },
|
||||||
{ ttl: expireTime }
|
{ ttl: expireTime }
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -262,7 +250,8 @@ export class TeamWorkspaceResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
// TODO(@darkskygit): send team approve mail
|
// send approve mail
|
||||||
|
await this.workspaceService.sendReviewApproveEmail(result);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -321,4 +310,31 @@ export class TeamWorkspaceResolver {
|
|||||||
return new TooManyRequest();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
import type { FileUpload } from '../../../fundamentals';
|
import type { FileUpload } from '../../../fundamentals';
|
||||||
@@ -32,10 +31,8 @@ import {
|
|||||||
} from '../../../fundamentals';
|
} from '../../../fundamentals';
|
||||||
import { CurrentUser, Public } from '../../auth';
|
import { CurrentUser, Public } from '../../auth';
|
||||||
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
|
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||||
import { DocContentService } from '../../doc-renderer';
|
|
||||||
import { Permission, PermissionService } from '../../permission';
|
import { Permission, PermissionService } from '../../permission';
|
||||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||||
import { WorkspaceBlobStorage } from '../../storage';
|
|
||||||
import { UserService, UserType } from '../../user';
|
import { UserService, UserType } from '../../user';
|
||||||
import {
|
import {
|
||||||
InvitationType,
|
InvitationType,
|
||||||
@@ -43,7 +40,7 @@ import {
|
|||||||
UpdateWorkspaceInput,
|
UpdateWorkspaceInput,
|
||||||
WorkspaceType,
|
WorkspaceType,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { defaultWorkspaceAvatar } from '../utils';
|
import { WorkspaceService } from './service';
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class EditorType implements Partial<Editor> {
|
export class EditorType implements Partial<Editor> {
|
||||||
@@ -86,9 +83,8 @@ export class WorkspaceResolver {
|
|||||||
private readonly quota: QuotaManagementService,
|
private readonly quota: QuotaManagementService,
|
||||||
private readonly users: UserService,
|
private readonly users: UserService,
|
||||||
private readonly event: EventEmitter,
|
private readonly event: EventEmitter,
|
||||||
private readonly blobStorage: WorkspaceBlobStorage,
|
|
||||||
private readonly mutex: RequestMutex,
|
private readonly mutex: RequestMutex,
|
||||||
private readonly doc: DocContentService,
|
private readonly workspaceService: WorkspaceService,
|
||||||
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
|
private readonly workspaceStorage: PgWorkspaceDocStorageAdapter
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -433,20 +429,8 @@ export class WorkspaceResolver {
|
|||||||
permission
|
permission
|
||||||
);
|
);
|
||||||
if (sendInviteMail) {
|
if (sendInviteMail) {
|
||||||
const inviteInfo = await this.getInviteInfo(inviteId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.mailer.sendInviteEmail(email, inviteId, {
|
await this.workspaceService.sendInviteMail(inviteId, email);
|
||||||
workspace: {
|
|
||||||
id: inviteInfo.workspace.id,
|
|
||||||
name: inviteInfo.workspace.name,
|
|
||||||
avatar: inviteInfo.workspace.avatar,
|
|
||||||
},
|
|
||||||
user: {
|
|
||||||
avatar: inviteInfo.user?.avatarUrl || '',
|
|
||||||
name: inviteInfo.user?.name || '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const ret = await this.permissions.revokeWorkspace(
|
const ret = await this.permissions.revokeWorkspace(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -483,63 +467,20 @@ export class WorkspaceResolver {
|
|||||||
@Query(() => InvitationType, {
|
@Query(() => InvitationType, {
|
||||||
description: 'send workspace invitation',
|
description: 'send workspace invitation',
|
||||||
})
|
})
|
||||||
async getInviteInfo(@Args('inviteId') inviteId: string) {
|
async getInviteInfo(
|
||||||
let workspaceId = null;
|
@CurrentUser() user: UserType | undefined,
|
||||||
let invitee = null;
|
@Args('inviteId') inviteId: string
|
||||||
// invite link
|
) {
|
||||||
const invite = await this.cache.get<{
|
const { workspaceId, inviteeUserId } =
|
||||||
workspaceId: string;
|
await this.workspaceService.getInviteInfo(inviteId);
|
||||||
inviteeUserId: string;
|
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||||
}>(`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);
|
|
||||||
|
|
||||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||||
|
|
||||||
if (!invitee) {
|
const inviteeId = inviteeUserId || user?.id;
|
||||||
invitee = await this.permissions.getWorkspaceInvitation(
|
if (!inviteeId) throw new UserNotFound();
|
||||||
inviteId,
|
const invitee = await this.users.findUserById(inviteeId);
|
||||||
workspaceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let avatar = '';
|
return { workspace, user: owner, invitee };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -569,13 +510,7 @@ export class WorkspaceResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.permissions.revokeWorkspace(workspaceId, userId);
|
return await this.permissions.revokeWorkspace(workspaceId, userId);
|
||||||
|
|
||||||
if (result && isTeam) {
|
|
||||||
// TODO(@darkskygit): send team revoke mail
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
@@ -615,8 +550,11 @@ export class WorkspaceResolver {
|
|||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
const inviteId = await this.permissions.grant(workspaceId, user.id);
|
const inviteId = await this.permissions.grant(workspaceId, user.id);
|
||||||
|
this.event.emit('workspace.team.reviewRequest', {
|
||||||
|
inviteIds: [inviteId],
|
||||||
|
});
|
||||||
// invite by link need admin to approve
|
// invite by link need admin to approve
|
||||||
return this.permissions.acceptWorkspaceInvitation(
|
return await this.permissions.acceptWorkspaceInvitation(
|
||||||
inviteId,
|
inviteId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
WorkspaceMemberStatus.UnderReview
|
WorkspaceMemberStatus.UnderReview
|
||||||
@@ -629,36 +567,31 @@ export class WorkspaceResolver {
|
|||||||
// so we need to check seat again here
|
// so we need to check seat again here
|
||||||
await this.quota.checkWorkspaceSeat(workspaceId, true);
|
await this.quota.checkWorkspaceSeat(workspaceId, true);
|
||||||
|
|
||||||
const {
|
|
||||||
invitee,
|
|
||||||
user: inviter,
|
|
||||||
workspace,
|
|
||||||
} = await this.getInviteInfo(inviteId);
|
|
||||||
|
|
||||||
if (!inviter || !invitee) {
|
|
||||||
throw new UserNotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sendAcceptMail) {
|
if (sendAcceptMail) {
|
||||||
// TODO(@darkskygit): team accept mail
|
const success = await this.workspaceService.sendAcceptedEmail(inviteId);
|
||||||
await this.mailer.sendAcceptedEmail(inviter.email, {
|
if (!success) throw new UserNotFound();
|
||||||
inviteeName: invitee.name,
|
|
||||||
workspaceName: workspace.name,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.permissions.acceptWorkspaceInvitation(inviteId, workspaceId);
|
return await this.permissions.acceptWorkspaceInvitation(
|
||||||
|
inviteId,
|
||||||
|
workspaceId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async leaveWorkspace(
|
async leaveWorkspace(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
@Args('workspaceId') workspaceId: string,
|
@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);
|
await this.permissions.checkWorkspace(workspaceId, user.id);
|
||||||
|
const { name: workspaceName } =
|
||||||
|
await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||||
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
const owner = await this.permissions.getWorkspaceOwner(workspaceId);
|
||||||
|
|
||||||
if (sendLeaveMail) {
|
if (sendLeaveMail) {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -3,6 +3,14 @@ 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: {
|
||||||
|
seatAvailable: Payload<{ inviteId: string; email: string }[]>;
|
||||||
|
reviewRequest: Payload<{ inviteIds: string[] }>;
|
||||||
|
declineRequest: Payload<{
|
||||||
|
workspaceId: Workspace['id'];
|
||||||
|
inviteeId: User['id'];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
deleted: Payload<Workspace['id']>;
|
deleted: Payload<Workspace['id']>;
|
||||||
blob: {
|
blob: {
|
||||||
deleted: Payload<{
|
deleted: Payload<{
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendChangeEmail(to: string, url: string) {
|
async sendChangeEmail(to: string, url: string) {
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
title: 'Verify your current email for AFFiNE',
|
title: 'Verify your current email for AFFiNE',
|
||||||
@@ -180,6 +181,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendVerifyChangeEmail(to: string, url: string) {
|
async sendVerifyChangeEmail(to: string, url: string) {
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
title: 'Verify your new email address',
|
title: 'Verify your new email address',
|
||||||
@@ -194,6 +196,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendVerifyEmail(to: string, url: string) {
|
async sendVerifyEmail(to: string, url: string) {
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
title: 'Verify your email address',
|
title: 'Verify your email address',
|
||||||
@@ -208,6 +211,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendNotificationChangeEmail(to: string) {
|
async sendNotificationChangeEmail(to: string) {
|
||||||
const html = emailTemplate({
|
const html = emailTemplate({
|
||||||
title: 'Email change successful',
|
title: 'Email change successful',
|
||||||
@@ -219,6 +223,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendAcceptedEmail(
|
async sendAcceptedEmail(
|
||||||
to: string,
|
to: string,
|
||||||
{
|
{
|
||||||
@@ -241,6 +246,7 @@ export class MailService {
|
|||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendLeaveWorkspaceEmail(
|
async sendLeaveWorkspaceEmail(
|
||||||
to: string,
|
to: string,
|
||||||
{
|
{
|
||||||
@@ -263,4 +269,46 @@ export class MailService {
|
|||||||
html,
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ type Mutation {
|
|||||||
|
|
||||||
"""Create a stripe customer portal to manage payment methods"""
|
"""Create a stripe customer portal to manage payment methods"""
|
||||||
createCustomerPortal: String!
|
createCustomerPortal: String!
|
||||||
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): String!
|
createInviteLink(expireTime: WorkspaceInviteLinkExpireTime!, workspaceId: String!): InviteLink!
|
||||||
|
|
||||||
"""Create a new user"""
|
"""Create a new user"""
|
||||||
createUser(input: CreateUserInput!): UserType!
|
createUser(input: CreateUserInput!): UserType!
|
||||||
@@ -523,7 +523,7 @@ type Mutation {
|
|||||||
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
|
grantMember(permission: Permission!, userId: String!, workspaceId: String!): String!
|
||||||
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
|
invite(email: String!, permission: Permission!, sendInviteMail: Boolean, workspaceId: String!): String!
|
||||||
inviteBatch(emails: [String!]!, sendInviteMail: Boolean, workspaceId: String!): [InviteResult!]!
|
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!
|
publishPage(mode: PublicPageMode = Page, pageId: String!, workspaceId: String!): WorkspacePage!
|
||||||
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
recoverDoc(guid: String!, timestamp: DateTime!, workspaceId: String!): DateTime!
|
||||||
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
releaseDeletedBlobs(workspaceId: String!): Boolean!
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ava from 'ava';
|
|||||||
|
|
||||||
import { AppModule } from '../src/app.module';
|
import { AppModule } from '../src/app.module';
|
||||||
import { AuthService } from '../src/core/auth';
|
import { AuthService } from '../src/core/auth';
|
||||||
|
import { DocContentService } from '../src/core/doc-renderer';
|
||||||
import { Permission, PermissionService } from '../src/core/permission';
|
import { Permission, PermissionService } from '../src/core/permission';
|
||||||
import {
|
import {
|
||||||
QuotaManagementService,
|
QuotaManagementService,
|
||||||
@@ -15,11 +16,11 @@ import {
|
|||||||
} from '../src/core/quota';
|
} from '../src/core/quota';
|
||||||
import {
|
import {
|
||||||
acceptInviteById,
|
acceptInviteById,
|
||||||
|
createInviteLink,
|
||||||
createTestingApp,
|
createTestingApp,
|
||||||
createWorkspace,
|
createWorkspace,
|
||||||
getInviteInfo,
|
getInviteInfo,
|
||||||
grantMember,
|
grantMember,
|
||||||
inviteLink,
|
|
||||||
inviteUser,
|
inviteUser,
|
||||||
inviteUsers,
|
inviteUsers,
|
||||||
leaveWorkspace,
|
leaveWorkspace,
|
||||||
@@ -40,6 +41,16 @@ const test = ava as TestFn<{
|
|||||||
test.beforeEach(async t => {
|
test.beforeEach(async t => {
|
||||||
const { app } = await createTestingApp({
|
const { app } = await createTestingApp({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
|
tapModule: module => {
|
||||||
|
module.overrideProvider(DocContentService).useValue({
|
||||||
|
getWorkspaceContent() {
|
||||||
|
return {
|
||||||
|
name: 'test',
|
||||||
|
avatarKey: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const quota = app.get(QuotaService);
|
const quota = app.get(QuotaService);
|
||||||
@@ -94,8 +105,14 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
return [members, invites] as const;
|
return [members, invites] as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createInviteLink = async () => {
|
const getCreateInviteLinkFetcher = async () => {
|
||||||
const inviteId = await inviteLink(app, owner.token.token, ws.id, 'OneDay');
|
const { link } = await createInviteLink(
|
||||||
|
app,
|
||||||
|
owner.token.token,
|
||||||
|
ws.id,
|
||||||
|
'OneDay'
|
||||||
|
);
|
||||||
|
const inviteId = link.split('/').pop()!;
|
||||||
return [
|
return [
|
||||||
inviteId,
|
inviteId,
|
||||||
async (email: string): Promise<UserAuthedType> => {
|
async (email: string): Promise<UserAuthedType> => {
|
||||||
@@ -113,7 +130,7 @@ const init = async (app: INestApplication, memberLimit = 10) => {
|
|||||||
return {
|
return {
|
||||||
invite,
|
invite,
|
||||||
inviteBatch,
|
inviteBatch,
|
||||||
createInviteLink,
|
createInviteLink: getCreateInviteLinkFetcher,
|
||||||
owner,
|
owner,
|
||||||
ws,
|
ws,
|
||||||
admin,
|
admin,
|
||||||
@@ -169,7 +186,7 @@ test('should be able to check seat limit', async t => {
|
|||||||
ws.id,
|
ws.id,
|
||||||
(await members1)[0][0].id
|
(await members1)[0][0].id
|
||||||
),
|
),
|
||||||
WorkspaceMemberStatus.Accepted,
|
WorkspaceMemberStatus.Pending,
|
||||||
'should become accepted after refresh'
|
'should become accepted after refresh'
|
||||||
);
|
);
|
||||||
t.is(
|
t.is(
|
||||||
@@ -239,8 +256,7 @@ test('should be able to leave workspace', async t => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// enabled in next PR
|
test('should be able to invite by link', async t => {
|
||||||
test.skip('should be able to invite by link', async t => {
|
|
||||||
const { app, permissions, quotaManager } = t.context;
|
const { app, permissions, quotaManager } = t.context;
|
||||||
const { createInviteLink, owner, ws } = await init(app, 4);
|
const { createInviteLink, owner, ws } = await init(app, 4);
|
||||||
const [inviteId, invite] = await createInviteLink();
|
const [inviteId, invite] = await createInviteLink();
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export async function inviteUsers(
|
|||||||
return res.body.data.inviteBatch;
|
return res.body.data.inviteBatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function inviteLink(
|
export async function createInviteLink(
|
||||||
app: INestApplication,
|
app: INestApplication,
|
||||||
token: string,
|
token: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth'
|
expireTime: 'OneDay' | 'ThreeDays' | 'OneWeek' | 'OneMonth'
|
||||||
): Promise<string> {
|
): Promise<{ link: string; expireTime: string }> {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(gql)
|
.post(gql)
|
||||||
.auth(token, { type: 'bearer' })
|
.auth(token, { type: 'bearer' })
|
||||||
@@ -78,7 +78,10 @@ export async function inviteLink(
|
|||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
mutation {
|
mutation {
|
||||||
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime})
|
createInviteLink(workspaceId: "${workspaceId}", expireTime: ${expireTime}) {
|
||||||
|
link
|
||||||
|
expireTime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -109,7 +112,10 @@ export async function acceptInviteById(
|
|||||||
})
|
})
|
||||||
.expect(200);
|
.expect(200);
|
||||||
if (res.body.errors) {
|
if (res.body.errors) {
|
||||||
throw new Error(res.body.errors[0].message);
|
console.error(res.body.errors);
|
||||||
|
throw new Error(res.body.errors[0].message, {
|
||||||
|
cause: res.body.errors[0].cause,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return res.body.data.acceptInviteById;
|
return res.body.data.acceptInviteById;
|
||||||
}
|
}
|
||||||
@@ -127,7 +133,7 @@ export async function leaveWorkspace(
|
|||||||
.send({
|
.send({
|
||||||
query: `
|
query: `
|
||||||
mutation {
|
mutation {
|
||||||
leaveWorkspace(workspaceId: "${workspaceId}", workspaceName: "test workspace", sendLeaveMail: ${sendLeaveMail})
|
leaveWorkspace(workspaceId: "${workspaceId}", sendLeaveMail: ${sendLeaveMail})
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -88,7 +88,7 @@ export const CloudWorkspaceMembersPanel = ({
|
|||||||
|
|
||||||
const onGenerateInviteLink = useCallback(
|
const onGenerateInviteLink = useCallback(
|
||||||
async (expireTime: WorkspaceInviteLinkExpireTime) => {
|
async (expireTime: WorkspaceInviteLinkExpireTime) => {
|
||||||
const link =
|
const { link } =
|
||||||
await permissionService.permission.generateInviteLink(expireTime);
|
await permissionService.permission.generateInviteLink(expireTime);
|
||||||
return link;
|
return link;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,10 +16,7 @@ export class WorkspacePermissionService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async leaveWorkspace() {
|
async leaveWorkspace() {
|
||||||
await this.store.leaveWorkspace(
|
await this.store.leaveWorkspace(this.workspaceService.workspace.id);
|
||||||
this.workspaceService.workspace.id,
|
|
||||||
this.workspaceService.workspace.name$.value ?? ''
|
|
||||||
);
|
|
||||||
this.workspacesService.list.revalidate();
|
this.workspacesService.list.revalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class WorkspacePermissionStore extends Store {
|
|||||||
/**
|
/**
|
||||||
* @param workspaceName for send email
|
* @param workspaceName for send email
|
||||||
*/
|
*/
|
||||||
async leaveWorkspace(workspaceId: string, workspaceName: string) {
|
async leaveWorkspace(workspaceId: string) {
|
||||||
if (!this.workspaceServerService.server) {
|
if (!this.workspaceServerService.server) {
|
||||||
throw new Error('No Server');
|
throw new Error('No Server');
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,6 @@ export class WorkspacePermissionStore extends Store {
|
|||||||
query: leaveWorkspaceMutation,
|
query: leaveWorkspaceMutation,
|
||||||
variables: {
|
variables: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceName,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -840,12 +840,8 @@ export const leaveWorkspaceMutation = {
|
|||||||
definitionName: 'leaveWorkspace',
|
definitionName: 'leaveWorkspace',
|
||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLeaveMail: Boolean) {
|
mutation leaveWorkspace($workspaceId: String!, $sendLeaveMail: Boolean) {
|
||||||
leaveWorkspace(
|
leaveWorkspace(workspaceId: $workspaceId, sendLeaveMail: $sendLeaveMail)
|
||||||
workspaceId: $workspaceId
|
|
||||||
workspaceName: $workspaceName
|
|
||||||
sendLeaveMail: $sendLeaveMail
|
|
||||||
)
|
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1442,7 +1438,10 @@ export const createInviteLinkMutation = {
|
|||||||
containsFile: false,
|
containsFile: false,
|
||||||
query: `
|
query: `
|
||||||
mutation createInviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
|
mutation createInviteLink($workspaceId: String!, $expireTime: WorkspaceInviteLinkExpireTime!) {
|
||||||
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
|
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) {
|
||||||
|
link
|
||||||
|
expireTime
|
||||||
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
mutation leaveWorkspace(
|
mutation leaveWorkspace($workspaceId: String!, $sendLeaveMail: Boolean) {
|
||||||
$workspaceId: String!
|
leaveWorkspace(workspaceId: $workspaceId, sendLeaveMail: $sendLeaveMail)
|
||||||
$workspaceName: String!
|
|
||||||
$sendLeaveMail: Boolean
|
|
||||||
) {
|
|
||||||
leaveWorkspace(
|
|
||||||
workspaceId: $workspaceId
|
|
||||||
workspaceName: $workspaceName
|
|
||||||
sendLeaveMail: $sendLeaveMail
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,8 @@ mutation createInviteLink(
|
|||||||
$workspaceId: String!
|
$workspaceId: String!
|
||||||
$expireTime: WorkspaceInviteLinkExpireTime!
|
$expireTime: WorkspaceInviteLinkExpireTime!
|
||||||
) {
|
) {
|
||||||
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime)
|
createInviteLink(workspaceId: $workspaceId, expireTime: $expireTime) {
|
||||||
|
link
|
||||||
|
expireTime
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ export interface Mutation {
|
|||||||
createCopilotSession: Scalars['String']['output'];
|
createCopilotSession: Scalars['String']['output'];
|
||||||
/** Create a stripe customer portal to manage payment methods */
|
/** Create a stripe customer portal to manage payment methods */
|
||||||
createCustomerPortal: Scalars['String']['output'];
|
createCustomerPortal: Scalars['String']['output'];
|
||||||
createInviteLink: Scalars['String']['output'];
|
createInviteLink: InviteLink;
|
||||||
/** Create a new user */
|
/** Create a new user */
|
||||||
createUser: UserType;
|
createUser: UserType;
|
||||||
/** Create a new workspace */
|
/** Create a new workspace */
|
||||||
@@ -735,7 +735,7 @@ export interface MutationInviteBatchArgs {
|
|||||||
export interface MutationLeaveWorkspaceArgs {
|
export interface MutationLeaveWorkspaceArgs {
|
||||||
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
workspaceName: Scalars['String']['input'];
|
workspaceName?: InputMaybe<Scalars['String']['input']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MutationPublishPageArgs {
|
export interface MutationPublishPageArgs {
|
||||||
@@ -2163,7 +2163,6 @@ export type InvoicesQuery = {
|
|||||||
|
|
||||||
export type LeaveWorkspaceMutationVariables = Exact<{
|
export type LeaveWorkspaceMutationVariables = Exact<{
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
workspaceName: Scalars['String']['input'];
|
|
||||||
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -2692,7 +2691,11 @@ export type CreateInviteLinkMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type CreateInviteLinkMutation = {
|
export type CreateInviteLinkMutation = {
|
||||||
__typename?: 'Mutation';
|
__typename?: 'Mutation';
|
||||||
createInviteLink: string;
|
createInviteLink: {
|
||||||
|
__typename?: 'InviteLink';
|
||||||
|
link: string;
|
||||||
|
expireTime: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RevokeInviteLinkMutationVariables = Exact<{
|
export type RevokeInviteLinkMutationVariables = Exact<{
|
||||||
|
|||||||
Reference in New Issue
Block a user