mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-06 03:49:56 +08:00
feat(server): delay subscription after invitation accepted or approved (#11992)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-53
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user