feat(server): page model (#9715)

This commit is contained in:
fengmk2
2025-01-17 06:16:51 +00:00
parent 5c934c64aa
commit 46aa25de0b
5 changed files with 441 additions and 0 deletions

View File

@@ -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<Context>;
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);
});

View File

@@ -1,2 +1,3 @@
export * from './feature';
export * from './page';
export * from './permission';

View File

@@ -0,0 +1,4 @@
export enum PublicPageMode {
Page,
Edgeless,
}

View File

@@ -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';

View File

@@ -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<PageUserPermission> {
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
}