feat(server): team mail sender (#9104)

fix AF-1914
This commit is contained in:
darkskygit
2024-12-12 07:33:31 +00:00
parent 350696c861
commit 69e5997608
19 changed files with 416 additions and 208 deletions
@@ -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 });
}
} }
+2 -2
View File
@@ -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!
+23 -7
View File
@@ -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();
+11 -5
View File
@@ -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})
} }
`, `,
}) })
@@ -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
}
} }
+7 -4
View File
@@ -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<{