space updates

* space UI
* space management
* space permissions
* other fixes
This commit is contained in:
Philipinho
2024-04-12 19:38:58 +01:00
parent b02cfd02f0
commit 90ae750d48
54 changed files with 1966 additions and 365 deletions

View File

@ -49,7 +49,7 @@ export async function up(db: Kysely<any>): Promise<void> {
col.references('spaces.id').onDelete('cascade').notNull(),
)
.addColumn('role', 'varchar', (col) => col.notNull())
.addColumn('creator_id', 'uuid', (col) => col.references('users.id'))
.addColumn('addedById', 'uuid', (col) => col.references('users.id'))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)

View File

@ -1,30 +1,94 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import {
InsertableSpaceMember,
SpaceMember,
UpdatableSpaceMember,
} from '@docmost/db/types/entity.types';
import { PaginationOptions } from '../../pagination/pagination-options';
import { MemberInfo } from './types';
import { sql } from 'kysely';
import { MemberInfo, UserSpaceRole } from './types';
import { executeWithPagination } from '@docmost/db/pagination/pagination';
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
@Injectable()
export class SpaceMemberRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly groupRepo: GroupRepo,
) {}
async insertSpaceMember(
insertableSpaceMember: InsertableSpaceMember,
trx?: KyselyTransaction,
): Promise<SpaceMember> {
): Promise<void> {
const db = dbOrTx(this.db, trx);
return db
await db
.insertInto('spaceMembers')
.values(insertableSpaceMember)
.returningAll()
.execute();
}
async updateSpaceMember(
updatableSpaceMember: UpdatableSpaceMember,
spaceMemberId: string,
spaceId: string,
): Promise<void> {
await this.db
.updateTable('spaceMembers')
.set(updatableSpaceMember)
.where('id', '=', spaceMemberId)
.where('spaceId', '=', spaceId)
.execute();
}
async getSpaceMemberByTypeId(
spaceId: string,
opts: {
userId?: string;
groupId?: string;
},
trx?: KyselyTransaction,
): Promise<SpaceMember> {
const db = dbOrTx(this.db, trx);
let query = db
.selectFrom('spaceMembers')
.selectAll()
.where('spaceId', '=', spaceId);
if (opts.userId) {
query = query.where('userId', '=', opts.userId);
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
} else {
throw new BadRequestException('Please provider a userId or groupId');
}
return query.executeTakeFirst();
}
async removeSpaceMemberById(
memberId: string,
spaceId: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('spaceMembers')
.where('id', '=', memberId)
.where('spaceId', '=', spaceId)
.execute();
}
async roleCountBySpaceId(role: string, spaceId: string): Promise<number> {
const { count } = await this.db
.selectFrom('spaceMembers')
.select((eb) => eb.fn.count('role').as('count'))
.where('role', '=', role)
.where('spaceId', '=', spaceId)
.executeTakeFirst();
return count as number;
}
async getSpaceMembersPaginated(
@ -36,15 +100,17 @@ export class SpaceMemberRepo {
.leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
'users.email as userEmail',
'groups.id as groupId',
'groups.name as groupName',
'groups.isDefault as groupIsDefault',
'spaceMembers.role',
'spaceMembers.createdAt',
])
.select((eb) => this.groupRepo.withMemberCount(eb))
.where('spaceId', '=', spaceId)
.orderBy('spaceMembers.createdAt', 'asc');
@ -65,10 +131,10 @@ export class SpaceMemberRepo {
type: 'user',
};
} else if (member.groupId) {
// todo: get group member count
memberInfo = {
id: member.groupId,
name: member.groupName,
memberCount: member.memberCount as number,
isDefault: member.groupIsDefault,
type: 'group',
};
@ -77,200 +143,45 @@ export class SpaceMemberRepo {
return {
...memberInfo,
role: member.role,
createdAt: member.createdAt,
};
});
return members;
}
result.items = members as any;
/*
* we want to get all the spaces a user belongs either directly or via a group
* we will pass the user id and workspace id as parameters
* if the user is a member of the space via multiple groups
* we will return the one with the highest role permission
* it should return an array
* Todo: needs more work. this is a draft
*/
async getUserSpaces(userId: string, workspaceId: string) {
const rolePriority = sql`CASE "spaceMembers"."role"
WHEN 'owner' THEN 3
WHEN 'writer' THEN 2
WHEN 'reader' THEN 1
END`.as('role_priority');
const subquery = this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.select([
'spaces.id',
'spaces.name',
'spaces.slug',
'spaces.icon',
'spaceMembers.role',
rolePriority,
])
.where('spaceMembers.userId', '=', userId)
.where('spaces.workspaceId', '=', workspaceId)
.unionAll(
this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.innerJoin('groupUsers', 'spaceMembers.groupId', 'groupUsers.groupId')
.select([
'spaces.id',
'spaces.name',
'spaces.slug',
'spaces.icon',
'spaceMembers.role',
rolePriority,
])
.where('groupUsers.userId', '=', userId),
)
.as('membership');
const results = await this.db
.selectFrom(subquery)
.select([
'membership.id as space_id',
'membership.name as space_name',
'membership.slug as space_slug',
sql`MAX('role_priority')`.as('max_role_priority'),
sql`CASE MAX("role_priority")
WHEN 3 THEN 'owner'
WHEN 2 THEN 'writer'
WHEN 1 THEN 'reader'
END`.as('highest_role'),
])
.groupBy('membership.id')
.groupBy('membership.name')
.groupBy('membership.slug')
.execute();
let membership = {};
const spaces = results.map((result) => {
membership = {
id: result.space_id,
name: result.space_name,
role: result.highest_role,
};
return membership;
});
return spaces;
return result;
}
/*
* we want to get a user's role in a space.
* they user can be a member either directly or via a group
* we will pass the user id and space id and workspaceId to return the user's role
* we will pass the user id and space id to return the user's roles
* if the user is a member of the space via multiple groups
* we will return the one with the highest role permission
* It returns the space id, space name, user role
* and how the role was derived 'via'
* if the user has no space permission (not a member) it returns undefined
* if the user has no space permission it should return an empty array,
* maybe we should throw an exception?
*/
async getUserRoleInSpace(
async getUserSpaceRoles(
userId: string,
spaceId: string,
workspaceId: string,
) {
const rolePriority = sql`CASE "spaceMembers"."role"
WHEN 'owner' THEN 3
WHEN 'writer' THEN 2
WHEN 'reader' THEN 1
END`.as('role_priority');
const subquery = this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.select([
'spaces.id',
'spaces.name',
'spaceMembers.role',
'spaceMembers.userId',
rolePriority,
])
.where('spaceMembers.userId', '=', userId)
.where('spaces.id', '=', spaceId)
.where('spaces.workspaceId', '=', workspaceId)
): Promise<UserSpaceRole[]> {
const roles = await this.db
.selectFrom('spaceMembers')
.select(['userId', 'role'])
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.unionAll(
this.db
.selectFrom('spaces')
.innerJoin('spaceMembers', 'spaces.id', 'spaceMembers.spaceId')
.innerJoin('groupUsers', 'spaceMembers.groupId', 'groupUsers.groupId')
.select([
'spaces.id',
'spaces.name',
'spaceMembers.role',
'spaceMembers.userId',
rolePriority,
])
.where('spaces.id', '=', spaceId)
.where('spaces.workspaceId', '=', workspaceId)
.where('groupUsers.userId', '=', userId),
.selectFrom('spaceMembers')
.innerJoin('groupUsers', 'groupUsers.groupId', 'spaceMembers.groupId')
.select(['groupUsers.userId', 'spaceMembers.role'])
.where('groupUsers.userId', '=', userId)
.where('spaceMembers.spaceId', '=', spaceId),
)
.as('membership');
.execute();
const result = await this.db
.selectFrom(subquery)
.select([
'membership.id as space_id',
'membership.name as space_name',
'membership.userId as user_id',
sql`MAX('role_priority')`.as('max_role_priority'),
sql`CASE MAX("role_priority")
WHEN 3 THEN 'owner'
WHEN 2 THEN 'writer'
WHEN 1 THEN 'reader'
END`.as('highest_role'),
])
.groupBy('membership.id')
.groupBy('membership.name')
.groupBy('membership.userId')
.executeTakeFirst();
let membership = {};
if (result) {
membership = {
id: result.space_id,
name: result.space_name,
role: result.highest_role,
via: result.user_id ? 'user' : 'group', // user_id is empty then role was derived via a group
};
return membership;
if (roles.length < 1) {
return undefined;
}
return undefined;
}
async getSpaceMemberById(
userId: string,
groupId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('spaceMembers')
.selectAll()
.where('userId', '=', userId)
.where('groupId', '=', groupId)
.executeTakeFirst();
}
async removeUser(userId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('spaceMembers')
.where('userId', '=', userId)
.where('spaceId', '=', spaceId)
.execute();
}
async removeGroup(groupId: string, spaceId: string): Promise<void> {
await this.db
.deleteFrom('spaceMembers')
.where('userId', '=', groupId)
.where('spaceId', '=', spaceId)
.execute();
return roles;
}
}

View File

@ -16,34 +16,29 @@ import { DB } from '@docmost/db/types/db';
export class SpaceRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
private baseFields: Array<keyof Space> = [
'id',
'name',
'description',
'slug',
'icon',
'visibility',
'defaultRole',
'workspaceId',
'creatorId',
'createdAt',
'updatedAt',
'deletedAt',
];
async findById(spaceId: string, workspaceId: string): Promise<Space> {
async findById(
spaceId: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
): Promise<Space> {
return await this.db
.selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)])
.selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async findBySlug(slug: string, workspaceId: string): Promise<Space> {
async findBySlug(
slug: string,
workspaceId: string,
opts?: { includeMemberCount: boolean },
): Promise<Space> {
return await this.db
.selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)])
.selectAll('spaces')
.$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount))
.where(sql`LOWER(slug)`, '=', sql`LOWER(${slug})`)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
@ -62,7 +57,7 @@ export class SpaceRepo {
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
count = count as number;
return count == 0 ? false : true;
return count != 0;
}
async updateSpace(
@ -77,7 +72,8 @@ export class SpaceRepo {
.set(updatableSpace)
.where('id', '=', spaceId)
.where('workspaceId', '=', workspaceId)
.execute();
.returningAll()
.executeTakeFirst();
}
async insertSpace(
@ -99,7 +95,8 @@ export class SpaceRepo {
// todo: show spaces user have access based on visibility and memberships
let query = this.db
.selectFrom('spaces')
.select((eb) => [...this.baseFields, this.countSpaceMembers(eb)])
.selectAll('spaces')
.select((eb) => [this.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
@ -121,11 +118,17 @@ export class SpaceRepo {
return result;
}
countSpaceMembers(eb: ExpressionBuilder<DB, 'spaces'>) {
// should get unique members via groups?
withMemberCount(eb: ExpressionBuilder<DB, 'spaces'>) {
return eb
.selectFrom('spaceMembers')
.select((eb) => eb.fn.countAll().as('count'))
.innerJoin('groups', 'groups.id', 'spaceMembers.groupId')
.innerJoin('groupUsers', 'groupUsers.groupId', 'groups.id')
.select((eb) =>
eb.fn
.count(sql`concat(space_members.user_id, group_users.user_id)`)
.distinct()
.as('count'),
)
.whereRef('spaceMembers.spaceId', '=', 'spaces.id')
.as('memberCount');
}

View File

@ -1,3 +1,8 @@
export interface UserSpaceRole {
userId: string;
role: string;
}
interface SpaceUserInfo {
id: string;
name: string;
@ -10,7 +15,7 @@ interface SpaceGroupInfo {
id: string;
name: string;
isDefault: boolean;
memberCount?: number;
memberCount: number;
type: 'group';
}

View File

@ -0,0 +1,23 @@
import { UserSpaceRole } from '@docmost/db/repos/space/types';
import { SpaceRole } from '../../../helpers/types/permission';
export function findHighestUserSpaceRole(userSpaceRoles: UserSpaceRole[]) {
if (!userSpaceRoles) {
return undefined;
}
const roleOrder: { [key in SpaceRole]: number } = {
[SpaceRole.ADMIN]: 3,
[SpaceRole.WRITER]: 2,
[SpaceRole.READER]: 1,
};
let highestRole: string;
for (const userSpaceRole of userSpaceRoles) {
const currentRole = userSpaceRole.role;
if (!highestRole || roleOrder[currentRole] > roleOrder[highestRole]) {
highestRole = currentRole;
}
}
return highestRole;
}

View File

@ -1,10 +1,15 @@
import type { ColumnType } from "kysely";
import type { ColumnType } from 'kysely';
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Generated<T> =
T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
export type Int8 = ColumnType<
string,
bigint | number | string,
bigint | number | string
>;
export type Json = JsonValue;