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
@@ -15,6 +15,9 @@ const workspace = await module.create(Mockers.Workspace, {
owner,
});
const waitNextMillisecond = () =>
new Promise(resolve => setTimeout(resolve, 1));
test.after.always(async () => {
await module.close();
});
@@ -77,7 +80,6 @@ test('should get a comment', async t => {
docId,
userId: owner.id,
});
const comment2 = await models.comment.get(comment1.id);
t.deepEqual(comment2, comment1);
t.deepEqual(comment2?.content, {
@@ -146,7 +148,7 @@ test('should resolve a comment', async t => {
docId,
userId: owner.id,
});
await waitNextMillisecond();
const comment2 = await models.comment.resolve({
id: comment.id,
resolved: true,
@@ -158,6 +160,7 @@ test('should resolve a comment', async t => {
// updatedAt should be changed
t.true(comment3!.updatedAt.getTime() > comment3!.createdAt.getTime());
await waitNextMillisecond();
const comment4 = await models.comment.resolve({
id: comment.id,
resolved: false,
@@ -272,6 +275,7 @@ test('should update a reply', async t => {
commentId: comment.id,
});
await waitNextMillisecond();
const reply2 = await models.comment.updateReply({
id: reply.id,
content: {
@@ -322,6 +326,7 @@ test('should list comments with replies', async t => {
userId: owner.id,
});
await waitNextMillisecond();
const comment2 = await models.comment.create({
content: {
type: 'paragraph',
@@ -342,6 +347,7 @@ test('should list comments with replies', async t => {
userId: owner.id,
});
await waitNextMillisecond();
const reply1 = await models.comment.createReply({
userId: owner.id,
content: {
@@ -351,6 +357,7 @@ test('should list comments with replies', async t => {
commentId: comment1.id,
});
await waitNextMillisecond();
const reply2 = await models.comment.createReply({
userId: owner.id,
content: {
@@ -421,7 +428,9 @@ test('should list changes', async t => {
docId,
userId: owner.id,
});
const comment1Cursor = comment1.updatedAt;
await waitNextMillisecond();
const comment2 = await models.comment.create({
content: {
type: 'paragraph',
@@ -432,6 +441,7 @@ test('should list changes', async t => {
userId: owner.id,
});
await waitNextMillisecond();
const reply1 = await models.comment.createReply({
userId: owner.id,
content: {
@@ -441,6 +451,7 @@ test('should list changes', async t => {
commentId: comment1.id,
});
await waitNextMillisecond();
const reply2 = await models.comment.createReply({
userId: owner.id,
content: {
@@ -465,7 +476,7 @@ test('should list changes', async t => {
t.is((changes1[2].item as Reply).commentId, comment1.id);
const changes2 = await models.comment.listChanges(workspace.id, docId, {
commentUpdatedAt: comment1.updatedAt,
commentUpdatedAt: comment1Cursor,
replyUpdatedAt: reply1.updatedAt,
});
t.is(changes2.length, 2);
@@ -476,6 +487,7 @@ test('should list changes', async t => {
t.is(changes2[1].commentId, comment1.id);
// update comment1
await waitNextMillisecond();
const comment1Updated = await models.comment.update({
id: comment1.id,
content: {
@@ -493,8 +505,10 @@ test('should list changes', async t => {
t.is(changes3[0].id, comment1Updated.id);
// delete comment1 and reply1, update reply2
await waitNextMillisecond();
await models.comment.delete(comment1.id);
await models.comment.deleteReply(reply1.id);
await waitNextMillisecond();
await models.comment.updateReply({
id: reply2.id,
content: {
@@ -19,4 +19,13 @@ export class BaseModel {
// See https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#using-the-injecttransaction-decorator
return this.txHost.tx;
}
protected async withPermissionProjectionMetric<T>(operation: Promise<T>) {
try {
return await operation;
} catch (err) {
this.models.permissionProjection.recordTriggerErrorMetric(err);
throw err;
}
}
}
+55 -87
View File
@@ -3,11 +3,12 @@ import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
import { WorkspaceDocUserRole } from '@prisma/client';
import { DocGrant, WorkspaceDocUserRole } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
import { BaseModel } from './base';
import { DocRole } from './common';
import { docRoleFromNew } from './permission-write';
@Injectable()
export class DocUserModel extends BaseModel {
@@ -17,36 +18,7 @@ export class DocUserModel extends BaseModel {
*/
@Transactional<TransactionalAdapterPrisma>({ timeout: 15000 })
async setOwner(workspaceId: string, docId: string, userId: string) {
await this.db.workspaceDocUserRole.updateMany({
where: {
workspaceId,
docId,
type: DocRole.Owner,
userId: { not: 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,
},
});
await this.models.docGrant.setOwner(workspaceId, docId, userId);
this.logger.log(
`Set doc owner of [${workspaceId}/${docId}] to [${userId}]`
);
@@ -62,34 +34,11 @@ export class DocUserModel extends BaseModel {
// 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;
await this.models.docGrant.set(workspaceId, docId, userId, role);
return await this.get(workspaceId, docId, userId);
}
@Transactional()
async batchSetUserRoles(
workspaceId: string,
docId: string,
@@ -104,76 +53,83 @@ export class DocUserModel extends BaseModel {
throw new CanNotBatchGrantDocOwnerPermissions();
}
const result = await this.db.workspaceDocUserRole.createMany({
skipDuplicates: true,
data: userIds.map(userId => ({
workspaceId,
docId,
userId,
type: role,
})),
});
return result.count;
return await this.models.docGrant.batchSetUserRoles(
workspaceId,
docId,
userIds,
role
);
}
@Transactional()
async delete(workspaceId: string, docId: string, userId: string) {
await this.db.workspaceDocUserRole.deleteMany({
where: {
workspaceId,
docId,
userId,
},
});
await this.models.docGrant.delete(workspaceId, docId, userId);
}
@Transactional()
async deleteByUserId(userId: string) {
await this.db.workspaceDocUserRole.deleteMany({
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.docGrant.deleteMany({
where: {
userId,
principalType: 'user',
principalId: userId,
},
});
await this.withPermissionProjectionMetric(
this.db.workspaceDocUserRole.deleteMany({
where: {
userId,
},
})
);
}
async getOwner(workspaceId: string, docId: string) {
return await this.db.workspaceDocUserRole.findFirst({
const grant = await this.db.docGrant.findFirst({
where: {
workspaceId,
docId,
type: DocRole.Owner,
principalType: 'user',
role: 'owner',
},
});
return grant ? this.docGrantToCompat(grant) : null;
}
async get(workspaceId: string, docId: string, userId: string) {
return await this.db.workspaceDocUserRole.findUnique({
const grant = await this.db.docGrant.findUnique({
where: {
workspaceId_docId_userId: {
workspaceId_docId_principalType_principalId: {
workspaceId,
docId,
userId,
principalType: 'user',
principalId: userId,
},
},
});
return grant ? this.docGrantToCompat(grant) : null;
}
async findMany(workspaceId: string, docIds: string[], userId: string) {
return await this.db.workspaceDocUserRole.findMany({
const grants = await this.db.docGrant.findMany({
where: {
workspaceId,
docId: {
in: docIds,
},
userId,
principalType: 'user',
principalId: userId,
},
});
return grants.map(grant => this.docGrantToCompat(grant));
}
count(workspaceId: string, docId: string) {
return this.db.workspaceDocUserRole.count({
return this.db.docGrant.count({
where: {
workspaceId,
docId,
principalType: 'user',
},
});
}
@@ -183,11 +139,12 @@ export class DocUserModel extends BaseModel {
docId: string,
pagination: PaginationInput
): Promise<[WorkspaceDocUserRole[], number]> {
return await Promise.all([
this.db.workspaceDocUserRole.findMany({
const [grants, total] = await Promise.all([
this.db.docGrant.findMany({
where: {
workspaceId,
docId,
principalType: 'user',
createdAt: pagination.after
? {
gte: pagination.after,
@@ -202,5 +159,16 @@ export class DocUserModel extends BaseModel {
}),
this.count(workspaceId, docId),
]);
return [grants.map(grant => this.docGrantToCompat(grant)), total];
}
private docGrantToCompat(grant: DocGrant): WorkspaceDocUserRole {
return {
workspaceId: grant.workspaceId,
docId: grant.docId,
userId: grant.principalId,
type: docRoleFromNew(grant.role as never),
createdAt: grant.createdAt,
};
}
}
+41 -19
View File
@@ -351,27 +351,44 @@ export class DocModel extends BaseModel {
/**
* Create or update the doc meta.
*/
@Transactional()
async upsertMeta(
workspaceId: string,
docId: string,
data?: DocMetaUpsertInput
) {
const doc = await this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
if (
data &&
('public' in data || 'defaultRole' in data || 'publishedAt' in data)
) {
await this.models.docAccessPolicy.upsert(workspaceId, docId, {
public: data.public,
defaultRole: data.defaultRole,
publishedAt:
typeof data.publishedAt === 'string'
? new Date(data.publishedAt)
: data.publishedAt,
});
}
const doc = await this.withPermissionProjectionMetric(
this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId,
docId,
},
},
update: {
...data,
},
create: {
...data,
workspaceId,
docId,
},
},
update: {
...data,
},
create: {
...data,
workspaceId,
docId,
},
});
})
);
this.event.emit('doc.updated', {
workspaceId,
docId,
@@ -643,13 +660,17 @@ export class DocModel extends BaseModel {
async paginateDocInfoByUpdatedAt(
workspaceId: string,
pagination: PaginationInput
pagination: PaginationInput,
readablePredicate: Prisma.Sql = Prisma.sql`TRUE`
) {
const count = await this.db.workspaceDoc.count({
where: {
workspaceId,
},
});
const [countRow] = await this.db.$queryRaw<{ count: bigint | number }[]>`
SELECT COUNT(*) AS count
FROM "workspace_pages"
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND ${readablePredicate}
`;
const count = Number(countRow?.count ?? 0);
const after = pagination.after
? Prisma.sql`AND "snapshots"."updated_at" < ${new Date(pagination.after)}`
@@ -686,6 +707,7 @@ export class DocModel extends BaseModel {
AND "workspace_pages"."page_id" = "snapshots"."guid"
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND ${readablePredicate}
${after}
ORDER BY
"snapshots"."updated_at" DESC
@@ -30,6 +30,14 @@ import { FeatureModel } from './feature';
import { HistoryModel } from './history';
import { MagicLinkOtpModel } from './magic-link-otp';
import { NotificationModel } from './notification';
import { PermissionProjectionModel } from './permission-projection';
import {
DocAccessPolicyModel,
DocGrantModel,
WorkspaceAccessPolicyModel,
WorkspaceInvitationModel,
WorkspaceMemberModel,
} from './permission-write';
import { MODELS_SYMBOL } from './provider';
import { SessionModel } from './session';
import { UserModel } from './user';
@@ -41,6 +49,7 @@ import { WorkspaceModel } from './workspace';
import { WorkspaceAnalyticsModel } from './workspace-analytics';
import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceRuntimeStateModel } from './workspace-runtime-state';
import { WorkspaceUserModel } from './workspace-user';
const MODELS = {
@@ -52,12 +61,19 @@ const MODELS = {
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
workspaceFeature: WorkspaceFeatureModel,
workspaceRuntimeState: WorkspaceRuntimeStateModel,
doc: DocModel,
userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
history: HistoryModel,
notification: NotificationModel,
permissionProjection: PermissionProjectionModel,
workspaceMember: WorkspaceMemberModel,
workspaceInvitation: WorkspaceInvitationModel,
workspaceAccessPolicy: WorkspaceAccessPolicyModel,
docAccessPolicy: DocAccessPolicyModel,
docGrant: DocGrantModel,
userSettings: UserSettingsModel,
copilotSession: CopilotSessionModel,
copilotUsage: CopilotUsageModel,
@@ -150,6 +166,8 @@ export * from './feature';
export * from './history';
export * from './magic-link-otp';
export * from './notification';
export * from './permission-projection';
export * from './permission-write';
export * from './session';
export * from './user';
export * from './user-doc';
@@ -160,4 +178,5 @@ export * from './workspace';
export * from './workspace-analytics';
export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-runtime-state';
export * from './workspace-user';
@@ -0,0 +1,555 @@
import { Injectable, Optional } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { metrics } from '../base';
import { BaseModel } from './base';
type CountRow = { count: bigint };
type ProjectionIssueRow = {
category: string;
count: bigint;
};
type ProjectionBackfillDb = {
$transaction: (
callback: (
tx: Pick<Prisma.TransactionClient, '$executeRaw'>
) => Promise<void>
) => Promise<void>;
};
export type PermissionProjectionCheckReport = {
oldWorkspacePolicyMismatch: number;
oldAcceptedMemberMismatch: number;
extraProjectedMember: number;
oldInvitationMismatch: number;
extraProjectedInvitation: number;
oldDocGrantMismatch: number;
extraProjectedDocGrant: number;
oldDocPolicyMismatch: number;
extraProjectedDocPolicy: number;
runtimeStateMissing: number;
runtimeStateMismatch: number;
ownerConflict: number;
oldNewDecisionMismatch: number;
invalidLegacyRows: Record<string, number>;
};
export const PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES = [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
] as const;
export function permissionProjectionTriggerErrorCategory(error: unknown) {
const message =
error instanceof Error ? error.message : String(error ?? 'unknown');
const match = message.match(/permission_projection_error:([^:]+):/);
const category = match?.[1];
if (!category) {
return null;
}
return PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES.includes(
category as (typeof PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES)[number]
)
? category
: 'unknown';
}
async function count(first: Promise<CountRow[]>) {
const rows = await first;
return Number(rows[0]?.count ?? 0);
}
@Injectable()
export class PermissionProjectionModel extends BaseModel {
constructor(@Optional() private readonly prisma?: PrismaClient) {
super();
}
async backfillLegacyProjection() {
const db = (this.prisma ?? this.db) as unknown as ProjectionBackfillDb;
await db.$transaction(async tx => {
await tx.$executeRaw`
DELETE FROM workspace_members projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_invitations projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_grants projected
WHERE projected.principal_type = 'user'
AND NOT EXISTS (
SELECT 1
FROM workspace_page_user_permissions old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
AND affine_permission_legacy_doc_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspace_pages old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspaces old
WHERE old.id = projected.workspace_id
)
`;
await tx.$executeRaw`
INSERT INTO workspace_access_policies (
workspace_id,
visibility,
sharing_enabled,
url_preview_enabled,
updated_at
)
SELECT
id,
CASE WHEN public THEN 'public' ELSE 'private' END,
enable_sharing,
enable_url_preview,
now()
FROM workspaces
ON CONFLICT (workspace_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
sharing_enabled = EXCLUDED.sharing_enabled,
url_preview_enabled = EXCLUDED.url_preview_enabled,
updated_at = now()
`;
await tx.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
affine_permission_legacy_workspace_role(type),
'active',
CASE source
WHEN 'Email'::"WorkspaceMemberSource" THEN 'email'
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'legacy'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
inviter_id,
CASE WHEN affine_permission_legacy_workspace_role(type) = 'admin' THEN 'admin' ELSE 'member' END,
affine_permission_workspace_invitation_state(status),
CASE source
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'email'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(status) IS NOT NULL
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
invitee_user_id = EXCLUDED.invitee_user_id,
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
published_at,
updated_at
)
SELECT
workspace_id,
page_id,
CASE WHEN public THEN 'public' ELSE 'private' END,
CASE WHEN public THEN 'external' ELSE NULL END,
affine_permission_legacy_default_doc_role("defaultRole"),
published_at,
now()
FROM workspace_pages
WHERE affine_permission_legacy_default_doc_role("defaultRole") IS NOT NULL
ON CONFLICT (workspace_id, doc_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
public_role = EXCLUDED.public_role,
member_default_role = EXCLUDED.member_default_role,
published_at = EXCLUDED.published_at,
updated_at = now()
`;
await tx.$executeRaw`
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
legacy_workspace_id,
legacy_doc_id,
legacy_user_id,
created_at,
updated_at
)
SELECT
workspace_id,
page_id,
'user',
user_id,
affine_permission_legacy_doc_role(type),
workspace_id,
page_id,
user_id,
created_at,
now()
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
ON CONFLICT (workspace_id, doc_id, principal_type, principal_id)
DO UPDATE SET
role = EXCLUDED.role,
updated_at = now()
`;
});
}
recordTriggerErrorMetric(error: unknown) {
const category = permissionProjectionTriggerErrorCategory(error);
if (!category) {
return null;
}
metrics.permission
.counter('projection_trigger_errors', {
description: 'Permission projection trigger error count',
})
.add(1, { category });
return category;
}
async checkLegacyProjection(): Promise<PermissionProjectionCheckReport> {
const [
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
ownerConflict,
invalidLegacyRows,
] = await Promise.all([
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspaces old
LEFT JOIN workspace_access_policies projected
ON projected.workspace_id = old.id
WHERE projected.workspace_id IS NULL
OR projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END
OR projected.sharing_enabled <> old.enable_sharing
OR projected.url_preview_enabled <> old.enable_url_preview
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_members projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.user_id = old.user_id
AND projected.state = 'active'
)
WHERE old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.user_id <> old.user_id OR
projected.role <> affine_permission_legacy_workspace_role(old.type) OR
projected.state <> 'active'
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_members projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.user_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
)
WHERE
projected.state = 'active'
AND (
old.id IS NULL OR
old.status <> 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_invitations projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.invitee_user_id = old.user_id
)
WHERE old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.invitee_user_id <> old.user_id OR
projected.requested_role <> CASE WHEN affine_permission_legacy_workspace_role(old.type) = 'admin' THEN 'admin' ELSE 'member' END OR
projected.status <> affine_permission_workspace_invitation_state(old.status)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_invitations projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.invitee_user_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
)
WHERE projected.invitee_user_id IS NOT NULL
AND (
old.id IS NULL OR
old.status = 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_workspace_invitation_state(old.status) IS NULL OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_page_user_permissions old
LEFT JOIN doc_grants projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
AND projected.principal_type = 'user'
AND projected.principal_id = old.user_id
WHERE affine_permission_legacy_doc_role(old.type) IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.role <> affine_permission_legacy_doc_role(old.type)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_grants projected
LEFT JOIN workspace_page_user_permissions old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
WHERE projected.principal_type = 'user'
AND (
old.workspace_id IS NULL OR
affine_permission_legacy_doc_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_pages old
LEFT JOIN doc_access_policies projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
WHERE affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END OR
projected.public_role IS DISTINCT FROM CASE WHEN old.public THEN 'external' ELSE NULL END OR
projected.member_default_role IS DISTINCT FROM affine_permission_legacy_default_doc_role(old."defaultRole")
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_access_policies projected
LEFT JOIN workspace_pages old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
WHERE old.workspace_id IS NULL
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COALESCE(SUM(conflicts.count - 1), 0)::bigint AS count
FROM (
SELECT workspace_id, COUNT(*)::bigint AS count
FROM workspace_members
WHERE state = 'active'
AND role = 'owner'
GROUP BY workspace_id
HAVING COUNT(*) > 1
UNION ALL
SELECT workspace_id || ':' || doc_id AS workspace_id, COUNT(*)::bigint AS count
FROM doc_grants
WHERE principal_type = 'user'
AND role = 'owner'
GROUP BY workspace_id, doc_id
HAVING COUNT(*) > 1
) conflicts
`),
this.db.$queryRaw<ProjectionIssueRow[]>`
SELECT category, COUNT(*)::bigint AS count
FROM (
SELECT 'unknown_workspace_role' AS category
FROM workspace_user_permissions
WHERE affine_permission_legacy_workspace_role(type) IS NULL
AND type <> -99
UNION ALL
SELECT 'unknown_doc_role' AS category
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NULL
AND type NOT IN (0, -32768)
UNION ALL
SELECT 'legacy_doc_external_row' AS category
FROM workspace_page_user_permissions
WHERE type = 0
UNION ALL
SELECT 'legacy_doc_none_row' AS category
FROM workspace_page_user_permissions
WHERE type = -32768
UNION ALL
SELECT 'doc_default_owner' AS category
FROM workspace_pages
WHERE "defaultRole" = 99
) issues
GROUP BY category
`,
]);
return {
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict,
oldNewDecisionMismatch: 0,
invalidLegacyRows: Object.fromEntries(
invalidLegacyRows.map(row => [row.category, Number(row.count)])
),
};
}
async lockWorkspaceOwnerTransfer(workspaceId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 16))
`;
}
async lockDocOwnerTransfer(workspaceId: string, docId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${docId}`}, 16))
`;
}
async markNewWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'new', true)
`;
}
async markLegacyWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'legacy', true)
`;
}
}
@@ -0,0 +1,742 @@
import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { WorkspaceMemberSource, WorkspaceMemberStatus } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions } from '../base';
import { BaseModel } from './base';
import { DocRole, WorkspaceRole } from './common';
type WorkspaceMemberRole = 'owner' | 'admin' | 'member';
type WorkspaceInvitationStatus = 'pending' | 'waiting_review' | 'waiting_seat';
type WorkspaceInvitationKind = 'email' | 'link';
type PermissionSource = 'email' | 'link' | 'legacy';
type DocGrantRole = 'owner' | 'manager' | 'editor' | 'commenter' | 'reader';
export function workspaceRoleToNew(role: WorkspaceRole): WorkspaceMemberRole {
switch (role) {
case WorkspaceRole.Owner:
return 'owner';
case WorkspaceRole.Admin:
return 'admin';
case WorkspaceRole.Collaborator:
return 'member';
default:
throw new Error(
`Unsupported workspace role for new permission model: ${role}`
);
}
}
export function workspaceRoleFromNew(role: WorkspaceMemberRole): WorkspaceRole {
switch (role) {
case 'owner':
return WorkspaceRole.Owner;
case 'admin':
return WorkspaceRole.Admin;
case 'member':
return WorkspaceRole.Collaborator;
}
}
export function workspaceStatusFromNew(
state: 'active' | WorkspaceInvitationStatus
): WorkspaceMemberStatus {
switch (state) {
case 'active':
return WorkspaceMemberStatus.Accepted;
case 'pending':
return WorkspaceMemberStatus.Pending;
case 'waiting_review':
return WorkspaceMemberStatus.UnderReview;
case 'waiting_seat':
return WorkspaceMemberStatus.NeedMoreSeat;
}
}
export function workspaceSourceToNew(
source?: WorkspaceMemberSource
): PermissionSource {
switch (source) {
case WorkspaceMemberSource.Email:
return 'email';
case WorkspaceMemberSource.Link:
return 'link';
default:
return 'legacy';
}
}
export function workspaceSourceFromNew(
source?: PermissionSource | WorkspaceInvitationKind
): WorkspaceMemberSource {
return source === 'link'
? WorkspaceMemberSource.Link
: WorkspaceMemberSource.Email;
}
export function workspaceStatusToInvitationState(
status: WorkspaceMemberStatus
): WorkspaceInvitationStatus | null {
switch (status) {
case WorkspaceMemberStatus.Pending:
return 'pending';
case WorkspaceMemberStatus.UnderReview:
return 'waiting_review';
case WorkspaceMemberStatus.AllocatingSeat:
case WorkspaceMemberStatus.NeedMoreSeat:
case WorkspaceMemberStatus.NeedMoreSeatAndReview:
return 'waiting_seat';
default:
return null;
}
}
export function docRoleToNew(role: DocRole): DocGrantRole {
switch (role) {
case DocRole.Owner:
return 'owner';
case DocRole.Manager:
return 'manager';
case DocRole.Editor:
return 'editor';
case DocRole.Commenter:
return 'commenter';
case DocRole.Reader:
return 'reader';
default:
throw new Error(
`Unsupported doc grant role for new permission model: ${role}`
);
}
}
function workspaceInvitationKindToNew(
source?: WorkspaceMemberSource
): WorkspaceInvitationKind {
return source === WorkspaceMemberSource.Link ? 'link' : 'email';
}
export function docRoleFromNew(role: DocGrantRole): DocRole {
switch (role) {
case 'owner':
return DocRole.Owner;
case 'manager':
return DocRole.Manager;
case 'editor':
return DocRole.Editor;
case 'commenter':
return DocRole.Commenter;
case 'reader':
return DocRole.Reader;
}
}
@Injectable()
export class WorkspaceMemberModel extends BaseModel {
@Transactional()
async setOwner(
workspaceId: string,
userId: string,
fallbackRole: WorkspaceRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockWorkspaceOwnerTransfer(
workspaceId
);
const ownerCount = await this.db.workspaceMember.count({
where: { workspaceId, role: 'owner', state: 'active' },
});
if (ownerCount > 0) {
const target = await this.db.workspaceMember.findFirst({
where: { workspaceId, userId, state: 'active' },
});
if (!target) {
throw new Error('New workspace owner must be an active member.');
}
}
await this.db.workspaceMember.updateMany({
where: {
workspaceId,
role: 'owner',
userId: { not: userId },
state: 'active',
},
data: {
role: workspaceRoleToNew(fallbackRole),
source: 'legacy',
},
});
return await this.db.workspaceMember.upsert({
where: {
workspaceId_userId_state: {
workspaceId,
userId,
state: 'active',
},
},
update: {
role: 'owner',
source: 'legacy',
},
create: {
workspaceId,
userId,
role: 'owner',
state: 'active',
source: 'legacy',
},
});
}
@Transactional()
async setActive(
workspaceId: string,
userId: string,
role: WorkspaceRole,
data: { legacyPermissionId?: string | null; source?: PermissionSource } = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
return await this.db.workspaceMember.upsert({
where: {
workspaceId_userId_state: {
workspaceId,
userId,
state: 'active',
},
},
update: {
role: workspaceRoleToNew(role),
legacyPermissionId: data.legacyPermissionId,
source: data.source,
},
create: {
workspaceId,
userId,
role: workspaceRoleToNew(role),
state: 'active',
source: data.source ?? 'legacy',
legacyPermissionId: data.legacyPermissionId,
},
});
}
@Transactional()
async delete(workspaceId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT id
FROM workspace_members
WHERE workspace_id = ${workspaceId}
AND role = 'owner'
AND state = 'active'
FOR UPDATE
`;
const existingOwners = await this.db.workspaceMember.count({
where: {
workspaceId,
role: 'owner',
state: 'active',
userId: { not: userId },
},
});
const deletingOwner = await this.db.workspaceMember.count({
where: {
workspaceId,
userId,
role: 'owner',
state: 'active',
},
});
if (deletingOwner > 0 && existingOwners === 0) {
throw new Error('Cannot remove the last active workspace owner.');
}
return await this.db.workspaceMember.deleteMany({
where: { workspaceId, userId, state: 'active' },
});
}
}
@Injectable()
export class WorkspaceInvitationModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
@Transactional()
async set(
workspaceId: string,
userId: string,
role: WorkspaceRole,
status: WorkspaceMemberStatus,
data: {
source?: WorkspaceMemberSource;
inviterId?: string;
} = {}
): Promise<void> {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
const invitationStatus = workspaceStatusToInvitationState(status);
if (!invitationStatus) {
await this.models.workspaceMember.setActive(workspaceId, userId, role);
return;
}
await this.db.workspaceMember.deleteMany({
where: { workspaceId, userId, state: 'active' },
});
await this.upsertInvitation({
workspaceId,
userId,
inviterId: data.inviterId,
requestedRole: role === WorkspaceRole.Admin ? 'admin' : 'member',
status: invitationStatus,
kind: workspaceInvitationKindToNew(data.source),
source: workspaceSourceToNew(data.source),
});
}
@Transactional()
async setState(
workspaceId: string,
userId: string,
status: WorkspaceMemberStatus,
data: {
inviterId?: string;
} = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const invitationStatus = workspaceStatusToInvitationState(status);
if (!invitationStatus) {
const invitation = await this.findInvitation(workspaceId, userId);
if (!invitation) {
throw new Error('Cannot activate a missing workspace invitation.');
}
const role =
invitation.requestedRole === 'admin'
? WorkspaceRole.Admin
: WorkspaceRole.Collaborator;
return await this.models.workspaceMember.setActive(
workspaceId,
userId,
role,
{
legacyPermissionId: invitation.legacyPermissionId,
source: invitation.source,
}
);
}
return await this.updateInvitationStatus({
workspaceId,
userId,
status: invitationStatus,
inviterId: data.inviterId,
});
}
@Transactional()
async deleteNonAccepted(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: { workspaceId },
});
}
private async supportsCurrentInvitationColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async upsertInvitation(input: {
workspaceId: string;
userId: string;
inviterId?: string;
requestedRole: 'admin' | 'member';
status: WorkspaceInvitationStatus;
kind: WorkspaceInvitationKind;
source: PermissionSource;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.kind},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = now()
`;
}
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_id,
role,
state,
source,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.source},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_id = EXCLUDED.inviter_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = now()
`;
}
private async findInvitation(workspaceId: string, userId: string) {
if (await this.supportsCurrentInvitationColumns()) {
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
requested_role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
kind AS source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
LIMIT 1
`;
return rows[0] ?? null;
}
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
LIMIT 1
`;
return rows[0] ?? null;
}
private async updateInvitationStatus(input: {
workspaceId: string;
userId: string;
status: WorkspaceInvitationStatus;
inviterId?: string;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
status = ${input.status},
inviter_user_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
}
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
state = ${input.status},
inviter_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
}
}
@Injectable()
export class WorkspaceAccessPolicyModel extends BaseModel {
@Transactional()
async upsert(
workspaceId: string,
policy: {
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceAccessPolicy.upsert({
where: { workspaceId },
update: {
visibility:
policy.public === undefined
? undefined
: policy.public
? 'public'
: 'private',
sharingEnabled: policy.enableSharing,
urlPreviewEnabled: policy.enableUrlPreview,
},
create: {
workspaceId,
visibility: policy.public ? 'public' : 'private',
sharingEnabled: policy.enableSharing ?? true,
urlPreviewEnabled: policy.enableUrlPreview ?? false,
},
});
}
}
@Injectable()
export class DocAccessPolicyModel extends BaseModel {
async hasPublicExternal(workspaceId: string) {
const count = await this.db.docAccessPolicy.count({
where: {
workspaceId,
visibility: 'public',
publicRole: 'external',
},
});
return count > 0;
}
@Transactional()
async upsert(
workspaceId: string,
docId: string,
policy: {
public?: boolean;
defaultRole?: DocRole;
publishedAt?: Date | null;
urlPreviewEnabled?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const publicRole = policy.public ? 'external' : null;
return await this.db.docAccessPolicy.upsert({
where: { workspaceId_docId: { workspaceId, docId } },
update: {
visibility:
policy.public === undefined
? undefined
: policy.public
? 'public'
: 'private',
publicRole: policy.public === undefined ? undefined : publicRole,
memberDefaultRole:
policy.defaultRole === undefined
? undefined
: policy.defaultRole === DocRole.None
? 'none'
: docRoleToNew(policy.defaultRole),
publishedAt: policy.publishedAt,
urlPreviewEnabled: policy.urlPreviewEnabled,
},
create: {
workspaceId,
docId,
visibility: policy.public ? 'public' : 'private',
publicRole,
memberDefaultRole:
policy.defaultRole === undefined
? null
: policy.defaultRole === DocRole.None
? 'none'
: docRoleToNew(policy.defaultRole),
publishedAt: policy.publishedAt,
urlPreviewEnabled: policy.urlPreviewEnabled ?? false,
},
});
}
}
@Injectable()
export class DocGrantModel extends BaseModel {
@Transactional()
async setOwner(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockDocOwnerTransfer(
workspaceId,
docId
);
await this.db.docGrant.updateMany({
where: {
workspaceId,
docId,
principalType: 'user',
role: 'owner',
principalId: { not: userId },
},
data: { role: 'manager' },
});
return await this.set(workspaceId, docId, userId, DocRole.Owner);
}
@Transactional()
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
await this.models.permissionProjection.markNewWriteOrigin();
assert(role !== DocRole.None && role !== DocRole.External);
return await this.db.docGrant.upsert({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
},
},
update: {
role: docRoleToNew(role),
},
create: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
role: docRoleToNew(role),
},
});
}
@Transactional()
async batchSetUserRoles(
workspaceId: string,
docId: string,
userIds: string[],
role: DocRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
if (userIds.length === 0) {
return 0;
}
const grantRole = docRoleToNew(role);
for (const userId of userIds) {
await this.db.docGrant.upsert({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
},
},
update: {
role: grantRole,
},
create: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
role: grantRole,
},
});
}
return userIds.length;
}
@Transactional()
async delete(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT 1
FROM doc_grants
WHERE workspace_id = ${workspaceId}
AND doc_id = ${docId}
AND principal_type = 'user'
AND role = 'owner'
FOR UPDATE
`;
const deletingOwner = await this.db.docGrant.count({
where: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
role: 'owner',
},
});
const otherOwners = await this.db.docGrant.count({
where: {
workspaceId,
docId,
principalType: 'user',
principalId: { not: userId },
role: 'owner',
},
});
if (deletingOwner > 0 && otherOwners === 0) {
throw new Error('Cannot remove the last doc owner grant.');
}
return await this.db.docGrant.deleteMany({
where: {
workspaceId,
docId,
principalType: 'user',
principalId: userId,
},
});
}
}
@@ -0,0 +1,223 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
export type WorkspaceRuntimeState = {
workspaceId: string;
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date | null;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type WorkspaceRuntimeStateRow = {
workspaceId: string;
known: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type LegacyWorkspaceRuntimeStateRow = {
workspaceId: string;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
staleAt: Date | null;
};
function isMissingRuntimeStateColumn(error: unknown) {
const meta = (error as { meta?: { code?: string } })?.meta;
return meta?.code === '42703';
}
@Injectable()
export class WorkspaceRuntimeStateModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
async get(workspaceId: string): Promise<WorkspaceRuntimeState> {
const rows = await this.loadRows(workspaceId);
const row = rows[0];
if (!row) {
return {
workspaceId,
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
updatedAt: null,
lastReconciledAt: null,
staleAfter: null,
};
}
return {
workspaceId,
known: row.known,
stale:
!row.known || (row.staleAfter !== null && row.staleAfter <= new Date()),
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.lastReconciledAt,
staleAfter: row.staleAfter,
};
}
async upsert(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
if (await this.supportsCurrentRuntimeStateColumns()) {
await this.upsertCurrent(workspaceId, state);
} else {
await this.upsertLegacy(workspaceId, state);
}
}
private async loadRows(workspaceId: string) {
if (!(await this.supportsCurrentRuntimeStateColumns())) {
return await this.loadLegacyRows(workspaceId);
}
try {
return await this.db.$queryRaw<WorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
known,
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
last_reconciled_at AS "lastReconciledAt",
stale_after AS "staleAfter"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
} catch (error) {
if (!isMissingRuntimeStateColumn(error)) {
throw error;
}
return await this.loadLegacyRows(workspaceId);
}
}
private async supportsCurrentRuntimeStateColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async loadLegacyRows(workspaceId: string) {
const rows = await this.db.$queryRaw<LegacyWorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
stale_at AS "staleAt"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows.map(row => ({
workspaceId: row.workspaceId,
known: true,
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.updatedAt,
staleAfter: row.staleAt,
}));
}
private async upsertCurrent(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (
${workspaceId},
${state.known ?? true},
${state.readonly},
${state.readonlyReasons},
${state.lastReconciledAt ?? new Date()},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
known = EXCLUDED.known,
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
last_reconciled_at = EXCLUDED.last_reconciled_at,
stale_after = EXCLUDED.stale_after,
updated_at = now()
`;
}
private async upsertLegacy(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (
${workspaceId},
${state.readonly},
${state.readonlyReasons},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
stale_at = EXCLUDED.stale_at,
updated_at = now()
`;
}
}
@@ -0,0 +1,389 @@
import {
Prisma,
PrismaClient,
User,
WorkspaceInvitation,
WorkspaceMember,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
import { WorkspaceRole, workspaceUserSelect } from './common';
import {
workspaceRoleFromNew,
workspaceSourceFromNew,
workspaceStatusFromNew,
} from './permission-write';
export type WorkspaceUserCompat = WorkspaceUserRole & {
user?: Pick<User, keyof typeof workspaceUserSelect>;
};
export type WorkspaceMemberWithUser = WorkspaceMember & {
user?: Pick<User, keyof typeof workspaceUserSelect>;
};
export type WorkspaceInvitationWithUser = WorkspaceInvitation & {
inviteeUser?: Pick<User, keyof typeof workspaceUserSelect> | null;
};
type WorkspaceUserCompatRow = {
id: string;
workspaceId: string;
userId: string;
type: number;
status: WorkspaceMemberStatus;
source: WorkspaceMemberSource;
inviterId: string | null;
createdAt: Date;
updatedAt: Date;
userName: string;
userEmail: string;
userAvatarUrl: string | null;
};
type WorkspaceUserCompatDb = Pick<
PrismaClient,
'workspaceMember' | 'workspaceInvitation' | '$queryRaw'
>;
export function workspaceMemberToCompat(
member: WorkspaceMemberWithUser
): WorkspaceUserCompat {
return {
id: member.legacyPermissionId ?? member.id,
workspaceId: member.workspaceId,
userId: member.userId,
type: workspaceRoleFromNew(member.role as never),
status: WorkspaceMemberStatus.Accepted,
source: workspaceSourceFromNew(member.source as never),
inviterId: null,
createdAt: member.createdAt,
updatedAt: member.updatedAt,
...(member.user ? { user: member.user } : {}),
};
}
export function workspaceInvitationToCompat(
invitation: WorkspaceInvitationWithUser
): WorkspaceUserCompat {
return {
id: invitation.legacyPermissionId ?? invitation.id,
workspaceId: invitation.workspaceId,
userId: invitation.inviteeUserId ?? '',
type: workspaceRoleFromNew(invitation.requestedRole as never),
status: workspaceStatusFromNew(invitation.status as never),
source: workspaceSourceFromNew(invitation.kind as never),
inviterId: invitation.inviterUserId,
createdAt: invitation.createdAt,
updatedAt: invitation.updatedAt,
...(invitation.inviteeUser ? { user: invitation.inviteeUser } : {}),
};
}
function rawCompatRowToCompat(
row: WorkspaceUserCompatRow
): WorkspaceUserCompat {
return {
id: row.id,
workspaceId: row.workspaceId,
userId: row.userId,
type: row.type,
status: row.status,
source: row.source,
inviterId: row.inviterId,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
user: {
id: row.userId,
name: row.userName,
email: row.userEmail,
avatarUrl: row.userAvatarUrl,
},
};
}
export async function queryCompatRows(
db: WorkspaceUserCompatDb,
workspaceId: string,
pagination: { first: number; offset: number; after?: string | Date }
) {
const after = pagination.after
? Prisma.sql`AND created_at >= ${pagination.after}::timestamptz`
: Prisma.empty;
const rows = await db.$queryRaw<WorkspaceUserCompatRow[]>`
SELECT *
FROM (
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
WHEN 'owner' THEN ${WorkspaceRole.Owner}
WHEN 'admin' THEN ${WorkspaceRole.Admin}
ELSE ${WorkspaceRole.Collaborator}
END AS type,
'Accepted'::"WorkspaceMemberStatus" AS status,
CASE wm.source
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
ELSE 'Email'::"WorkspaceMemberSource"
END AS source,
NULL::varchar AS "inviterId",
wm.created_at AS "createdAt",
wm.updated_at AS "updatedAt",
u.name AS "userName",
u.email AS "userEmail",
u.avatar_url AS "userAvatarUrl",
wm.created_at AS created_at
FROM workspace_members wm
INNER JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id = ${workspaceId}
AND wm.state = 'active'
UNION ALL
SELECT
COALESCE(wi.legacy_permission_id, wi.id) AS id,
wi.workspace_id AS "workspaceId",
wi.invitee_user_id AS "userId",
CASE wi.requested_role
WHEN 'admin' THEN ${WorkspaceRole.Admin}
ELSE ${WorkspaceRole.Collaborator}
END AS type,
CASE wi.status
WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus"
WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus"
ELSE 'Pending'::"WorkspaceMemberStatus"
END AS status,
CASE wi.kind
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
ELSE 'Email'::"WorkspaceMemberSource"
END AS source,
wi.inviter_user_id AS "inviterId",
wi.created_at AS "createdAt",
wi.updated_at AS "updatedAt",
u.name AS "userName",
u.email AS "userEmail",
u.avatar_url AS "userAvatarUrl",
wi.created_at AS created_at
FROM workspace_invitations wi
INNER JOIN users u ON u.id = wi.invitee_user_id
WHERE wi.workspace_id = ${workspaceId}
) roles
WHERE true
${after}
ORDER BY created_at ASC
OFFSET ${pagination.offset}
LIMIT ${pagination.first}
`;
return rows.map(row => rawCompatRowToCompat(row));
}
export async function searchCompatRows(
db: WorkspaceUserCompatDb,
workspaceId: string,
query: string,
pagination: { first: number; offset: number; after?: string | Date }
) {
const after = pagination.after
? Prisma.sql`AND wm.created_at >= ${pagination.after}::timestamptz`
: Prisma.empty;
const rows = await db.$queryRaw<WorkspaceUserCompatRow[]>`
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
WHEN 'owner' THEN ${WorkspaceRole.Owner}
WHEN 'admin' THEN ${WorkspaceRole.Admin}
ELSE ${WorkspaceRole.Collaborator}
END AS type,
'Accepted'::"WorkspaceMemberStatus" AS status,
CASE wm.source
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
ELSE 'Email'::"WorkspaceMemberSource"
END AS source,
NULL::varchar AS "inviterId",
wm.created_at AS "createdAt",
wm.updated_at AS "updatedAt",
u.name AS "userName",
u.email AS "userEmail",
u.avatar_url AS "userAvatarUrl"
FROM workspace_members wm
INNER JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id = ${workspaceId}
AND wm.state = 'active'
AND (u.email ILIKE ${`%${query}%`} OR u.name ILIKE ${`%${query}%`})
${after}
ORDER BY wm.created_at ASC
OFFSET ${pagination.offset}
LIMIT ${pagination.first}
`;
return rows.map(row => rawCompatRowToCompat(row));
}
export async function countWorkspaceUsers(
db: WorkspaceUserCompatDb,
workspaceId: string
) {
const [members, invitations] = await Promise.all([
db.workspaceMember.count({
where: { workspaceId, state: 'active' },
}),
db.workspaceInvitation.count({ where: { workspaceId } }),
]);
return members + invitations;
}
export async function countChargedWorkspaceUsers(
db: WorkspaceUserCompatDb,
workspaceId: string
) {
const [members, invitations] = await Promise.all([
db.workspaceMember.count({
where: { workspaceId, state: 'active' },
}),
db.workspaceInvitation.count({
where: {
workspaceId,
status: {
not: 'waiting_review',
},
},
}),
]);
return members + invitations;
}
export async function findUserActiveWorkspaceRoles(
db: WorkspaceUserCompatDb,
userId: string,
filter: { role?: WorkspaceRole } = {}
) {
const roles = await db.workspaceMember.findMany({
where: {
userId,
state: 'active',
role: filter.role ? workspaceRoleToNewFilter(filter.role) : undefined,
},
});
return roles.map(role => workspaceMemberToCompat(role));
}
export async function hasSharedWorkspace(
db: WorkspaceUserCompatDb,
userId: string,
otherUserId: string
) {
if (userId === otherUserId) {
return true;
}
const shared = await db.$queryRaw<{ id: string }[]>`
SELECT mine.id
FROM workspace_members mine
INNER JOIN workspace_members other
ON other.workspace_id = mine.workspace_id
AND other.user_id = ${otherUserId}
AND other.state = 'active'
WHERE mine.user_id = ${userId}
AND mine.state = 'active'
LIMIT 1
`;
return shared.length > 0;
}
export async function allocateWorkspaceSeats(
db: WorkspaceUserCompatDb,
models: {
permissionProjection: { markNewWriteOrigin(): Promise<void> };
workspaceMember: {
setActive(
workspaceId: string,
userId: string,
role: WorkspaceRole
): Promise<unknown>;
};
},
workspaceId: string,
limit: number
) {
await models.permissionProjection.markNewWriteOrigin();
const [activeCount, pendingCount] = await Promise.all([
db.workspaceMember.count({
where: {
workspaceId,
state: 'active',
},
}),
db.workspaceInvitation.count({
where: {
workspaceId,
status: 'pending',
},
}),
]);
const usedCount = activeCount + pendingCount;
if (limit <= usedCount) {
return [];
}
const invitationsToAllocate = await db.workspaceInvitation.findMany({
where: {
workspaceId,
status: 'waiting_seat',
inviteeUserId: {
not: null,
},
},
orderBy: { createdAt: 'asc' },
take: limit - usedCount,
});
const groups = groupBy(invitationsToAllocate, invitation =>
workspaceSourceFromNew(invitation.kind as never)
) as Record<WorkspaceMemberSource, WorkspaceInvitation[]>;
if (groups.Email?.length > 0) {
await db.workspaceInvitation.updateMany({
where: { id: { in: groups.Email.map(invitation => invitation.id) } },
data: { status: 'pending' },
});
}
if (groups.Link?.length > 0) {
await Promise.all(
groups.Link.map(invitation =>
models.workspaceMember.setActive(
invitation.workspaceId,
invitation.inviteeUserId as string,
invitation.requestedRole === 'admin'
? WorkspaceRole.Admin
: WorkspaceRole.Collaborator
)
)
);
}
return (groups.Email ?? []).map(invitation =>
workspaceInvitationToCompat({
...invitation,
status: 'pending',
})
);
}
export function workspaceRoleToNewFilter(role: WorkspaceRole) {
switch (role) {
case WorkspaceRole.Owner:
return 'owner';
case WorkspaceRole.Admin:
return 'admin';
case WorkspaceRole.Collaborator:
return 'member';
default:
return undefined;
}
}
@@ -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
);
}
}
+49 -15
View File
@@ -90,9 +90,11 @@ export class WorkspaceModel extends BaseModel {
*/
@Transactional()
async create(userId: string) {
const workspace = await this.db.workspace.create({
data: { public: false },
});
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.create({
data: { public: false },
})
);
this.logger.log(`Workspace created with id ${workspace.id}`);
await this.models.workspaceUser.setOwner(workspace.id, userId);
return workspace;
@@ -106,12 +108,26 @@ export class WorkspaceModel extends BaseModel {
data: UpdateWorkspaceInput,
notifyUpdate = true
) {
const workspace = await this.db.workspace.update({
where: {
id: workspaceId,
},
data,
});
if (
data.public !== undefined ||
data.enableSharing !== undefined ||
data.enableUrlPreview !== undefined
) {
await this.models.workspaceAccessPolicy.upsert(workspaceId, {
public: data.public,
enableSharing: data.enableSharing,
enableUrlPreview: data.enableUrlPreview,
});
}
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.update({
where: {
id: workspaceId,
},
data,
})
);
this.logger.debug(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
);
@@ -155,11 +171,13 @@ export class WorkspaceModel extends BaseModel {
}
async delete(workspaceId: string) {
const rawResult = await this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
});
const rawResult = await this.withPermissionProjectionMetric(
this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
})
);
if (rawResult.count > 0) {
this.event.emit('workspace.deleted', { id: workspaceId });
@@ -183,7 +201,23 @@ export class WorkspaceModel extends BaseModel {
}
async isTeamWorkspace(workspaceId: string) {
return this.models.workspaceFeature.has(workspaceId, 'team_plan_v1');
const now = new Date();
const count = await this.db.entitlement.count({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: { in: ['team', 'selfhost_team'] },
OR: [
{
status: 'active',
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
{ status: 'grace', graceUntil: { gt: now } },
],
},
});
return count > 0;
}
// #endregion