feat(server): delay subscription after invitation accepted or approved (#11992)

This commit is contained in:
liuyi
2025-04-30 14:27:47 +08:00
committed by GitHub
parent 9a721c65b5
commit 2e1bed061e
49 changed files with 1990 additions and 2694 deletions
@@ -73,7 +73,9 @@ test('should create invitation notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitation');
await notificationJob.sendInvitation({
@@ -92,7 +94,9 @@ test('should create invitation accepted notification when user accepts the invit
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
{
status: WorkspaceMemberStatus.Accepted,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationAccepted');
await notificationJob.sendInvitationAccepted({
@@ -121,7 +125,9 @@ test('should create invitation review request notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewRequest');
await notificationJob.sendInvitationReviewRequest({
@@ -151,7 +157,9 @@ test('should create invitation review approved notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewApproved');
await notificationJob.sendInvitationReviewApproved({
@@ -181,7 +189,9 @@ test('should create invitation review declined notification', async t => {
workspace.id,
member.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Pending
{
status: WorkspaceMemberStatus.Pending,
}
);
const spy = Sinon.spy(notificationService, 'createInvitationReviewDeclined');
await notificationJob.sendInvitationReviewDeclined({
@@ -46,12 +46,9 @@ test('should get null role', async t => {
test('should return null if workspace role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.UnderReview,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -114,12 +111,9 @@ test('should return [External] if doc is public', async t => {
test('should return null if doc role is [None]', async t => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -132,12 +126,9 @@ test('should return null if doc role is [None]', async t => {
test('should return [External] if doc role is [None] but doc is public', async t => {
await models.doc.setDefaultRole(ws.id, 'doc1', DocRole.None);
await models.workspaceUser.set(
ws.id,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, user.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
await models.doc.publish(ws.id, 'doc1');
const role = await ac.getRole({
@@ -180,12 +171,9 @@ test('should assert action', async t => {
)
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.Accepted,
});
await models.docUser.set(ws.id, 'doc1', u2.id, DocRole.Manager);
@@ -45,12 +45,9 @@ test('should get null role', async t => {
test('should return null if role is not accepted', async t => {
const u2 = await models.user.create({ email: 'u2@affine.pro' });
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Collaborator, {
status: WorkspaceMemberStatus.UnderReview,
});
const role = await ac.getRole({
workspaceId: ws.id,
@@ -131,12 +128,9 @@ test('should assert action', async t => {
ac.assert({ workspaceId: ws.id, userId: u2.id }, 'Workspace.Sync')
);
await models.workspaceUser.set(
ws.id,
u2.id,
WorkspaceRole.Admin,
WorkspaceMemberStatus.Accepted
);
await models.workspaceUser.set(ws.id, u2.id, WorkspaceRole.Admin, {
status: WorkspaceMemberStatus.Accepted,
});
await t.notThrowsAsync(
ac.assert(
@@ -1,14 +1,42 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '../../base';
import { Models } from '../../models';
import { WorkspaceService } from './resolvers/service';
import { Mailer } from '../mail';
import { WorkspaceService } from './service';
declare global {
interface Events {
'workspace.members.invite': {
inviterId: string;
inviteId: string;
};
'workspace.members.removed': {
workspaceId: string;
userId: string;
};
'workspace.members.leave': {
workspaceId: string;
userId: string;
};
'workspace.members.updated': {
workspaceId: string;
};
'workspace.members.allocateSeats': {
workspaceId: string;
quantity: number;
};
}
}
@Injectable()
export class WorkspaceEvents {
private readonly logger = new Logger(WorkspaceEvents.name);
constructor(
private readonly workspaceService: WorkspaceService,
private readonly models: Models
private readonly models: Models,
private readonly mailer: Mailer
) {}
@OnEvent('workspace.members.roleChanged')
@@ -24,6 +52,30 @@ export class WorkspaceEvents {
});
}
@OnEvent('workspace.members.removed')
async onMemberRemoved({
userId,
workspaceId,
}: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId);
if (!user) {
this.logger.warn(
`User not found for seeding member removed email: ${userId}`
);
return;
}
await this.mailer.send({
name: 'MemberRemoved',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
@OnEvent('workspace.owner.changed')
async onOwnerTransferred({
workspaceId,
@@ -49,4 +101,28 @@ export class WorkspaceEvents {
});
}
}
@OnEvent('workspace.members.leave')
async onMemberLeave({
userId,
workspaceId,
}: Events['workspace.members.leave']) {
await this.workspaceService.sendLeaveEmail(workspaceId, userId);
}
@OnEvent('workspace.members.invite')
async onMemberInvite({
inviterId,
inviteId,
}: Events['workspace.members.invite']) {
await this.workspaceService.sendInvitationNotification(inviterId, inviteId);
}
@OnEvent('workspace.members.allocateSeats')
async onAllocateSeats({
workspaceId,
quantity,
}: Events['workspace.members.allocateSeats']) {
await this.workspaceService.allocateSeats(workspaceId, quantity);
}
}
@@ -14,12 +14,12 @@ import { WorkspaceEvents } from './event';
import {
DocHistoryResolver,
DocResolver,
TeamWorkspaceResolver,
WorkspaceBlobResolver,
WorkspaceDocResolver,
WorkspaceMemberResolver,
WorkspaceResolver,
WorkspaceService,
} from './resolvers';
import { WorkspaceService } from './service';
@Module({
imports: [
@@ -36,7 +36,7 @@ import {
controllers: [WorkspacesController],
providers: [
WorkspaceResolver,
TeamWorkspaceResolver,
WorkspaceMemberResolver,
WorkspaceDocResolver,
DocResolver,
DocHistoryResolver,
@@ -48,4 +48,5 @@ import {
})
export class WorkspaceModule {}
export { WorkspaceService } from './service';
export { InvitationType, WorkspaceType } from './types';
@@ -17,6 +17,7 @@ import {
Cache,
DocActionDenied,
DocDefaultRoleCanNotBeOwner,
DocNotFound,
ExpectToGrantDocUserRoles,
ExpectToPublishDoc,
ExpectToRevokeDocUserRoles,
@@ -29,6 +30,7 @@ import {
} from '../../../base';
import { Models, PublicDocMode } from '../../../models';
import { CurrentUser } from '../../auth';
import { Editor } from '../../doc';
import {
AccessController,
DOC_ACTIONS,
@@ -148,6 +150,29 @@ const DocPermissions = registerObjectType<
{ name: 'DocPermissions' }
);
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
class WorkspaceDocMeta {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
@Field(() => EditorType, { nullable: true })
createdBy!: EditorType | null;
@Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null;
}
@Resolver(() => WorkspaceType)
export class WorkspaceDocResolver {
private readonly logger = new Logger(WorkspaceDocResolver.name);
@@ -162,6 +187,28 @@ export class WorkspaceDocResolver {
private readonly cache: Cache
) {}
@ResolveField(() => WorkspaceDocMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
deprecationReason: 'use [WorkspaceType.doc.meta] instead',
})
async pageMeta(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
const metadata = await this.models.doc.getAuthors(workspace.id, pageId);
if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => [DocType], {
complexity: 2,
deprecationReason: 'use [WorkspaceType.publicDocs] instead',
@@ -364,6 +411,26 @@ export class DocResolver {
private readonly models: Models
) {}
@ResolveField(() => WorkspaceDocMeta, {
description: 'Doc metadata',
complexity: 2,
})
async meta(@Parent() doc: DocType) {
const metadata = await this.models.doc.getAuthors(
doc.workspaceId,
doc.docId
);
if (!metadata) {
throw new DocNotFound({ spaceId: doc.workspaceId, docId: doc.docId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => DocPermissions)
async permissions(
@CurrentUser() user: CurrentUser,
@@ -16,7 +16,7 @@ import { PgWorkspaceDocStorageAdapter } from '../../doc';
import { AccessController } from '../../permission';
import { DocID } from '../../utils/doc';
import { WorkspaceType } from '../types';
import { EditorType } from './workspace';
import { EditorType } from './doc';
@ObjectType()
class DocHistoryType implements Partial<SnapshotHistory> {
@@ -1,6 +1,5 @@
export * from './blob';
export * from './doc';
export * from './history';
export * from './service';
export * from './team';
export * from './member';
export * from './workspace';
@@ -0,0 +1,643 @@
import {
Args,
Int,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { nanoid } from 'nanoid';
import {
ActionForbiddenOnNonTeamWorkspace,
AlreadyInSpace,
AuthenticationRequired,
Cache,
CanNotRevokeYourself,
EventBus,
InvalidInvitation,
mapAnyError,
MemberNotFoundInSpace,
NoMoreSeat,
OwnerCanNotLeaveWorkspace,
QueryTooLong,
RequestMutex,
SpaceAccessDenied,
Throttle,
TooManyRequest,
URLHelper,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota';
import { UserType } from '../../user';
import { validators } from '../../utils/validators';
import { WorkspaceService } from '../service';
import {
InvitationType,
InviteLink,
InviteResult,
InviteUserType,
WorkspaceInviteLinkExpireTime,
WorkspaceType,
} from '../types';
/**
* Workspace team resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@Resolver(() => WorkspaceType)
export class WorkspaceMemberResolver {
constructor(
private readonly cache: Cache,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly ac: AccessController,
private readonly models: Models,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
private readonly quota: QuotaService
) {}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.getOwner(workspace.id);
}
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.count(workspace.id);
}
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number,
@Args('query', { type: () => String, nullable: true }) query?: string
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Read');
if (query) {
if (query.length > 255) {
throw new QueryTooLong({ max: 255 });
}
const list = await this.models.workspaceUser.search(workspace.id, query, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
status,
}));
} else {
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
status,
}));
}
}
@Mutation(() => [InviteResult])
async inviteMembers(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[]
): Promise<InviteResult[]> {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
if (emails.length > 512) {
throw new TooManyRequest();
}
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
const isTeam = await this.models.workspace.isTeamWorkspace(workspaceId);
const results: InviteResult[] = [];
for (const [idx, email] of emails.entries()) {
try {
validators.assertValidEmail(email);
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord = await this.models.workspaceUser.get(
workspaceId,
target.id
);
// only invite if the user is not already in the workspace
if (originRecord) {
throw new AlreadyInSpace({ spaceId: workspaceId });
}
} else {
target = await this.models.user.create({
email,
registered: false,
});
}
// no need to check quota, directly go allocating seat path
if (isTeam) {
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.AllocatingSeat,
source: WorkspaceMemberSource.Email,
inviterId: me.id,
}
);
results.push({
email,
inviteId: role.id,
});
} else {
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
if (needMoreSeat) {
throw new NoMoreSeat({ spaceId: workspaceId });
} else {
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
source: WorkspaceMemberSource.Email,
inviterId: me.id,
}
);
this.event.emit('workspace.members.invite', {
inviteId: role.id,
inviterId: me.id,
});
results.push({
email,
inviteId: role.id,
});
}
}
} catch (error) {
results.push({
email,
error: mapAnyError(error),
});
}
}
this.event.emit('workspace.members.updated', {
workspaceId,
});
return results;
}
/**
* @deprecated
*/
@Mutation(() => [InviteResult], {
deprecationReason: 'use [inviteMembers] instead',
})
async inviteBatch(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean = false
) {
return this.inviteMembers(user, workspaceId, emails);
}
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(
@Parent() workspace: WorkspaceType,
@CurrentUser() user: CurrentUser
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (id) {
const expireTime = await this.cache.ttl(cacheId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
return null;
}
@Mutation(() => InviteLink)
async createInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
if (typeof invite?.inviteId === 'string') {
const expireTime = await this.cache.ttl(cacheWorkspaceId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${invite.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviterUserId: user.id },
{ ttl: expireTime }
);
return {
link: this.url.link(`/invite/${inviteId}`),
expireTime: new Date(Date.now() + expireTime),
};
}
@Mutation(() => Boolean)
async revokeInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspaceId}`;
return await this.cache.delete(cacheId);
}
@Mutation(() => Boolean)
async approveMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (role) {
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.models.workspaceUser.setStatus(
workspaceId,
userId,
WorkspaceMemberStatus.AllocatingSeat,
{
inviterId: me.id,
}
);
this.event.emit('workspace.members.updated', {
workspaceId,
});
await this.workspaceService.sendReviewApprovedNotification(
role.id,
me.id
);
}
return true;
} else {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
}
@Mutation(() => Boolean)
async grantMember(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert(
newRole === WorkspaceRole.Owner
? 'Workspace.TransferOwner'
: 'Workspace.Users.Manage'
);
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
if (newRole === WorkspaceRole.Owner) {
await this.models.workspaceUser.setOwner(workspaceId, userId);
} else {
// non-team workspace can only transfer ownership, but no detailed permission control
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace();
}
await this.models.workspaceUser.set(workspaceId, userId, newRole);
}
return true;
}
@Throttle('strict')
@Public()
@Query(() => InvitationType, {
description: 'get workspace invitation info',
})
async getInviteInfo(
@CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string
): Promise<InvitationType> {
const { workspaceId, inviteeUserId, isLink } =
await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getWorkspaceUser(inviteeId);
if (!invitee) throw new UserNotFound();
let status: WorkspaceMemberStatus | undefined;
if (isLink) {
const invitation = await this.models.workspaceUser.get(
workspaceId,
inviteeId
);
status = invitation?.status;
} else {
const invitation = await this.models.workspaceUser.getById(inviteId);
status = invitation?.status;
}
return { workspace, user: owner, invitee, status };
}
/**
* @deprecated
*/
@Mutation(() => Boolean, {
deprecationReason: 'use [revokeMember] instead',
})
async revoke(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
return this.revokeMember(me, workspaceId, userId);
}
@Mutation(() => Boolean)
async revokeMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
if (userId === me.id) {
throw new CanNotRevokeYourself();
}
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert(
role.type === WorkspaceRole.Admin
? 'Workspace.Administrators.Manage'
: 'Workspace.Users.Manage'
);
await this.models.workspaceUser.delete(workspaceId, userId);
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.workspaceService.sendReviewDeclinedNotification(
userId,
workspaceId,
me.id
);
} else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
});
}
this.event.emit('workspace.members.updated', {
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@CurrentUser() user: CurrentUser | undefined,
@Args('inviteId') inviteId: string,
@Args('workspaceId', { deprecationReason: 'never used', nullable: true })
_workspaceId: string,
@Args('sendAcceptMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendAcceptMail: boolean
) {
const role = await this.models.workspaceUser.getById(inviteId);
// invitation by email
if (role) {
if (user && user.id !== role.userId) {
throw new InvalidInvitation();
}
await this.acceptInvitationByEmail(role);
} else {
// invitation by link
if (!user) {
throw new AuthenticationRequired();
}
const invitation = await this.cache.get<{
workspaceId: string;
inviterUserId: string;
}>(`workspace:inviteLinkId:${inviteId}`);
if (!invitation) {
throw new InvalidInvitation();
}
const role = await this.models.workspaceUser.get(
invitation.workspaceId,
user.id
);
if (role) {
// if status is pending, should accept the invitation directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInvitationByEmail(role);
} else {
throw new AlreadyInSpace({ spaceId: invitation.workspaceId });
}
}
await this.acceptInvitationByLink(
user,
invitation.workspaceId,
invitation.inviterUserId
);
return true;
}
return true;
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('sendLeaveMail', {
nullable: true,
deprecationReason: 'no used anymore',
})
_sendLeaveMail?: boolean,
@Args('workspaceName', {
nullable: true,
deprecationReason: 'no longer used',
})
_workspaceName?: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
await this.models.workspaceUser.delete(workspaceId, user.id);
this.event.emit('workspace.members.leave', {
workspaceId,
userId: user.id,
});
this.event.emit('workspace.members.updated', {
workspaceId,
});
return true;
}
private async acceptInvitationByEmail(role: WorkspaceUserRole) {
await this.models.workspaceUser.setStatus(
role.workspaceId,
role.userId,
WorkspaceMemberStatus.Accepted
);
await this.workspaceService.sendInvitationAcceptedNotification(
role.inviterId ??
(await this.models.workspaceUser.getOwner(role.workspaceId)).id,
role.id
);
}
private async acceptInvitationByLink(
user: CurrentUser,
workspaceId: string,
inviterId: string
) {
let inviter = await this.models.user.getPublicUser(inviterId);
if (!inviter) {
inviter = await this.models.workspaceUser.getOwner(workspaceId);
}
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.UnderReview,
source: WorkspaceMemberSource.Link,
inviterId: inviter.id,
}
);
await this.workspaceService.sendReviewRequestNotification(role.id);
return;
}
}
@@ -1,292 +0,0 @@
import { Logger } from '@nestjs/common';
import {
Args,
Mutation,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import { nanoid } from 'nanoid';
import {
ActionForbiddenOnNonTeamWorkspace,
Cache,
EventBus,
MemberNotFoundInSpace,
RequestMutex,
TooManyRequest,
URLHelper,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser } from '../../auth';
import { AccessController, WorkspaceRole } from '../../permission';
import { QuotaService } from '../../quota';
import {
InviteLink,
InviteResult,
WorkspaceInviteLinkExpireTime,
WorkspaceType,
} from '../types';
import { WorkspaceService } from './service';
/**
* Workspace team resolver
* Public apis rate limit: 10 req/m
* Other rate limit: 120 req/m
*/
@Resolver(() => WorkspaceType)
export class TeamWorkspaceResolver {
private readonly logger = new Logger(TeamWorkspaceResolver.name);
constructor(
private readonly cache: Cache,
private readonly event: EventBus,
private readonly url: URLHelper,
private readonly ac: AccessController,
private readonly models: Models,
private readonly quota: QuotaService,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService
) {}
@ResolveField(() => Boolean, {
name: 'team',
description: 'if workspace is team workspace',
complexity: 2,
})
team(@Parent() workspace: WorkspaceType) {
return this.workspaceService.isTeamWorkspace(workspace.id);
}
@Mutation(() => [InviteResult])
async inviteBatch(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args({ name: 'emails', type: () => [String] }) emails: string[],
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
if (emails.length > 512) {
throw new TooManyRequest();
}
// lock to prevent concurrent invite
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
const quota = await this.quota.getWorkspaceSeatQuota(workspaceId);
const results = [];
for (const [idx, email] of emails.entries()) {
const ret: InviteResult = { email, sentSuccess: false, inviteId: null };
try {
let target = await this.models.user.getUserByEmail(email);
if (target) {
const originRecord = await this.models.workspaceUser.get(
workspaceId,
target.id
);
// only invite if the user is not already in the workspace
if (originRecord) continue;
} else {
target = await this.models.user.create({
email,
registered: false,
});
}
const needMoreSeat = quota.memberCount + idx + 1 > quota.memberLimit;
const role = await this.models.workspaceUser.set(
workspaceId,
target.id,
WorkspaceRole.Collaborator,
needMoreSeat
? WorkspaceMemberStatus.NeedMoreSeat
: WorkspaceMemberStatus.Pending
);
ret.inviteId = role.id;
// NOTE: we always send email even seat not enough
// because at this moment we cannot know whether the seat increase charge was successful
// after user click the invite link, we can check again and reject if charge failed
await this.workspaceService.sendInvitationNotification(
user.id,
ret.inviteId
);
ret.sentSuccess = true;
} catch (e) {
this.logger.error('failed to invite user', e);
}
results.push(ret);
}
const memberCount = quota.memberCount + results.length;
if (memberCount > quota.memberLimit) {
this.event.emit('workspace.members.updated', {
workspaceId,
count: memberCount,
});
}
return results;
}
@ResolveField(() => InviteLink, {
description: 'invite link for workspace',
nullable: true,
})
async inviteLink(
@Parent() workspace: WorkspaceType,
@CurrentUser() user: CurrentUser
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspace.id}`;
const id = await this.cache.get<{ inviteId: string }>(cacheId);
if (id) {
const expireTime = await this.cache.ttl(cacheId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${id.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
return null;
}
@Mutation(() => InviteLink)
async createInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('expireTime', { type: () => WorkspaceInviteLinkExpireTime })
expireTime: WorkspaceInviteLinkExpireTime
): Promise<InviteLink> {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheWorkspaceId = `workspace:inviteLink:${workspaceId}`;
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
if (typeof invite?.inviteId === 'string') {
const expireTime = await this.cache.ttl(cacheWorkspaceId);
if (Number.isSafeInteger(expireTime)) {
return {
link: this.url.link(`/invite/${invite.inviteId}`),
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
};
}
}
const inviteId = nanoid();
const cacheInviteId = `workspace:inviteLinkId:${inviteId}`;
await this.cache.set(cacheWorkspaceId, { inviteId }, { ttl: expireTime });
await this.cache.set(
cacheInviteId,
{ workspaceId, inviterUserId: user.id },
{ ttl: expireTime }
);
return {
link: this.url.link(`/invite/${inviteId}`),
expireTime: new Date(Date.now() + expireTime),
};
}
@Mutation(() => Boolean)
async revokeInviteLink(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const cacheId = `workspace:inviteLink:${workspaceId}`;
return await this.cache.delete(cacheId);
}
@Mutation(() => Boolean)
async approveMember(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (role) {
if (role.status === WorkspaceMemberStatus.UnderReview) {
const result = await this.models.workspaceUser.setStatus(
workspaceId,
userId,
WorkspaceMemberStatus.Accepted
);
await this.workspaceService.sendReviewApprovedNotification(
result.id,
me.id
);
}
return true;
} else {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
}
@Mutation(() => Boolean)
async grantMember(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string,
@Args('permission', { type: () => WorkspaceRole }) newRole: WorkspaceRole
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert(
newRole === WorkspaceRole.Owner
? 'Workspace.TransferOwner'
: 'Workspace.Users.Manage'
);
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
if (newRole === WorkspaceRole.Owner) {
await this.models.workspaceUser.setOwner(workspaceId, userId);
} else {
// non-team workspace can only transfer ownership, but no detailed permission control
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
if (!isTeam) {
throw new ActionForbiddenOnNonTeamWorkspace();
}
await this.models.workspaceUser.set(workspaceId, userId, newRole);
}
return true;
}
}
@@ -1,7 +1,6 @@
import {
Args,
Field,
Int,
Mutation,
ObjectType,
Parent,
@@ -9,33 +8,17 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import type { FileUpload } from '../../../base';
import {
AFFiNELogger,
AlreadyInSpace,
Cache,
CanNotRevokeYourself,
DocNotFound,
EventBus,
MemberNotFoundInSpace,
MemberQuotaExceeded,
OwnerCanNotLeaveWorkspace,
QueryTooLong,
registerObjectType,
RequestMutex,
SpaceAccessDenied,
SpaceNotFound,
Throttle,
TooManyRequest,
UserFriendlyError,
UserNotFound,
} from '../../../base';
import { Models } from '../../../models';
import { CurrentUser, Public } from '../../auth';
import { type Editor } from '../../doc';
import { CurrentUser } from '../../auth';
import {
AccessController,
WORKSPACE_ACTIONS,
@@ -43,14 +26,8 @@ import {
WorkspaceRole,
} from '../../permission';
import { QuotaService, WorkspaceQuotaType } from '../../quota';
import { UserType } from '../../user';
import {
InvitationType,
InviteUserType,
UpdateWorkspaceInput,
WorkspaceType,
} from '../types';
import { WorkspaceService } from './service';
import { WorkspaceService } from '../service';
import { UpdateWorkspaceInput, WorkspaceType } from '../types';
export type DotToUnderline<T extends string> =
T extends `${infer Prefix}.${infer Suffix}`
@@ -68,30 +45,6 @@ export function mapPermissionsToGraphqlPermissions<A extends string>(
) as Record<DotToUnderline<A>, boolean>;
}
@ObjectType()
export class EditorType implements Partial<Editor> {
@Field()
name!: string;
@Field(() => String, { nullable: true })
avatarUrl!: string | null;
}
@ObjectType()
class WorkspacePageMeta {
@Field(() => Date)
createdAt!: Date;
@Field(() => Date)
updatedAt!: Date;
@Field(() => EditorType, { nullable: true })
createdBy!: EditorType | null;
@Field(() => EditorType, { nullable: true })
updatedBy!: EditorType | null;
}
const WorkspacePermissions = registerObjectType<
Record<DotToUnderline<WorkspaceAction>, boolean>
>(
@@ -126,18 +79,32 @@ export class WorkspaceRolePermissions {
@Resolver(() => WorkspaceType)
export class WorkspaceResolver {
constructor(
private readonly cache: Cache,
private readonly ac: AccessController,
private readonly quota: QuotaService,
private readonly models: Models,
private readonly event: EventBus,
private readonly mutex: RequestMutex,
private readonly workspaceService: WorkspaceService,
private readonly logger: AFFiNELogger
) {
logger.setContext(WorkspaceResolver.name);
}
@ResolveField(() => Boolean, {
description: 'is current workspace initialized',
complexity: 2,
})
async initialized(@Parent() workspace: WorkspaceType) {
return this.models.doc.exists(workspace.id, workspace.id);
}
@ResolveField(() => Boolean, {
name: 'team',
description: 'if workspace is team workspace',
complexity: 2,
})
team(@Parent() workspace: WorkspaceType) {
return this.workspaceService.isTeamWorkspace(workspace.id);
}
@ResolveField(() => WorkspaceRole, {
description: 'Role of current signed in user in workspace',
complexity: 2,
@@ -174,100 +141,6 @@ export class WorkspaceResolver {
return mapPermissionsToGraphqlPermissions(permissions);
}
@ResolveField(() => Int, {
description: 'member count of workspace',
complexity: 2,
})
memberCount(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.count(workspace.id);
}
@ResolveField(() => Boolean, {
description: 'is current workspace initialized',
complexity: 2,
})
async initialized(@Parent() workspace: WorkspaceType) {
return this.models.doc.exists(workspace.id, workspace.id);
}
@ResolveField(() => UserType, {
description: 'Owner of workspace',
complexity: 2,
})
async owner(@Parent() workspace: WorkspaceType) {
return this.models.workspaceUser.getOwner(workspace.id);
}
@ResolveField(() => [InviteUserType], {
description: 'Members of workspace',
complexity: 2,
})
async members(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('skip', { type: () => Int, nullable: true }) skip?: number,
@Args('take', { type: () => Int, nullable: true }) take?: number,
@Args('query', { type: () => String, nullable: true }) query?: string
) {
await this.ac
.user(user.id)
.workspace(workspace.id)
.assert('Workspace.Users.Read');
if (query) {
if (query.length > 255) {
throw new QueryTooLong({ max: 255 });
}
const list = await this.models.workspaceUser.search(workspace.id, query, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
} else {
const [list] = await this.models.workspaceUser.paginate(workspace.id, {
offset: skip ?? 0,
first: take ?? 8,
});
return list.map(({ id, accepted, status, type, user }) => ({
...user,
permission: type,
inviteId: id,
accepted,
status,
}));
}
}
@ResolveField(() => WorkspacePageMeta, {
description: 'Cloud page metadata of workspace',
complexity: 2,
})
async pageMeta(
@Parent() workspace: WorkspaceType,
@Args('pageId') pageId: string
) {
const metadata = await this.models.doc.getAuthors(workspace.id, pageId);
if (!metadata) {
throw new DocNotFound({ spaceId: workspace.id, docId: pageId });
}
return {
createdAt: metadata.createdAt,
updatedAt: metadata.updatedAt,
createdBy: metadata.createdByUser || null,
updatedBy: metadata.updatedByUser || null,
};
}
@ResolveField(() => WorkspaceQuotaType, {
name: 'quota',
description: 'quota of workspace',
@@ -442,274 +315,4 @@ export class WorkspaceResolver {
return true;
}
@Mutation(() => String)
async invite(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('email') email: string,
@Args('sendInviteMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendInviteMail: boolean,
@Args('permission', {
type: () => WorkspaceRole,
nullable: true,
deprecationReason: 'never used',
})
_permission?: WorkspaceRole
) {
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert('Workspace.Users.Manage');
try {
// lock to prevent concurrent invite and grant
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
// member limit check
await this.quota.checkSeat(workspaceId);
let user = await this.models.user.getUserByEmail(email);
if (user) {
const role = await this.models.workspaceUser.get(workspaceId, user.id);
// only invite if the user is not already in the workspace
if (role) return role.id;
} else {
user = await this.models.user.create({
email,
registered: false,
});
}
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator
);
await this.workspaceService.sendInvitationNotification(me.id, role.id);
return role.id;
} catch (e) {
// pass through user friendly error
if (e instanceof UserFriendlyError) {
throw e;
}
this.logger.error('failed to invite user', e);
throw new TooManyRequest();
}
}
@Throttle('strict')
@Public()
@Query(() => InvitationType, {
description: 'send workspace invitation',
})
async getInviteInfo(
@CurrentUser() user: UserType | undefined,
@Args('inviteId') inviteId: string
): Promise<InvitationType> {
const { workspaceId, inviteeUserId, isLink } =
await this.workspaceService.getInviteInfo(inviteId);
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
const inviteeId = inviteeUserId || user?.id;
if (!inviteeId) throw new UserNotFound();
const invitee = await this.models.user.getWorkspaceUser(inviteeId);
if (!invitee) throw new UserNotFound();
let status: WorkspaceMemberStatus | undefined;
if (isLink) {
const invitation = await this.models.workspaceUser.get(
workspaceId,
inviteeId
);
status = invitation?.status;
} else {
const invitation = await this.models.workspaceUser.getById(inviteId);
status = invitation?.status;
}
return { workspace, user: owner, invitee, status };
}
@Mutation(() => Boolean)
async revoke(
@CurrentUser() me: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('userId') userId: string
) {
if (userId === me.id) {
throw new CanNotRevokeYourself();
}
const role = await this.models.workspaceUser.get(workspaceId, userId);
if (!role) {
throw new MemberNotFoundInSpace({ spaceId: workspaceId });
}
await this.ac
.user(me.id)
.workspace(workspaceId)
.assert(
role.type === WorkspaceRole.Admin
? 'Workspace.Administrators.Manage'
: 'Workspace.Users.Manage'
);
await this.models.workspaceUser.delete(workspaceId, userId);
const count = await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
if (role.status === WorkspaceMemberStatus.UnderReview) {
await this.workspaceService.sendReviewDeclinedNotification(
userId,
workspaceId,
me.id
);
} else if (role.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.removed', {
userId,
workspaceId,
});
}
return true;
}
@Mutation(() => Boolean)
@Public()
async acceptInviteById(
@CurrentUser() user: CurrentUser | undefined,
@Args('workspaceId') workspaceId: string,
@Args('inviteId') inviteId: string,
@Args('sendAcceptMail', {
nullable: true,
deprecationReason: 'never used',
})
_sendAcceptMail: boolean
) {
const lockFlag = `invite:${workspaceId}`;
await using lock = await this.mutex.acquire(lockFlag);
if (!lock) {
throw new TooManyRequest();
}
if (user) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (role) {
throw new AlreadyInSpace({ spaceId: workspaceId });
}
// invite link
const invite = await this.cache.get<{ inviteId: string }>(
`workspace:inviteLink:${workspaceId}`
);
if (invite?.inviteId === inviteId) {
await this.acceptInviteByLink(user, workspaceId);
return true;
}
}
await this.acceptInviteByInviteId(inviteId);
return true;
}
private async acceptInviteByInviteId(inviteId: string) {
await this.models.workspaceUser.accept(inviteId);
await this.workspaceService.sendInvitationAcceptedNotification(inviteId);
}
private async acceptInviteByLink(user: CurrentUser, workspaceId: string) {
const seatAvailable = await this.quota.tryCheckSeat(workspaceId);
if (seatAvailable) {
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.UnderReview
);
// if status is pending, should accept the invite directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInviteByInviteId(role.id);
return;
}
await this.workspaceService.sendReviewRequestNotification(role.id);
return;
}
const isTeam = await this.workspaceService.isTeamWorkspace(workspaceId);
// only team workspace allow over limit
if (isTeam) {
const role = await this.models.workspaceUser.set(
workspaceId,
user.id,
WorkspaceRole.Collaborator,
WorkspaceMemberStatus.NeedMoreSeatAndReview
);
// if status is pending, should accept the invite directly
if (role.status === WorkspaceMemberStatus.Pending) {
await this.acceptInviteByInviteId(role.id);
return;
}
await this.workspaceService.sendReviewRequestNotification(role.id);
const memberCount = await this.models.workspaceUser.count(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count: memberCount,
});
return;
}
throw new MemberQuotaExceeded();
}
@Mutation(() => Boolean)
async leaveWorkspace(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('sendLeaveMail', { nullable: true }) sendLeaveMail?: boolean,
@Args('workspaceName', {
nullable: true,
deprecationReason: 'no longer used',
})
_workspaceName?: string
) {
const role = await this.models.workspaceUser.getActive(
workspaceId,
user.id
);
if (!role) {
throw new SpaceAccessDenied({ spaceId: workspaceId });
}
if (role.type === WorkspaceRole.Owner) {
throw new OwnerCanNotLeaveWorkspace();
}
await this.models.workspaceUser.delete(workspaceId, user.id);
if (sendLeaveMail) {
await this.workspaceService.sendLeaveEmail(workspaceId, user.id);
}
return true;
}
}
@@ -1,29 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { getStreamAsBuffer } from 'get-stream';
import {
Cache,
JobQueue,
NotFound,
OnEvent,
URLHelper,
UserNotFound,
} from '../../../base';
import { Cache, JobQueue, NotFound, URLHelper } from '../../base';
import {
DEFAULT_WORKSPACE_AVATAR,
DEFAULT_WORKSPACE_NAME,
Models,
} from '../../../models';
import { DocReader } from '../../doc';
import { Mailer } from '../../mail';
import { WorkspaceRole } from '../../permission';
import { WorkspaceBlobStorage } from '../../storage';
} from '../../models';
import { DocReader } from '../doc';
import { Mailer } from '../mail';
import { WorkspaceRole } from '../permission';
import { WorkspaceBlobStorage } from '../storage';
export type InviteInfo = {
isLink: boolean;
workspaceId: string;
inviterUserId?: string;
inviteeUserId?: string;
inviterUserId: string | null;
inviteeUserId: string | null;
};
@Injectable()
@@ -62,6 +55,7 @@ export class WorkspaceService {
isLink: false,
workspaceId: workspaceUser.workspaceId,
inviteeUserId: workspaceUser.userId,
inviterUserId: workspaceUser.inviterId,
};
}
@@ -87,26 +81,15 @@ export class WorkspaceService {
};
}
async sendInvitationAcceptedNotification(inviteId: string) {
const { workspaceId, inviterUserId, inviteeUserId } =
await this.getInviteInfo(inviteId);
const inviter = inviterUserId
? await this.models.user.getWorkspaceUser(inviterUserId)
: await this.models.workspaceUser.getOwner(workspaceId);
if (!inviter || !inviteeUserId) {
this.logger.warn(
`Inviter or invitee user not found for inviteId: ${inviteId}`
);
throw new UserNotFound();
}
async sendInvitationAcceptedNotification(
inviterId: string,
inviteId: string
) {
await this.queue.add('notification.sendInvitationAccepted', {
inviterId: inviter.id,
inviterId,
inviteId,
});
}
async sendInvitationNotification(inviterId: string, inviteId: string) {
await this.queue.add('notification.sendInvitation', {
inviterId,
@@ -116,7 +99,7 @@ export class WorkspaceService {
// ================ Team ================
async isTeamWorkspace(workspaceId: string) {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
return this.models.workspace.isTeamWorkspace(workspaceId);
}
async sendTeamWorkspaceUpgradedEmail(workspaceId: string) {
@@ -269,27 +252,21 @@ export class WorkspaceService {
});
}
@OnEvent('workspace.members.removed')
async onMemberRemoved({
userId,
workspaceId,
}: Events['workspace.members.removed']) {
const user = await this.models.user.get(userId);
if (!user) {
this.logger.warn(
`User not found for seeding member removed email: ${userId}`
);
return;
async allocateSeats(workspaceId: string, quantity: number) {
const pendings = await this.models.workspaceUser.allocateSeats(
workspaceId,
quantity
);
const owner = await this.models.workspaceUser.getOwner(workspaceId);
for (const member of pendings) {
try {
await this.queue.add('notification.sendInvitation', {
inviterId: member.inviterId ?? owner.id,
inviteId: member.id,
});
} catch (e) {
this.logger.error('Failed to send invitation notification', e);
}
}
await this.mailer.send({
name: 'MemberRemoved',
to: user.email,
props: {
workspace: {
$$workspaceId: workspaceId,
},
},
});
}
}
@@ -9,7 +9,7 @@ import {
registerEnumType,
} from '@nestjs/graphql';
import { WorkspaceMemberStatus } from '@prisma/client';
import { SafeIntResolver } from 'graphql-scalars';
import { GraphQLJSONObject, SafeIntResolver } from 'graphql-scalars';
import { DocRole, WorkspaceRole } from '../permission';
import { UserType, WorkspaceUserType } from '../user/types';
@@ -56,12 +56,6 @@ export class InviteUserType extends OmitType(
@Field({ description: 'Invite id' })
inviteId!: string;
@Field({
description: 'User accepted',
deprecationReason: 'Use `status` instead',
})
accepted!: boolean;
@Field(() => WorkspaceMemberStatus, {
description: 'Member invite status in workspace',
})
@@ -161,10 +155,23 @@ export class InviteResult {
nullable: true,
description: 'Invite id, null if invite record create failed',
})
inviteId!: string | null;
inviteId?: string;
@Field(() => Boolean, { description: 'Invite email sent success' })
sentSuccess!: boolean;
/**
* @deprecated
*/
@Field(() => Boolean, {
description: 'Invite email sent success',
deprecationReason: 'Notification will be sent asynchronously',
defaultValue: true,
})
sentSuccess?: boolean;
@Field(() => GraphQLJSONObject, {
nullable: true,
description: 'Invite error',
})
error?: object;
}
const Day = 24 * 60 * 60 * 1000;