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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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:
DarkSky
2026-05-19 22:48:05 +08:00
committed by GitHub
parent 103ad2a810
commit c53457691d
150 changed files with 13463 additions and 2458 deletions
@@ -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
);
}
}