mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 10:06:17 +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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user