refactor(server): permission (#10449)

This commit is contained in:
liuyi
2025-03-05 15:57:00 +08:00
committed by GitHub
parent bf7b1646b3
commit 61162c59fc
61 changed files with 2680 additions and 3562 deletions

View File

@@ -12,3 +12,8 @@ export interface Doc {
}
export type DocEditor = Pick<User, 'id' | 'name' | 'avatarUrl'>;
export enum PublicDocMode {
Page,
Edgeless,
}

View File

@@ -1,3 +1,3 @@
export * from './doc';
export * from './feature';
export * from './page';
export * from './role';

View File

@@ -1,4 +0,0 @@
export enum PublicPageMode {
Page,
Edgeless,
}

View File

@@ -0,0 +1,14 @@
export enum DocRole {
External = 0,
Reader = 10,
Editor = 20,
Manager = 30,
Owner = 99,
}
export enum WorkspaceRole {
External = -99,
Collaborator = 1,
Admin = 10,
Owner = 99,
}

View File

@@ -0,0 +1,203 @@
import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceDocUserRole } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
import { BaseModel } from './base';
import { DocRole } from './common';
@Injectable()
export class DocUserModel extends BaseModel {
/**
* Set or update the [Owner] of a doc.
* The old [Owner] will be changed to [Manager] if there is already an [Owner].
*/
@Transactional()
async setOwner(workspaceId: string, docId: string, userId: string) {
const oldOwner = await this.db.workspaceDocUserRole.findFirst({
where: {
workspaceId,
docId,
type: DocRole.Owner,
},
});
if (oldOwner) {
await this.db.workspaceDocUserRole.update({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId: oldOwner.userId,
},
},
data: {
type: DocRole.Manager,
},
});
}
await this.db.workspaceDocUserRole.upsert({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
update: {
type: DocRole.Owner,
},
create: {
workspaceId,
docId,
userId,
type: DocRole.Owner,
},
});
if (oldOwner) {
this.logger.log(
`Transfer doc owner of [${workspaceId}/${docId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
this.logger.log(
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
);
}
}
/**
* Set or update the Role of a user in a doc.
*
* NOTE: do not use this method to set the [Owner] of a doc. Use {@link setOwner} instead.
*/
@Transactional()
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
// internal misuse, throw directly
assert(role !== DocRole.Owner, 'Cannot set Owner role of a doc to a user.');
const oldRole = await this.get(workspaceId, docId, userId);
if (oldRole && oldRole.type === role) {
return oldRole;
}
const newRole = await this.db.workspaceDocUserRole.upsert({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
update: {
type: role,
},
create: {
workspaceId,
docId,
userId,
type: role,
},
});
return newRole;
}
async batchSetUserRoles(
workspaceId: string,
docId: string,
userIds: string[],
role: DocRole
) {
if (userIds.length === 0) {
return 0;
}
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
const result = await this.db.workspaceDocUserRole.createMany({
skipDuplicates: true,
data: userIds.map(userId => ({
workspaceId,
docId,
userId,
type: role,
})),
});
return result.count;
}
async delete(workspaceId: string, docId: string, userId: string) {
await this.db.workspaceDocUserRole.deleteMany({
where: {
workspaceId,
docId,
userId,
},
});
}
async getOwner(workspaceId: string, docId: string) {
return await this.db.workspaceDocUserRole.findFirst({
where: {
workspaceId,
docId,
type: DocRole.Owner,
},
});
}
async get(workspaceId: string, docId: string, userId: string) {
return await this.db.workspaceDocUserRole.findUnique({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
});
}
count(workspaceId: string, docId: string) {
return this.db.workspaceDocUserRole.count({
where: {
workspaceId,
docId,
},
});
}
async paginate(
workspaceId: string,
docId: string,
pagination: PaginationInput
): Promise<[WorkspaceDocUserRole[], number]> {
return await Promise.all([
this.db.workspaceDocUserRole.findMany({
where: {
workspaceId,
docId,
createdAt: pagination.after
? {
gte: pagination.after,
}
: undefined,
},
orderBy: {
createdAt: 'asc',
},
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
}),
this.count(workspaceId, docId),
]);
}
}

View File

@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
import { ApplyType } from '../base';
import { DocModel } from './doc';
import { DocUserModel } from './doc-user';
import { FeatureModel } from './feature';
import { PageModel } from './page';
import { MODELS_SYMBOL } from './provider';
@@ -18,6 +19,7 @@ import { UserFeatureModel } from './user-feature';
import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceUserModel } from './workspace-user';
const MODELS = {
user: UserModel,
@@ -30,6 +32,8 @@ const MODELS = {
workspaceFeature: WorkspaceFeatureModel,
doc: DocModel,
userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
};
type ModelsType = {
@@ -83,6 +87,7 @@ export class ModelsModule {}
export * from './common';
export * from './doc';
export * from './doc-user';
export * from './feature';
export * from './page';
export * from './session';
@@ -92,3 +97,4 @@ export * from './user-feature';
export * from './verification-token';
export * from './workspace';
export * from './workspace-feature';
export * from './workspace-user';

View File

@@ -1,16 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import {
type WorkspaceDoc as Page,
type WorkspaceDocUserPermission as PageUserPermission,
} from '@prisma/client';
import { type WorkspaceDoc as Page } from '@prisma/client';
import { WorkspaceRole } from '../core/permission';
import { BaseModel } from './base';
import { PublicPageMode } from './common';
import { PublicDocMode } from './common';
export type { Page };
export type UpdatePageInput = {
mode?: PublicPageMode;
mode?: PublicDocMode;
public?: boolean;
};
@@ -82,118 +77,4 @@ export class PageModel extends BaseModel {
}
// #endregion
// #region page member and permission
/**
* Grant the page member with the given permission.
*/
@Transactional()
async grantMember(
workspaceId: string,
docId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
): Promise<PageUserPermission> {
let data = await this.db.workspaceDocUserPermission.findUnique({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
});
// If the user is already accepted and the new permission is owner, we need to revoke old owner
if (!data || data.type !== permission) {
if (data) {
// Update the permission
data = await this.db.workspaceDocUserPermission.update({
where: {
workspaceId_docId_userId: {
workspaceId,
docId,
userId,
},
},
data: { type: permission },
});
} else {
// Create a new permission
data = await this.db.workspaceDocUserPermission.create({
data: {
workspaceId,
docId,
userId,
type: permission,
},
});
}
// If the new permission is owner, we need to revoke old owner
if (permission === WorkspaceRole.Owner) {
await this.db.workspaceDocUserPermission.updateMany({
where: {
workspaceId,
docId,
type: WorkspaceRole.Owner,
userId: { not: userId },
},
data: { type: WorkspaceRole.Admin },
});
this.logger.log(
`Change owner of workspace ${workspaceId} doc ${docId} to user ${userId}`
);
}
return data;
}
// nothing to do
return data;
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
* Default to read permission.
*/
async isMember(
workspaceId: string,
docId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const count = await this.db.workspaceDocUserPermission.count({
where: {
workspaceId,
docId,
userId,
type: {
gte: permission,
},
},
});
return count > 0;
}
/**
* Delete a page member
* Except the owner, the owner can't be deleted.
*/
async deleteMember(workspaceId: string, docId: string, userId: string) {
const { count } = await this.db.workspaceDocUserPermission.deleteMany({
where: {
workspaceId,
docId,
userId,
type: {
// We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading
not: WorkspaceRole.Owner,
},
},
});
return count;
}
// #endregion
}

View File

@@ -10,6 +10,7 @@ import {
WrongSignInMethod,
} from '../base';
import { BaseModel } from './base';
import { WorkspaceRole } from './common';
import type { Workspace } from './workspace';
const publicUserSelect = {
@@ -215,12 +216,17 @@ export class UserModel extends BaseModel {
}
async delete(id: string) {
const ownedWorkspaceIds = await this.models.workspace.findOwnedIds(id);
const ownedWorkspaces = await this.models.workspaceUser.getUserActiveRoles(
id,
{
role: WorkspaceRole.Owner,
}
);
const user = await this.db.user.delete({ where: { id } });
this.event.emit('user.deleted', {
...user,
ownedWorkspaces: ownedWorkspaceIds,
ownedWorkspaces: ownedWorkspaces.map(r => r.workspaceId),
});
return user;

View File

@@ -0,0 +1,385 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceMemberStatus } from '@prisma/client';
import { groupBy } from 'lodash-es';
import { EventBus, PaginationInput } from '../base';
import { BaseModel } from './base';
import { WorkspaceRole } from './common';
export { WorkspaceMemberStatus };
declare global {
interface Events {
'workspace.owner.changed': {
workspaceId: string;
from: string;
to: string;
};
'workspace.members.roleChanged': {
userId: string;
workspaceId: string;
role: WorkspaceRole;
};
// below are business events, should be declare somewhere else
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.reviewRequested': {
inviteId: string;
};
'workspace.members.requestApproved': {
inviteId: string;
};
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.removed': {
userId: string;
workspaceId: string;
};
'workspace.members.leave': {
workspaceId: string;
user: {
id: string;
email: string;
};
};
}
}
@Injectable()
export class WorkspaceUserModel extends BaseModel {
constructor(private readonly event: EventBus) {
super();
}
/**
* Set or update the [Owner] of a workspace.
* The old [Owner] will be changed to [Admin] if there is already an [Owner].
*/
@Transactional()
async setOwner(workspaceId: string, userId: string) {
const oldOwner = await this.db.workspaceUserRole.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
});
// If there is already an owner, we need to change the old owner to admin
if (oldOwner) {
await this.db.workspaceUserRole.update({
where: {
id: oldOwner.id,
},
data: {
type: WorkspaceRole.Admin,
},
});
}
await this.db.workspaceUserRole.upsert({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
update: {
type: WorkspaceRole.Owner,
},
create: {
workspaceId,
userId,
type: WorkspaceRole.Owner,
status: WorkspaceMemberStatus.Accepted,
},
});
if (oldOwner) {
this.event.emit('workspace.owner.changed', {
workspaceId,
from: oldOwner.userId,
to: userId,
});
this.logger.log(
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]`
);
} else {
this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`);
}
}
/**
* Set or update the Role of a user in a workspace.
*
* NOTE: do not use this method to set the [Owner] of a workspace. Use {@link setOwner} instead.
*/
@Transactional()
async set(
workspaceId: string,
userId: string,
role: WorkspaceRole,
defaultStatus: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
) {
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
const oldRole = await this.get(workspaceId, userId);
if (oldRole) {
if (oldRole.type === role) {
return oldRole;
}
const newRole = await this.db.workspaceUserRole.update({
where: { id: oldRole.id },
data: { type: role },
});
if (oldRole.status === WorkspaceMemberStatus.Accepted) {
this.event.emit('workspace.members.roleChanged', {
userId,
workspaceId,
role: newRole.type,
});
}
return newRole;
} else {
return await this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: role,
status: defaultStatus,
},
});
}
}
async setStatus(
workspaceId: string,
userId: string,
status: WorkspaceMemberStatus
) {
return await this.db.workspaceUserRole.update({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
data: {
status,
},
});
}
async accept(id: string) {
await this.db.workspaceUserRole.update({
where: { id },
data: { status: WorkspaceMemberStatus.Accepted },
});
}
async delete(workspaceId: string, userId: string) {
await this.db.workspaceUserRole.deleteMany({
where: {
workspaceId,
userId,
},
});
}
async get(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
});
}
async getById(id: string) {
return await this.db.workspaceUserRole.findUnique({
where: { id },
});
}
/**
* Get the **accepted** Role of a user in a workspace.
*/
async getActive(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: { workspaceId, userId },
status: WorkspaceMemberStatus.Accepted,
},
});
}
async getOwner(workspaceId: string) {
const role = await this.db.workspaceUserRole.findFirst({
include: {
user: true,
},
where: {
workspaceId,
type: WorkspaceRole.Owner,
},
});
if (!role) {
throw new Error('Workspace owner not found');
}
return role.user;
}
async getAdmins(workspaceId: string) {
const list = await this.db.workspaceUserRole.findMany({
include: {
user: true,
},
where: {
workspaceId,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
});
return list.map(l => l.user);
}
async count(workspaceId: string) {
return this.db.workspaceUserRole.count({
where: {
workspaceId,
},
});
}
async getUserActiveRoles(
userId: string,
filter: { role?: WorkspaceRole } = {}
) {
return await this.db.workspaceUserRole.findMany({
where: {
userId,
status: WorkspaceMemberStatus.Accepted,
type: filter.role,
},
});
}
async paginate(workspaceId: string, pagination: PaginationInput) {
return await Promise.all([
this.db.workspaceUserRole.findMany({
include: {
user: true,
},
where: {
workspaceId,
createdAt: pagination.after
? {
gte: pagination.after,
}
: undefined,
},
orderBy: {
createdAt: 'asc',
},
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
}),
this.count(workspaceId),
]);
}
async search(
workspaceId: string,
query: string,
pagination: PaginationInput
) {
return await this.db.workspaceUserRole.findMany({
include: { user: true },
where: {
workspaceId,
status: WorkspaceMemberStatus.Accepted,
user: {
OR: [
{
email: {
contains: query,
},
},
{
name: {
contains: query,
},
},
],
},
},
orderBy: { createdAt: 'asc' },
take: pagination.first,
skip: pagination.offset + (pagination.after ? 1 : 0),
});
}
@Transactional()
async refresh(workspaceId: string, memberLimit: number) {
const usedCount = await this.db.workspaceUserRole.count({
where: { workspaceId, status: WorkspaceMemberStatus.Accepted },
});
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
const members = await this.db.workspaceUserRole.findMany({
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
orderBy: { createdAt: 'asc' },
});
const needChange = members.slice(0, availableCount);
const { NeedMoreSeat, NeedMoreSeatAndReview } = groupBy(
needChange,
m => m.status
);
const toPendings = NeedMoreSeat ?? [];
if (toPendings.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviewUserIds = NeedMoreSeatAndReview ?? [];
if (toUnderReviewUserIds.length > 0) {
await this.db.workspaceUserRole.updateMany({
where: { id: { in: toUnderReviewUserIds.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
}
}

View File

@@ -1,78 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import {
type Workspace,
WorkspaceMemberStatus,
type WorkspaceUserPermission,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
import { type Workspace } from '@prisma/client';
import { EventBus } from '../base';
import { WorkspaceRole } from '../core/permission';
import { DocIsNotPublic, EventBus } from '../base';
import { BaseModel } from './base';
import { DocRole, PublicDocMode } from './common';
declare global {
interface Events {
'workspace.members.reviewRequested': { inviteId: string };
'workspace.members.requestDeclined': {
userId: string;
workspaceId: string;
};
'workspace.members.requestApproved': { inviteId: string };
'workspace.members.roleChanged': {
userId: string;
workspaceId: string;
permission: number;
};
'workspace.members.ownershipTransferred': {
from: string;
to: string;
workspaceId: string;
};
'workspace.members.updated': {
workspaceId: string;
count: number;
};
'workspace.members.leave': {
user: {
id: string;
email: string;
};
workspaceId: string;
};
'workspace.members.removed': {
workspaceId: string;
userId: string;
};
'workspace.deleted': {
id: string;
};
'workspace.blob.delete': {
workspaceId: string;
key: string;
};
'workspace.blob.sync': {
workspaceId: string;
key: string;
};
}
}
export { WorkspaceMemberStatus };
export type { Workspace };
export type UpdateWorkspaceInput = Pick<
Partial<Workspace>,
'public' | 'enableAi' | 'enableUrlPreview'
>;
export interface FindWorkspaceMembersOptions {
skip?: number;
/**
* Default to `8`
*/
take?: number;
}
@Injectable()
export class WorkspaceModel extends BaseModel {
constructor(private readonly event: EventBus) {
@@ -80,25 +27,16 @@ export class WorkspaceModel extends BaseModel {
}
// #region workspace
/**
* Create a new workspace for the user, default to private.
*/
@Transactional()
async create(userId: string) {
const workspace = await this.db.workspace.create({
data: {
public: false,
permissions: {
create: {
type: WorkspaceRole.Owner,
userId: userId,
accepted: true,
status: WorkspaceMemberStatus.Accepted,
},
},
},
data: { public: false },
});
this.logger.log(`Created workspace ${workspace.id} for user ${userId}`);
this.logger.log(`Workspace created with id ${workspace.id}`);
await this.models.workspaceUser.setOwner(workspace.id, userId);
return workspace;
}
@@ -106,7 +44,7 @@ export class WorkspaceModel extends BaseModel {
* Update the workspace with the given data.
*/
async update(workspaceId: string, data: UpdateWorkspaceInput) {
await this.db.workspace.update({
const workspace = await this.db.workspace.update({
where: {
id: workspaceId,
},
@@ -115,6 +53,7 @@ export class WorkspaceModel extends BaseModel {
this.logger.log(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
);
return workspace;
}
async get(workspaceId: string) {
@@ -125,422 +64,104 @@ export class WorkspaceModel extends BaseModel {
});
}
async findMany(ids: string[]) {
return await this.db.workspace.findMany({
where: {
id: { in: ids },
},
});
}
async delete(workspaceId: string) {
await this.db.workspace.deleteMany({
const rawResult = await this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
});
this.logger.log(`Deleted workspace ${workspaceId}`);
if (rawResult.count > 0) {
this.event.emit('workspace.deleted', { id: workspaceId });
this.logger.log(`Workspace [${workspaceId}] deleted`);
}
}
/**
* Find the workspace ids that the user is a member of owner.
*/
async findOwnedIds(userId: string) {
const rows = await this.db.workspaceUserPermission.findMany({
where: {
userId,
type: WorkspaceRole.Owner,
OR: this.acceptedCondition,
},
select: {
workspaceId: true,
},
});
return rows.map(row => row.workspaceId);
async allowUrlPreview(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableUrlPreview ?? false;
}
/**
* Find the accessible workspaces for the user.
*/
async findAccessibleWorkspaces(userId: string) {
return await this.db.workspaceUserPermission.findMany({
where: {
userId,
OR: this.acceptedCondition,
},
include: {
workspace: true,
},
});
}
// #endregion
// #region workspace member and permission
/**
* Grant the workspace member with the given permission and status.
*/
@Transactional()
async grantMember(
workspaceId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Pending
): Promise<WorkspaceUserPermission> {
const data = await this.db.workspaceUserPermission.findUnique({
// #region doc
async getDoc(workspaceId: string, docId: string) {
return await this.db.workspaceDoc.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
});
if (!data) {
// Create a new permission
const created = await this.db.workspaceUserPermission.create({
data: {
workspaceId,
userId,
type: permission,
status:
permission === WorkspaceRole.Owner
? WorkspaceMemberStatus.Accepted
: status,
},
});
this.logger.log(
`Granted workspace ${workspaceId} member ${userId} with permission ${WorkspaceRole[permission]}`
);
await this.notifyMembersUpdated(workspaceId);
return created;
}
// If the user is already accepted and the new permission is owner, we need to revoke old owner
if (data.status === WorkspaceMemberStatus.Accepted || data.accepted) {
const updated = await this.db.workspaceUserPermission.update({
where: {
workspaceId_userId: { workspaceId, userId },
},
data: { type: permission },
});
// If the new permission is owner, we need to revoke old owner
if (permission === WorkspaceRole.Owner) {
await this.db.workspaceUserPermission.updateMany({
where: {
workspaceId,
type: WorkspaceRole.Owner,
userId: { not: userId },
},
data: { type: WorkspaceRole.Admin },
});
this.logger.log(
`Change owner of workspace ${workspaceId} to ${userId}`
);
}
return updated;
}
// If the user is not accepted, we can update the status directly
const allowedStatus = this.getAllowedStatusSource(data.status);
if (allowedStatus.includes(status)) {
const updated = await this.db.workspaceUserPermission.update({
where: { workspaceId_userId: { workspaceId, userId } },
data: {
status,
// TODO(fengmk2): should we update the permission here?
// type: permission,
},
});
return updated;
}
// nothing to do
return data;
}
/**
* Get the workspace member invitation.
*/
async getMemberInvitation(invitationId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
id: invitationId,
workspaceId_docId: { workspaceId, docId },
},
});
}
/**
* Accept the workspace member invitation.
* @param status: the status to update to, default to `Accepted`. Can be `Accepted` or `UnderReview`.
*/
async acceptMemberInvitation(
invitationId: string,
async isPublicPage(workspaceId: string, docId: string) {
const doc = await this.getDoc(workspaceId, docId);
if (doc?.public) {
return true;
}
const workspace = await this.get(workspaceId);
return workspace?.public ?? false;
}
async publishDoc(
workspaceId: string,
status: WorkspaceMemberStatus = WorkspaceMemberStatus.Accepted
docId: string,
mode: PublicDocMode = PublicDocMode.Page
) {
const { count } = await this.db.workspaceUserPermission.updateMany({
where: {
id: invitationId,
workspaceId: workspaceId,
// TODO(fengmk2): should we check the status here?
AND: [{ accepted: false }, { status: WorkspaceMemberStatus.Pending }],
},
data: { accepted: true, status },
return await this.db.workspaceDoc.upsert({
where: { workspaceId_docId: { workspaceId, docId } },
update: { public: true, mode },
create: { workspaceId, docId, public: true, mode },
});
}
@Transactional()
async revokePublicDoc(workspaceId: string, docId: string) {
const doc = await this.getDoc(workspaceId, docId);
if (!doc?.public) {
throw new DocIsNotPublic();
}
return await this.db.workspaceDoc.update({
where: { workspaceId_docId: { workspaceId, docId } },
data: { public: false },
});
}
async hasPublicDoc(workspaceId: string) {
const count = await this.db.workspaceDoc.count({
where: {
workspaceId,
public: true,
},
});
return count > 0;
}
/**
* Get a workspace member in accepted status by workspace id and user id.
*/
async getMember(workspaceId: string, userId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
OR: this.acceptedCondition,
},
});
}
/**
* Get a workspace member in any status by workspace id and user id.
*/
async getMemberInAnyStatus(workspaceId: string, userId: string) {
return await this.db.workspaceUserPermission.findUnique({
where: {
workspaceId_userId: {
workspaceId,
userId,
},
},
});
}
/**
* Returns whether a given user is a member of a workspace and has the given or higher permission.
* Default to read permission.
*/
async isMember(
workspaceId: string,
userId: string,
permission: WorkspaceRole = WorkspaceRole.Collaborator
) {
const count = await this.db.workspaceUserPermission.count({
async getPublicDocs(workspaceId: string) {
return await this.db.workspaceDoc.findMany({
where: {
workspaceId,
userId,
OR: this.acceptedCondition,
type: {
gte: permission,
},
},
});
return count > 0;
}
/**
* Get the workspace owner.
*/
async getOwner(workspaceId: string) {
return await this.db.workspaceUserPermission.findFirst({
where: {
workspaceId,
type: WorkspaceRole.Owner,
OR: this.acceptedCondition,
},
include: {
user: true,
public: true,
},
});
}
/**
* Find the workspace admins.
*/
async findAdmins(workspaceId: string) {
return await this.db.workspaceUserPermission.findMany({
where: {
workspaceId,
type: WorkspaceRole.Admin,
OR: this.acceptedCondition,
},
include: {
user: true,
},
async setDocDefaultRole(workspaceId: string, docId: string, role: DocRole) {
await this.db.workspaceDoc.upsert({
where: { workspaceId_docId: { workspaceId, docId } },
update: { defaultRole: role },
create: { workspaceId, docId, defaultRole: role },
});
}
/**
* Find the workspace members.
*/
async findMembers(
workspaceId: string,
options: FindWorkspaceMembersOptions = {}
) {
return await this.db.workspaceUserPermission.findMany({
where: {
workspaceId,
},
skip: options.skip,
take: options.take || 8,
orderBy: [{ createdAt: 'asc' }, { type: 'desc' }],
include: {
user: true,
},
});
}
/**
* Delete a workspace member by workspace id and user id.
* Except the owner, the owner can't be deleted.
*/
async deleteMember(workspaceId: string, userId: string) {
const member = await this.getMemberInAnyStatus(workspaceId, userId);
// We shouldn't revoke owner permission
// should auto deleted by workspace/user delete cascading
if (!member || member.type === WorkspaceRole.Owner) {
return false;
}
await this.db.workspaceUserPermission.deleteMany({
where: {
workspaceId,
userId,
},
});
this.logger.log(
`Deleted workspace member ${userId} from workspace ${workspaceId}`
);
await this.notifyMembersUpdated(workspaceId);
if (
member.status === WorkspaceMemberStatus.UnderReview ||
member.status === WorkspaceMemberStatus.NeedMoreSeatAndReview
) {
this.event.emit('workspace.members.requestDeclined', {
workspaceId,
userId,
});
}
return true;
}
private async notifyMembersUpdated(workspaceId: string) {
const count = await this.getMemberTotalCount(workspaceId);
this.event.emit('workspace.members.updated', {
workspaceId,
count,
});
}
/**
* Get the workspace member total count, including pending and accepted.
*/
async getMemberTotalCount(workspaceId: string) {
return await this.db.workspaceUserPermission.count({
where: {
workspaceId,
},
});
}
/**
* Get the workspace member used count, only count the accepted member
*/
async getMemberUsedCount(workspaceId: string) {
return await this.db.workspaceUserPermission.count({
where: {
workspaceId,
OR: this.acceptedCondition,
},
});
}
/**
* Refresh the workspace member seat status.
*/
@Transactional()
async refreshMemberSeatStatus(workspaceId: string, memberLimit: number) {
const usedCount = await this.getMemberUsedCount(workspaceId);
const availableCount = memberLimit - usedCount;
if (availableCount <= 0) {
return;
}
const members = await this.db.workspaceUserPermission.findMany({
select: { id: true, status: true },
where: {
workspaceId,
status: {
in: [
WorkspaceMemberStatus.NeedMoreSeat,
WorkspaceMemberStatus.NeedMoreSeatAndReview,
],
},
},
// find the oldest members first
orderBy: { createdAt: 'asc' },
});
const needChange = members.slice(0, availableCount);
const groups = groupBy(needChange, m => m.status);
const toPendings = groups.NeedMoreSeat;
if (toPendings) {
// NeedMoreSeat => Pending
await this.db.workspaceUserPermission.updateMany({
where: { id: { in: toPendings.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.Pending },
});
}
const toUnderReviews = groups.NeedMoreSeatAndReview;
if (toUnderReviews) {
// NeedMoreSeatAndReview => UnderReview
await this.db.workspaceUserPermission.updateMany({
where: { id: { in: toUnderReviews.map(m => m.id) } },
data: { status: WorkspaceMemberStatus.UnderReview },
});
}
}
/**
* Accepted condition for workspace member.
*/
private get acceptedCondition() {
return [
{
// keep compatibility with old data
accepted: true,
},
{
status: WorkspaceMemberStatus.Accepted,
},
];
}
/**
* NeedMoreSeat => Pending
*
* NeedMoreSeatAndReview => UnderReview
*
* Pending | UnderReview => Accepted
*/
private getAllowedStatusSource(
to: WorkspaceMemberStatus
): WorkspaceMemberStatus[] {
switch (to) {
case WorkspaceMemberStatus.NeedMoreSeat:
return [WorkspaceMemberStatus.Pending];
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return [WorkspaceMemberStatus.UnderReview];
case WorkspaceMemberStatus.Pending:
case WorkspaceMemberStatus.UnderReview: // need admin to review in team workspace
return [WorkspaceMemberStatus.Accepted];
default:
return [];
}
}
// #endregion
}