mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
feat(server): entitlement based model (#14996)
#### PR Dependency Tree * **PR #14996** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Admin mutations to grant/revoke commercial entitlements. * New Doc comment-update permission. * Realtime user/workspace quota-state endpoints and live-update rooms. * **Bug Fixes** * More accurate readable-doc filtering and permission evaluation. * **Refactor** * Workspace feature management moved to entitlement-based model; permission and quota pipelines redesigned. * Admin workspace UI now edits flags only (feature toggles removed). * **Tests** * Extensive new and updated tests for permissions, entitlements, quota, projection, and backfills. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/toeverything/AFFiNE/pull/14996?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -5,11 +5,21 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
WorkspaceUserRole,
|
||||
} from '@prisma/client';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
import { WorkspaceRole, workspaceUserSelect } from './common';
|
||||
import {
|
||||
allocateWorkspaceSeats,
|
||||
countChargedWorkspaceUsers,
|
||||
countWorkspaceUsers,
|
||||
findUserActiveWorkspaceRoles,
|
||||
hasSharedWorkspace,
|
||||
queryCompatRows,
|
||||
searchCompatRows,
|
||||
workspaceInvitationToCompat,
|
||||
workspaceMemberToCompat,
|
||||
} from './workspace-user-compat';
|
||||
|
||||
export { WorkspaceMemberStatus };
|
||||
|
||||
@@ -41,68 +51,50 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
*/
|
||||
@Transactional()
|
||||
async setOwner(workspaceId: string, userId: string) {
|
||||
const oldOwner = await this.db.workspaceUserRole.findFirst({
|
||||
const oldOwner = await this.db.workspaceMember.findFirst({
|
||||
include: {
|
||||
user: {
|
||||
select: workspaceUserSelect,
|
||||
},
|
||||
},
|
||||
where: {
|
||||
workspaceId,
|
||||
type: WorkspaceRole.Owner,
|
||||
role: 'owner',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
const fallbackRole = (await this.models.workspace.isTeamWorkspace(
|
||||
workspaceId
|
||||
))
|
||||
? WorkspaceRole.Admin
|
||||
: WorkspaceRole.Collaborator;
|
||||
|
||||
// If there is already an owner, we need to change the old owner to admin
|
||||
if (oldOwner) {
|
||||
const newOwnerOldRole = await this.db.workspaceUserRole.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await this.models.workspaceMember.setOwner(
|
||||
workspaceId,
|
||||
userId,
|
||||
fallbackRole
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!newOwnerOldRole ||
|
||||
newOwnerOldRole.status !== WorkspaceMemberStatus.Accepted
|
||||
error instanceof Error &&
|
||||
error.message === 'New workspace owner must be an active member.'
|
||||
) {
|
||||
throw new NewOwnerIsNotActiveMember();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fallbackRole = (await this.models.workspace.isTeamWorkspace(
|
||||
workspaceId
|
||||
))
|
||||
? WorkspaceRole.Admin
|
||||
: WorkspaceRole.Collaborator;
|
||||
|
||||
await this.db.workspaceUserRole.update({
|
||||
where: {
|
||||
id: oldOwner.id,
|
||||
},
|
||||
data: {
|
||||
type: fallbackRole,
|
||||
},
|
||||
});
|
||||
await this.db.workspaceUserRole.update({
|
||||
where: {
|
||||
id: newOwnerOldRole.id,
|
||||
},
|
||||
data: {
|
||||
type: WorkspaceRole.Owner,
|
||||
},
|
||||
});
|
||||
if (oldOwner?.user && oldOwner.user.id !== userId) {
|
||||
this.event.emit('workspace.owner.changed', {
|
||||
workspaceId,
|
||||
from: oldOwner.userId,
|
||||
from: oldOwner.user.id,
|
||||
to: userId,
|
||||
});
|
||||
this.logger.log(
|
||||
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.userId}] to [${userId}]`
|
||||
`Transfer workspace owner of [${workspaceId}] from [${oldOwner.user.id}] to [${userId}]`
|
||||
);
|
||||
} else {
|
||||
await this.db.workspaceUserRole.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId,
|
||||
type: WorkspaceRole.Owner,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
});
|
||||
this.logger.log(`Set workspace owner of [${workspaceId}] to [${userId}]`);
|
||||
}
|
||||
}
|
||||
@@ -123,21 +115,33 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
inviterId?: string;
|
||||
} = {}
|
||||
) {
|
||||
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 (role === WorkspaceRole.External) {
|
||||
return await this.setExternal(workspaceId, userId, oldRole);
|
||||
}
|
||||
|
||||
if (oldRole) {
|
||||
if (oldRole.type === role) {
|
||||
return oldRole;
|
||||
}
|
||||
|
||||
const newRole = await this.db.workspaceUserRole.update({
|
||||
where: { id: oldRole.id },
|
||||
data: { type: role },
|
||||
});
|
||||
const status = defaultData.status ?? oldRole.status;
|
||||
if (status === WorkspaceMemberStatus.Accepted) {
|
||||
await this.models.workspaceMember.setActive(workspaceId, userId, role);
|
||||
} else {
|
||||
await this.models.workspaceInvitation.set(
|
||||
workspaceId,
|
||||
userId,
|
||||
role,
|
||||
status,
|
||||
{
|
||||
source: defaultData.source ?? oldRole.source,
|
||||
inviterId: defaultData.inviterId ?? oldRole.inviterId ?? undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
const newRole = await this.mustGet(workspaceId, userId);
|
||||
|
||||
if (oldRole.status === WorkspaceMemberStatus.Accepted) {
|
||||
this.event.emit('workspace.members.roleChanged', {
|
||||
@@ -155,17 +159,60 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
inviterId,
|
||||
} = defaultData;
|
||||
|
||||
return await this.db.workspaceUserRole.create({
|
||||
if (status === WorkspaceMemberStatus.Accepted) {
|
||||
await this.models.workspaceMember.setActive(workspaceId, userId, role);
|
||||
} else {
|
||||
await this.models.workspaceInvitation.set(
|
||||
workspaceId,
|
||||
userId,
|
||||
role,
|
||||
status,
|
||||
{
|
||||
source,
|
||||
inviterId,
|
||||
}
|
||||
);
|
||||
}
|
||||
return await this.mustGet(workspaceId, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async setExternal(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
oldRole: WorkspaceUserRole | null
|
||||
) {
|
||||
await this.models.permissionProjection.markLegacyWriteOrigin();
|
||||
await this.db.workspaceMember.deleteMany({
|
||||
where: { workspaceId, userId, state: 'active' },
|
||||
});
|
||||
await this.db.workspaceInvitation.deleteMany({
|
||||
where: { workspaceId, inviteeUserId: userId },
|
||||
});
|
||||
|
||||
await this.models.permissionProjection.markNewWriteOrigin();
|
||||
if (oldRole) {
|
||||
return await this.withPermissionProjectionMetric(
|
||||
this.db.workspaceUserRole.update({
|
||||
where: { id: oldRole.id },
|
||||
data: {
|
||||
type: WorkspaceRole.External,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return await this.withPermissionProjectionMetric(
|
||||
this.db.workspaceUserRole.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId,
|
||||
type: role,
|
||||
status,
|
||||
source,
|
||||
inviterId,
|
||||
type: WorkspaceRole.External,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
@@ -177,68 +224,129 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
} = {}
|
||||
) {
|
||||
const { inviterId } = data;
|
||||
return await this.db.workspaceUserRole.update({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
await this.models.workspaceInvitation.setState(
|
||||
workspaceId,
|
||||
userId,
|
||||
status,
|
||||
{
|
||||
inviterId,
|
||||
}
|
||||
);
|
||||
return await this.mustGet(workspaceId, userId);
|
||||
}
|
||||
|
||||
private async mustGet(workspaceId: string, userId: string) {
|
||||
const role = await this.get(workspaceId, userId);
|
||||
if (!role) {
|
||||
throw new Error(
|
||||
`Workspace permission ${workspaceId}/${userId} not found after write.`
|
||||
);
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async delete(workspaceId: string, userId: string) {
|
||||
await this.models.workspaceMember.delete(workspaceId, userId);
|
||||
await this.db.workspaceInvitation.deleteMany({
|
||||
where: { workspaceId, inviteeUserId: userId },
|
||||
});
|
||||
await this.withPermissionProjectionMetric(
|
||||
this.db.workspaceUserRole.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
status,
|
||||
inviterId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(workspaceId: string, userId: string) {
|
||||
await this.db.workspaceUserRole.deleteMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async deleteByUserId(userId: string) {
|
||||
await this.db.workspaceUserRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
await this.models.permissionProjection.markNewWriteOrigin();
|
||||
await this.db.workspaceMember.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
await this.db.workspaceInvitation.deleteMany({
|
||||
where: { inviteeUserId: userId },
|
||||
});
|
||||
await this.withPermissionProjectionMetric(
|
||||
this.db.workspaceUserRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async deleteNonAccepted(workspaceId: string) {
|
||||
return await this.db.workspaceUserRole.deleteMany({
|
||||
where: { workspaceId, status: { not: WorkspaceMemberStatus.Accepted } },
|
||||
});
|
||||
return await this.models.workspaceInvitation.deleteNonAccepted(workspaceId);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async demoteAcceptedAdmins(workspaceId: string) {
|
||||
return await this.db.workspaceUserRole.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
type: WorkspaceRole.Admin,
|
||||
},
|
||||
data: {
|
||||
type: WorkspaceRole.Collaborator,
|
||||
},
|
||||
await this.models.permissionProjection.markNewWriteOrigin();
|
||||
return await this.db.workspaceMember.updateMany({
|
||||
where: { workspaceId, role: 'admin', state: 'active' },
|
||||
data: { role: 'member' },
|
||||
});
|
||||
}
|
||||
|
||||
async get(workspaceId: string, userId: string) {
|
||||
return await this.db.workspaceUserRole.findUnique({
|
||||
const active = await this.db.workspaceMember.findFirst({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
if (active) {
|
||||
return workspaceMemberToCompat(active);
|
||||
}
|
||||
|
||||
const invitation = await this.db.workspaceInvitation.findUnique({
|
||||
where: {
|
||||
workspaceId_inviteeUserId: {
|
||||
workspaceId,
|
||||
userId,
|
||||
inviteeUserId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (invitation) {
|
||||
return workspaceInvitationToCompat(invitation);
|
||||
}
|
||||
|
||||
return await this.db.workspaceUserRole.findFirst({
|
||||
where: {
|
||||
workspaceId,
|
||||
userId,
|
||||
type: WorkspaceRole.External,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const member = await this.db.workspaceMember.findFirst({
|
||||
where: {
|
||||
OR: [{ id }, { legacyPermissionId: id }],
|
||||
},
|
||||
});
|
||||
if (member) {
|
||||
return workspaceMemberToCompat(member);
|
||||
}
|
||||
|
||||
const invitation = await this.db.workspaceInvitation.findFirst({
|
||||
where: {
|
||||
OR: [{ id }, { legacyPermissionId: id }],
|
||||
inviteeUserId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (invitation) {
|
||||
return workspaceInvitationToCompat(invitation);
|
||||
}
|
||||
|
||||
return await this.db.workspaceUserRole.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -248,16 +356,18 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
* Get the **accepted** Role of a user in a workspace.
|
||||
*/
|
||||
async getActive(workspaceId: string, userId: string) {
|
||||
return await this.db.workspaceUserRole.findUnique({
|
||||
const active = await this.db.workspaceMember.findFirst({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
workspaceId,
|
||||
userId,
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
return active ? workspaceMemberToCompat(active) : null;
|
||||
}
|
||||
|
||||
async getOwner(workspaceId: string) {
|
||||
const role = await this.db.workspaceUserRole.findFirst({
|
||||
const role = await this.db.workspaceMember.findFirst({
|
||||
include: {
|
||||
user: {
|
||||
select: workspaceUserSelect,
|
||||
@@ -265,11 +375,12 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
},
|
||||
where: {
|
||||
workspaceId,
|
||||
type: WorkspaceRole.Owner,
|
||||
role: 'owner',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
if (!role?.user) {
|
||||
throw new Error('Workspace owner not found');
|
||||
}
|
||||
|
||||
@@ -277,7 +388,7 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
}
|
||||
|
||||
async getAdmins(workspaceId: string) {
|
||||
const list = await this.db.workspaceUserRole.findMany({
|
||||
const list = await this.db.workspaceMember.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: workspaceUserSelect,
|
||||
@@ -285,8 +396,8 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
},
|
||||
where: {
|
||||
workspaceId,
|
||||
type: WorkspaceRole.Admin,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
role: 'admin',
|
||||
state: 'active',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -294,87 +405,34 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
}
|
||||
|
||||
async count(workspaceId: string) {
|
||||
return this.db.workspaceUserRole.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
return await countWorkspaceUsers(this.db, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of users those in the status should be charged in billing system in a workspace.
|
||||
*/
|
||||
async chargedCount(workspaceId: string) {
|
||||
return this.db.workspaceUserRole.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: {
|
||||
not: WorkspaceMemberStatus.UnderReview,
|
||||
},
|
||||
},
|
||||
});
|
||||
return await countChargedWorkspaceUsers(this.db, workspaceId);
|
||||
}
|
||||
|
||||
async getUserActiveRoles(
|
||||
userId: string,
|
||||
filter: { role?: WorkspaceRole } = {}
|
||||
) {
|
||||
return await this.db.workspaceUserRole.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
type: filter.role,
|
||||
},
|
||||
});
|
||||
return await findUserActiveWorkspaceRoles(this.db, userId, filter);
|
||||
}
|
||||
|
||||
async hasSharedWorkspace(userId: string, otherUserId: string) {
|
||||
if (userId === otherUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const shared = await this.db.workspaceUserRole.findFirst({
|
||||
select: { id: true },
|
||||
where: {
|
||||
userId,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
workspace: {
|
||||
permissions: {
|
||||
some: {
|
||||
userId: otherUserId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!shared;
|
||||
return await hasSharedWorkspace(this.db, userId, otherUserId);
|
||||
}
|
||||
|
||||
async paginate(workspaceId: string, pagination: PaginationInput) {
|
||||
return await Promise.all([
|
||||
this.db.workspaceUserRole.findMany({
|
||||
include: {
|
||||
user: {
|
||||
select: workspaceUserSelect,
|
||||
},
|
||||
},
|
||||
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),
|
||||
]);
|
||||
const rows = await queryCompatRows(this.db, workspaceId, {
|
||||
first: pagination.first,
|
||||
offset: pagination.offset + (pagination.after ? 1 : 0),
|
||||
after: pagination.after ?? undefined,
|
||||
});
|
||||
return [rows, await this.count(workspaceId)] as const;
|
||||
}
|
||||
|
||||
async search(
|
||||
@@ -382,91 +440,20 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
query: string,
|
||||
pagination: PaginationInput
|
||||
) {
|
||||
return await this.db.workspaceUserRole.findMany({
|
||||
include: { user: { select: workspaceUserSelect } },
|
||||
where: {
|
||||
workspaceId,
|
||||
status: WorkspaceMemberStatus.Accepted,
|
||||
user: {
|
||||
OR: [
|
||||
{
|
||||
email: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: {
|
||||
contains: query,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: pagination.first,
|
||||
skip: pagination.offset + (pagination.after ? 1 : 0),
|
||||
return await searchCompatRows(this.db, workspaceId, query, {
|
||||
first: pagination.first,
|
||||
offset: pagination.offset + (pagination.after ? 1 : 0),
|
||||
after: pagination.after ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async allocateSeats(workspaceId: string, limit: number) {
|
||||
const usedCount = await this.db.workspaceUserRole.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: {
|
||||
in: [WorkspaceMemberStatus.Accepted, WorkspaceMemberStatus.Pending],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (limit <= usedCount) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const membersToBeAllocated = await this.db.workspaceUserRole.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: {
|
||||
in: [
|
||||
WorkspaceMemberStatus.AllocatingSeat,
|
||||
WorkspaceMemberStatus.NeedMoreSeat,
|
||||
],
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit - usedCount,
|
||||
});
|
||||
|
||||
const groups = groupBy(
|
||||
membersToBeAllocated,
|
||||
member => member.source
|
||||
) as Record<WorkspaceMemberSource, WorkspaceUserRole[]>;
|
||||
|
||||
if (groups.Email?.length > 0) {
|
||||
await this.db.workspaceUserRole.updateMany({
|
||||
where: { id: { in: groups.Email.map(m => m.id) } },
|
||||
data: { status: WorkspaceMemberStatus.Pending },
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.Link?.length > 0) {
|
||||
await this.db.workspaceUserRole.updateMany({
|
||||
where: { id: { in: groups.Link.map(m => m.id) } },
|
||||
data: { status: WorkspaceMemberStatus.Accepted },
|
||||
});
|
||||
}
|
||||
|
||||
// after allocating, all rests should be `NeedMoreSeat`
|
||||
await this.db.workspaceUserRole.updateMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: WorkspaceMemberStatus.AllocatingSeat,
|
||||
},
|
||||
data: { status: WorkspaceMemberStatus.NeedMoreSeat },
|
||||
});
|
||||
|
||||
return groups.Email ?? [];
|
||||
return await allocateWorkspaceSeats(
|
||||
this.db,
|
||||
this.models,
|
||||
workspaceId,
|
||||
limit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user