From 46aa25de0beaa4b487e8af0652151d616955a30d Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Fri, 17 Jan 2025 06:16:51 +0000 Subject: [PATCH] feat(server): page model (#9715) --- .../server/src/__tests__/models/page.spec.ts | 232 ++++++++++++++++++ .../backend/server/src/models/common/index.ts | 1 + .../backend/server/src/models/common/page.ts | 4 + packages/backend/server/src/models/index.ts | 3 + packages/backend/server/src/models/page.ts | 201 +++++++++++++++ 5 files changed, 441 insertions(+) create mode 100644 packages/backend/server/src/__tests__/models/page.spec.ts create mode 100644 packages/backend/server/src/models/common/page.ts create mode 100644 packages/backend/server/src/models/page.ts diff --git a/packages/backend/server/src/__tests__/models/page.spec.ts b/packages/backend/server/src/__tests__/models/page.spec.ts new file mode 100644 index 0000000000..1d3a35da5f --- /dev/null +++ b/packages/backend/server/src/__tests__/models/page.spec.ts @@ -0,0 +1,232 @@ +import { TestingModule } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import ava, { TestFn } from 'ava'; + +import { Config } from '../../base/config'; +import { Permission, PublicPageMode } from '../../models/common'; +import { PageModel } from '../../models/page'; +import { type User, UserModel } from '../../models/user'; +import { type Workspace, WorkspaceModel } from '../../models/workspace'; +import { createTestingModule, initTestingDB } from '../utils'; + +interface Context { + config: Config; + module: TestingModule; + db: PrismaClient; + user: UserModel; + workspace: WorkspaceModel; + page: PageModel; +} + +const test = ava as TestFn; + +test.before(async t => { + const module = await createTestingModule(); + + t.context.user = module.get(UserModel); + t.context.workspace = module.get(WorkspaceModel); + t.context.page = module.get(PageModel); + t.context.db = module.get(PrismaClient); + t.context.config = module.get(Config); + t.context.module = module; +}); + +let user: User; +let workspace: Workspace; + +test.beforeEach(async t => { + await initTestingDB(t.context.db); + user = await t.context.user.create({ + email: 'test@affine.pro', + }); + workspace = await t.context.workspace.create(user.id); +}); + +test.after(async t => { + await t.context.module.close(); +}); + +test('should create page with default mode and public false', async t => { + const page = await t.context.page.upsert(workspace.id, 'page1'); + t.is(page.workspaceId, workspace.id); + t.is(page.pageId, 'page1'); + t.is(page.mode, PublicPageMode.Page); + t.is(page.public, false); +}); + +test('should update page', async t => { + const page = await t.context.page.upsert(workspace.id, 'page1'); + const data = { + mode: PublicPageMode.Edgeless, + public: true, + }; + await t.context.page.upsert(workspace.id, 'page1', data); + const page1 = await t.context.page.get(workspace.id, 'page1'); + t.deepEqual(page1, { + ...page, + ...data, + }); +}); + +test('should get null when page not exists', async t => { + const page = await t.context.page.get(workspace.id, 'page1'); + t.is(page, null); +}); + +test('should get page by id and public flag', async t => { + await t.context.page.upsert(workspace.id, 'page1'); + await t.context.page.upsert(workspace.id, 'page2', { + public: true, + }); + let page1 = await t.context.page.get(workspace.id, 'page1'); + t.is(page1!.public, false); + page1 = await t.context.page.get(workspace.id, 'page1', true); + t.is(page1, null); + let page2 = await t.context.page.get(workspace.id, 'page2', true); + t.is(page2!.public, true); + page2 = await t.context.page.get(workspace.id, 'page2', false); + t.is(page2, null); +}); + +test('should get public page count', async t => { + await t.context.page.upsert(workspace.id, 'page1', { + public: true, + }); + await t.context.page.upsert(workspace.id, 'page2', { + public: true, + }); + await t.context.page.upsert(workspace.id, 'page3'); + const count = await t.context.page.getPublicsCount(workspace.id); + t.is(count, 2); +}); + +test('should get public pages of a workspace', async t => { + await t.context.page.upsert(workspace.id, 'page1', { + public: true, + }); + await t.context.page.upsert(workspace.id, 'page2', { + public: true, + }); + await t.context.page.upsert(workspace.id, 'page3'); + const pages = await t.context.page.findPublics(workspace.id); + t.is(pages.length, 2); + t.deepEqual(pages.map(p => p.pageId).sort(), ['page1', 'page2']); +}); + +test('should grant a member to access a page', async t => { + await t.context.page.upsert(workspace.id, 'page1', { + public: true, + }); + const member = await t.context.user.create({ + email: 'test1@affine.pro', + }); + await t.context.page.grantMember(workspace.id, 'page1', member.id); + let hasAccess = await t.context.page.isMember( + workspace.id, + 'page1', + member.id + ); + t.true(hasAccess); + hasAccess = await t.context.page.isMember( + workspace.id, + 'page1', + user.id, + Permission.Write + ); + t.false(hasAccess); + // grant write permission + await t.context.page.grantMember( + workspace.id, + 'page1', + user.id, + Permission.Write + ); + hasAccess = await t.context.page.isMember( + workspace.id, + 'page1', + user.id, + Permission.Write + ); + t.true(hasAccess); + hasAccess = await t.context.page.isMember( + workspace.id, + 'page1', + user.id, + Permission.Read + ); + t.true(hasAccess); + // delete member + const count = await t.context.page.deleteMember( + workspace.id, + 'page1', + user.id + ); + t.is(count, 1); + hasAccess = await t.context.page.isMember(workspace.id, 'page1', user.id); + t.false(hasAccess); +}); + +test('should change the page owner', async t => { + await t.context.page.upsert(workspace.id, 'page1', { + public: true, + }); + await t.context.page.grantMember( + workspace.id, + 'page1', + user.id, + Permission.Owner + ); + t.true( + await t.context.page.isMember( + workspace.id, + 'page1', + user.id, + Permission.Owner + ) + ); + + // change owner + const otherUser = await t.context.user.create({ + email: 'test1@affine.pro', + }); + await t.context.page.grantMember( + workspace.id, + 'page1', + otherUser.id, + Permission.Owner + ); + t.true( + await t.context.page.isMember( + workspace.id, + 'page1', + otherUser.id, + Permission.Owner + ) + ); + t.false( + await t.context.page.isMember( + workspace.id, + 'page1', + user.id, + Permission.Owner + ) + ); +}); + +test('should not delete owner from page', async t => { + await t.context.page.upsert(workspace.id, 'page1', { + public: true, + }); + await t.context.page.grantMember( + workspace.id, + 'page1', + user.id, + Permission.Owner + ); + const count = await t.context.page.deleteMember( + workspace.id, + 'page1', + user.id + ); + t.is(count, 0); +}); diff --git a/packages/backend/server/src/models/common/index.ts b/packages/backend/server/src/models/common/index.ts index 2314abfdf5..40656295c3 100644 --- a/packages/backend/server/src/models/common/index.ts +++ b/packages/backend/server/src/models/common/index.ts @@ -1,2 +1,3 @@ export * from './feature'; +export * from './page'; export * from './permission'; diff --git a/packages/backend/server/src/models/common/page.ts b/packages/backend/server/src/models/common/page.ts new file mode 100644 index 0000000000..cc7c6428f5 --- /dev/null +++ b/packages/backend/server/src/models/common/page.ts @@ -0,0 +1,4 @@ +export enum PublicPageMode { + Page, + Edgeless, +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index c16fcb0460..c52b01d8d7 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; import { FeatureModel } from './feature'; +import { PageModel } from './page'; import { MODELS_SYMBOL } from './provider'; import { SessionModel } from './session'; import { UserModel } from './user'; @@ -20,6 +21,7 @@ const MODELS = { verificationToken: VerificationTokenModel, feature: FeatureModel, workspace: WorkspaceModel, + page: PageModel, }; type ModelsType = { @@ -72,6 +74,7 @@ const ModelsSymbolProvider: ExistingProvider = { export class ModelModules {} export * from './feature'; +export * from './page'; export * from './session'; export * from './user'; export * from './verification-token'; diff --git a/packages/backend/server/src/models/page.ts b/packages/backend/server/src/models/page.ts new file mode 100644 index 0000000000..e5085e40d7 --- /dev/null +++ b/packages/backend/server/src/models/page.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@nestjs/common'; +import { + type WorkspacePage as Page, + type WorkspacePageUserPermission as PageUserPermission, +} from '@prisma/client'; + +import { BaseModel } from './base'; +import { Permission, PublicPageMode } from './common'; + +export type { Page }; +export type UpdatePageInput = { + mode?: PublicPageMode; + public?: boolean; +}; + +@Injectable() +export class PageModel extends BaseModel { + // #region page + + /** + * Create or update the page. + */ + async upsert(workspaceId: string, pageId: string, data?: UpdatePageInput) { + return await this.db.workspacePage.upsert({ + where: { + workspaceId_pageId: { + workspaceId, + pageId, + }, + }, + update: { + ...data, + }, + create: { + ...data, + workspaceId, + pageId, + }, + }); + } + + /** + * Get the page. + * @param isPublic: if true, only return the public page. If false, only return the private page. + * If not set, return public or private both. + */ + async get(workspaceId: string, pageId: string, isPublic?: boolean) { + return await this.db.workspacePage.findUnique({ + where: { + workspaceId_pageId: { + workspaceId, + pageId, + }, + public: isPublic, + }, + }); + } + + /** + * Find the workspace public pages. + */ + async findPublics(workspaceId: string) { + return await this.db.workspacePage.findMany({ + where: { + workspaceId, + public: true, + }, + }); + } + + /** + * Get the workspace public pages count. + */ + async getPublicsCount(workspaceId: string) { + return await this.db.workspacePage.count({ + where: { + workspaceId, + public: true, + }, + }); + } + + // #endregion + + // #region page member and permission + + /** + * Grant the page member with the given permission. + */ + async grantMember( + workspaceId: string, + pageId: string, + userId: string, + permission: Permission = Permission.Read + ): Promise { + let data = await this.db.workspacePageUserPermission.findUnique({ + where: { + workspaceId_pageId_userId: { + workspaceId, + pageId, + userId, + }, + }, + }); + + // If the user is already accepted and the new permission is owner, we need to revoke old owner + if (!data || data.type !== permission) { + return await this.db.$transaction(async tx => { + if (data) { + // Update the permission + data = await tx.workspacePageUserPermission.update({ + where: { + workspaceId_pageId_userId: { + workspaceId, + pageId, + userId, + }, + }, + data: { type: permission }, + }); + } else { + // Create a new permission + data = await tx.workspacePageUserPermission.create({ + data: { + workspaceId, + pageId, + userId, + type: permission, + // page permission does not require invitee to accept, the accepted field will be deprecated later. + accepted: true, + }, + }); + } + + // If the new permission is owner, we need to revoke old owner + if (permission === Permission.Owner) { + await tx.workspacePageUserPermission.updateMany({ + where: { + workspaceId, + pageId, + type: Permission.Owner, + userId: { not: userId }, + }, + data: { type: Permission.Admin }, + }); + this.logger.log( + `Change owner of workspace ${workspaceId} page ${pageId} to user ${userId}` + ); + } + return data; + }); + } + + // nothing to do + return data; + } + + /** + * Returns whether a given user is a member of a workspace and has the given or higher permission. + * Default to read permission. + */ + async isMember( + workspaceId: string, + pageId: string, + userId: string, + permission: Permission = Permission.Read + ) { + const count = await this.db.workspacePageUserPermission.count({ + where: { + workspaceId, + pageId, + userId, + type: { + gte: permission, + }, + }, + }); + return count > 0; + } + + /** + * Delete a page member + * Except the owner, the owner can't be deleted. + */ + async deleteMember(workspaceId: string, pageId: string, userId: string) { + const { count } = await this.db.workspacePageUserPermission.deleteMany({ + where: { + workspaceId, + pageId, + userId, + type: { + // We shouldn't revoke owner permission, should auto deleted by workspace/user delete cascading + not: Permission.Owner, + }, + }, + }); + return count; + } + + // #endregion +}