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