mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-24 04:27:27 +08:00
feat(server): page model (#9715)
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './feature';
|
export * from './feature';
|
||||||
|
export * from './page';
|
||||||
export * from './permission';
|
export * from './permission';
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum PublicPageMode {
|
||||||
|
Page,
|
||||||
|
Edgeless,
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ModuleRef } from '@nestjs/core';
|
|||||||
|
|
||||||
import { ApplyType } from '../base';
|
import { ApplyType } from '../base';
|
||||||
import { FeatureModel } from './feature';
|
import { FeatureModel } from './feature';
|
||||||
|
import { PageModel } from './page';
|
||||||
import { MODELS_SYMBOL } from './provider';
|
import { MODELS_SYMBOL } from './provider';
|
||||||
import { SessionModel } from './session';
|
import { SessionModel } from './session';
|
||||||
import { UserModel } from './user';
|
import { UserModel } from './user';
|
||||||
@@ -20,6 +21,7 @@ const MODELS = {
|
|||||||
verificationToken: VerificationTokenModel,
|
verificationToken: VerificationTokenModel,
|
||||||
feature: FeatureModel,
|
feature: FeatureModel,
|
||||||
workspace: WorkspaceModel,
|
workspace: WorkspaceModel,
|
||||||
|
page: PageModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelsType = {
|
type ModelsType = {
|
||||||
@@ -72,6 +74,7 @@ const ModelsSymbolProvider: ExistingProvider = {
|
|||||||
export class ModelModules {}
|
export class ModelModules {}
|
||||||
|
|
||||||
export * from './feature';
|
export * from './feature';
|
||||||
|
export * from './page';
|
||||||
export * from './session';
|
export * from './session';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
export * from './verification-token';
|
export * from './verification-token';
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user