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

@ -22,7 +22,10 @@ export class TokenService {
return this.jwtService.sign(payload);
}
async generateRefreshToken(userId: string, workspaceId): Promise<string> {
async generateRefreshToken(
userId: string,
workspaceId: string,
): Promise<string> {
const payload: JwtRefreshPayload = {
sub: userId,
workspaceId,
@ -32,7 +35,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn });
}
async generateTokens(user): Promise<TokensDto> {
async generateTokens(user: User): Promise<TokensDto> {
return {
accessToken: await this.generateAccessToken(user),
refreshToken: await this.generateRefreshToken(user.id, user.workspaceId),

View File

@ -47,7 +47,7 @@ export default class CaslAbilityFactory {
}
if (userRole === UserRole.MEMBER) {
// can<any>([Action.Read], WorkspaceUser);
can([Action.Read], 'WorkspaceUser');
// Groups
can([Action.Read], 'Group');

View File

@ -0,0 +1,68 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import {
AbilityBuilder,
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { SpaceRole } from '../../../helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
SpaceCaslAction,
SpaceAbility,
SpaceCaslSubject,
} from '../interfaces/space-ability.type';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
@Injectable()
export default class SpaceAbilityFactory {
constructor(private readonly spaceMemberRepo: SpaceMemberRepo) {}
async createForUser(user: User, spaceId: string) {
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
user.id,
spaceId,
);
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
switch (userSpaceRole) {
case SpaceRole.ADMIN:
return buildSpaceAdminAbility();
case SpaceRole.WRITER:
return buildSpaceWriterAbility();
case SpaceRole.READER:
return buildSpaceReaderAbility();
default:
throw new ForbiddenException(
'You do not have permission to access this space',
);
}
}
}
function buildSpaceAdminAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
return build();
}
function buildSpaceWriterAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
return build();
}
function buildSpaceReaderAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<SpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
return build();
}

View File

@ -1,9 +1,10 @@
import { Global, Module } from '@nestjs/common';
import CaslAbilityFactory from './abilities/casl-ability.factory';
import SpaceAbilityFactory from './abilities/space-ability.factory';
@Global()
@Module({
providers: [CaslAbilityFactory],
exports: [CaslAbilityFactory],
providers: [CaslAbilityFactory, SpaceAbilityFactory],
exports: [CaslAbilityFactory, SpaceAbilityFactory],
})
export class CaslModule {}

View File

@ -0,0 +1,15 @@
export enum SpaceCaslAction {
Manage = 'manage',
Create = 'create',
Read = 'read',
Edit = 'edit',
Delete = 'delete',
}
export enum SpaceCaslSubject {
Settings = 'settings',
Member = 'member',
}
export type SpaceAbility =
| [SpaceCaslAction, SpaceCaslSubject.Settings]
| [SpaceCaslAction, SpaceCaslSubject.Member];

View File

@ -31,6 +31,8 @@ export class GroupController {
private readonly groupUserService: GroupUserService,
) {}
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, 'Group'))
@HttpCode(HttpStatus.OK)
@Post('/')
getWorkspaceGroups(
@ -62,7 +64,6 @@ export class GroupController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
console.log(createGroupDto);
return this.groupService.createGroup(user, workspace.id, createGroupDto);
}

View File

@ -1,4 +1,4 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class SearchDTO {
@IsString()
@ -16,3 +16,16 @@ export class SearchDTO {
@IsNumber()
offset?: number;
}
export class SearchSuggestionDTO {
@IsString()
query: string;
@IsOptional()
@IsBoolean()
includeUsers?: string;
@IsOptional()
@IsBoolean()
includeGroups?: number;
}

View File

@ -8,7 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { SearchService } from './search.service';
import { SearchDTO } from './dto/search.dto';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { Workspace } from '@docmost/db/types/entity.types';
@ -21,17 +21,21 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
async pageSearch(
@Query('type') type: string,
@Body() searchDto: SearchDTO,
@AuthWorkspace() workspace: Workspace,
) {
if (!type || type === 'page') {
return this.searchService.searchPage(
searchDto.query,
searchDto,
workspace.id,
);
}
return;
return this.searchService.searchPage(
searchDto.query,
searchDto,
workspace.id,
);
}
@Post('suggest')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthWorkspace() workspace: Workspace,
) {
return this.searchService.searchSuggestions(dto, workspace.id);
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { SearchDTO } from './dto/search.dto';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
@ -57,4 +57,38 @@ export class SearchService {
return searchResults;
}
async searchSuggestions(
suggestion: SearchSuggestionDTO,
workspaceId: string,
) {
const limit = 25;
const userSearch = this.db
.selectFrom('users')
.select(['id', 'name', 'avatarUrl'])
.where((eb) => eb('users.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
const groupSearch = this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) => eb('groups.name', 'ilike', `%${suggestion.query}%`))
.where('workspaceId', '=', workspaceId)
.limit(limit);
let users = [];
let groups = [];
if (suggestion.includeUsers) {
users = await userSearch.execute();
}
if (suggestion.includeGroups) {
groups = await groupSearch.execute();
}
return { users, groups };
}
}

View File

@ -0,0 +1,31 @@
import {
ArrayMaxSize,
IsArray,
IsEnum,
IsUUID,
} from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
import { SpaceRole } from '../../../helpers/types/permission';
export class AddSpaceMembersDto extends SpaceIdDto {
// @IsOptional()
// @IsUUID()
// userId: string;
@IsEnum(SpaceRole)
role: string;
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must an array with no more than 25 elements',
})
@IsUUID(4, { each: true })
userIds: string[];
@IsArray()
@ArrayMaxSize(25, {
message: 'userIds must an array with no more than 25 elements',
})
@IsUUID(4, { each: true })
groupIds: string[];
}

View File

@ -0,0 +1,14 @@
import { IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
export class RemoveSpaceMemberDto extends SpaceIdDto {
@IsOptional()
@IsNotEmpty()
@IsUUID()
userId: string;
@IsOptional()
@IsNotEmpty()
@IsUUID()
groupId: string;
}

View File

@ -0,0 +1,18 @@
import { IsEnum, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
import { SpaceIdDto } from './space-id.dto';
import { SpaceRole } from '../../../helpers/types/permission';
export class UpdateSpaceMemberRoleDto extends SpaceIdDto {
@IsOptional()
@IsNotEmpty()
@IsUUID()
userId: string;
@IsOptional()
@IsNotEmpty()
@IsUUID()
groupId: string;
@IsEnum(SpaceRole)
role: string;
}

View File

@ -1,4 +1,10 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSpaceDto } from './create-space.dto';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {}
export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
@IsString()
@IsNotEmpty()
@IsUUID()
spaceId: string;
}

View File

@ -1,12 +1,26 @@
import { Injectable } from '@nestjs/common';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { SpaceMember } from '@docmost/db/types/entity.types';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely';
import { SpaceMember, User } from '@docmost/db/types/entity.types';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../helpers/types/permission';
@Injectable()
export class SpaceMemberService {
constructor(private spaceMemberRepo: SpaceMemberRepo) {}
constructor(
private spaceMemberRepo: SpaceMemberRepo,
private spaceRepo: SpaceRepo,
@InjectKysely() private readonly db: KyselyDB,
) {}
async addUserToSpace(
userId: string,
@ -14,11 +28,11 @@ export class SpaceMemberService {
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<SpaceMember> {
): Promise<void> {
//if (existingSpaceUser) {
// throw new BadRequestException('User already added to this space');
// }
return await this.spaceMemberRepo.insertSpaceMember(
await this.spaceMemberRepo.insertSpaceMember(
{
userId: userId,
spaceId: spaceId,
@ -34,13 +48,13 @@ export class SpaceMemberService {
role: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<SpaceMember> {
): Promise<void> {
//const existingSpaceUser = await manager.findOneBy(SpaceMember, {
// userId: userId,
// spaceId: spaceId,
// });
// validations?
return await this.spaceMemberRepo.insertSpaceMember(
await this.spaceMemberRepo.insertSpaceMember(
{
groupId: groupId,
spaceId: spaceId,
@ -59,7 +73,11 @@ export class SpaceMemberService {
workspaceId: string,
pagination: PaginationOptions,
) {
//todo: validate the space is inside the workspace
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const members = await this.spaceMemberRepo.getSpaceMembersPaginated(
spaceId,
pagination,
@ -67,35 +85,197 @@ export class SpaceMemberService {
return members;
}
}
/*
* get spaces a user is a member of
* either by direct membership or via groups
*/
/*
async getUserSpaces(
userId: string,
async addMembersToSpaceBatch(
dto: AddSpaceMembersDto,
authUser: User,
workspaceId: string,
paginationOptions: PaginationOptions,
) {
const [userSpaces, count] = await this.spaceMemberRepository
.createQueryBuilder('spaceMember')
.leftJoinAndSelect('spaceMember.space', 'space')
.where('spaceMember.userId = :userId', { userId })
.andWhere('space.workspaceId = :workspaceId', { workspaceId })
.loadRelationCountAndMap(
'space.memberCount',
'space.spaceMembers',
'spaceMembers',
)
.take(paginationOptions.limit)
.skip(paginationOptions.skip)
.getManyAndCount();
): Promise<void> {
// await this.spaceService.findAndValidateSpace(spaceId, workspaceId);
const spaces = userSpaces.map((userSpace) => userSpace.space);
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
const paginationMeta = new PaginationMetaDto({ count, paginationOptions });
return new PaginatedResult(spaces, paginationMeta);
// make sure we have valid workspace users
const validUsersQuery = this.db
.selectFrom('users')
.select(['id', 'name'])
.where('users.id', 'in', dto.userIds)
.where('users.workspaceId', '=', workspaceId)
// using this because we can not use easily use onConflict with two unique indexes.
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('spaceMembers')
.select('id')
.whereRef('spaceMembers.userId', '=', 'users.id')
.where('spaceMembers.spaceId', '=', dto.spaceId),
),
),
);
const validGroupsQuery = this.db
.selectFrom('groups')
.select(['id', 'name'])
.where('groups.id', 'in', dto.groupIds)
.where('groups.workspaceId', '=', workspaceId)
.where(({ not, exists, selectFrom }) =>
not(
exists(
selectFrom('spaceMembers')
.select('id')
.whereRef('spaceMembers.groupId', '=', 'groups.id')
.where('spaceMembers.spaceId', '=', dto.spaceId),
),
),
);
let validUsers = [],
validGroups = [];
if (dto.userIds && dto.userIds.length > 0) {
validUsers = await validUsersQuery.execute();
}
if (dto.groupIds && dto.groupIds.length > 0) {
validGroups = await validGroupsQuery.execute();
}
const usersToAdd = [];
for (const user of validUsers) {
usersToAdd.push({
spaceId: dto.spaceId,
userId: user.id,
role: dto.role,
creatorId: authUser.id,
});
}
const groupsToAdd = [];
for (const group of validGroups) {
groupsToAdd.push({
spaceId: dto.spaceId,
groupId: group.id,
role: dto.role,
creatorId: authUser.id,
});
}
const membersToAdd = [...usersToAdd, ...groupsToAdd];
if (membersToAdd.length > 0) {
await this.spaceMemberRepo.insertSpaceMember(membersToAdd);
} else {
// either they are already members or do not exist on the workspace
}
}
*/
async removeMemberFromSpace(
dto: RemoveSpaceMemberDto,
authUser: User, // Todo: permissions check
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
);
}
async updateSpaceMemberRole(
dto: UpdateSpaceMemberRoleDto,
authUser: User,
workspaceId: string,
): Promise<void> {
const space = await this.spaceRepo.findById(dto.spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
let spaceMember: SpaceMember = null;
if (dto.userId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
userId: dto.userId,
},
);
} else if (dto.groupId) {
spaceMember = await this.spaceMemberRepo.getSpaceMemberByTypeId(
dto.spaceId,
{
groupId: dto.groupId,
},
);
} else {
throw new BadRequestException(
'Please provide a valid userId or groupId to remove',
);
}
if (!spaceMember) {
throw new NotFoundException('Space membership not found');
}
if (spaceMember.role === dto.role) {
return;
}
if (spaceMember.role === SpaceRole.ADMIN) {
await this.validateLastAdmin(dto.spaceId);
}
await this.spaceMemberRepo.updateSpaceMember(
{ role: dto.role },
spaceMember.id,
dto.spaceId,
);
}
async validateLastAdmin(spaceId: string): Promise<void> {
const spaceOwnerCount = await this.spaceMemberRepo.roleCountBySpaceId(
SpaceRole.ADMIN,
spaceId,
);
if (spaceOwnerCount === 1) {
throw new BadRequestException(
'There must be at least one space admin with full access',
);
}
}
}

View File

@ -4,12 +4,13 @@ import {
NotFoundException,
} from '@nestjs/common';
import { CreateSpaceDto } from '../dto/create-space.dto';
import { PaginationOptions } from '../../../kysely/pagination/pagination-options';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import slugify from 'slugify';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { Space } from '@docmost/db/types/entity.types';
import { PaginationResult } from '@docmost/db/pagination/pagination';
import { UpdateSpaceDto } from '../dto/update-space.dto';
@Injectable()
export class SpaceService {
@ -44,8 +45,28 @@ export class SpaceService {
);
}
async updateSpace(
updateSpaceDto: UpdateSpaceDto,
workspaceId: string,
): Promise<Space> {
if (!updateSpaceDto.name && !updateSpaceDto.description) {
throw new BadRequestException('Please provide fields to update');
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
},
updateSpaceDto.spaceId,
workspaceId,
);
}
async getSpaceInfo(spaceId: string, workspaceId: string): Promise<Space> {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
const space = await this.spaceRepo.findById(spaceId, workspaceId, {
includeMemberCount: true,
});
if (!space) {
throw new NotFoundException('Space not found');
}

View File

@ -1,6 +1,8 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
Post,
@ -11,9 +13,18 @@ import { AuthUser } from '../../decorators/auth-user.decorator';
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '../../kysely/pagination/pagination-options';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { AddSpaceMembersDto } from './dto/add-space-members.dto';
import { RemoveSpaceMemberDto } from './dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from './dto/update-space-member-role.dto';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import { UpdateSpaceDto } from './dto/update-space.dto';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
@ -21,6 +32,7 @@ export class SpaceController {
constructor(
private readonly spaceService: SpaceService,
private readonly spaceMemberService: SpaceMemberService,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@ -35,23 +47,6 @@ export class SpaceController {
return this.spaceService.getWorkspaceSpaces(workspace.id, pagination);
}
// get all spaces user is a member of
/*
@HttpCode(HttpStatus.OK)
@Post('user')
async getUserSpaces(
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
return this.spaceMemberService.getUserSpaces(
user.id,
workspace.id,
pagination,
);
}*/
@HttpCode(HttpStatus.OK)
@Post('info')
async getSpaceInfo(
@ -59,23 +54,135 @@ export class SpaceController {
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.getSpaceInfo(spaceIdDto.spaceId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('update')
async updateGroup(
@Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
updateSpaceDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.updateSpace(updateSpaceDto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('members')
async getSpaceMembers(
// todo: accept type? users | groups
@Body() spaceIdDto: SpaceIdDto,
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.getSpaceMembers(
spaceIdDto.spaceId,
workspace.id,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/add')
async addSpaceMember(
@Body() dto: AddSpaceMembersDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (
(!dto.userIds || dto.userIds.length === 0) &&
(!dto.groupIds || dto.groupIds.length === 0)
) {
throw new BadRequestException('userIds or groupIds is required');
}
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.addMembersToSpaceBatch(
dto,
user,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/remove')
async removeSpaceMember(
@Body() dto: RemoveSpaceMemberDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.validateIds(dto);
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.removeMemberFromSpace(
dto,
user,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/role')
async updateSpaceMemberRole(
@Body() dto: UpdateSpaceMemberRoleDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
this.validateIds(dto);
const ability = await this.spaceAbility.createForUser(user, dto.spaceId);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Member)) {
throw new ForbiddenException();
}
return this.spaceMemberService.updateSpaceMemberRole(
dto,
user,
workspace.id,
);
}
validateIds(dto: RemoveSpaceMemberDto | UpdateSpaceMemberRoleDto) {
if (!dto.userId && !dto.groupId) {
throw new BadRequestException('userId or groupId is required');
}
if (dto.userId && dto.groupId) {
throw new BadRequestException(
'please provide either a userId or groupId and both',
);
}
}
}

View File

@ -1,8 +1,10 @@
import { PartialType } from '@nestjs/mapped-types';
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from '../../auth/dto/create-user.dto';
import { IsOptional, IsString } from 'class-validator';
export class UpdateUserDto extends PartialType(CreateUserDto) {
export class UpdateUserDto extends PartialType(
OmitType(CreateUserDto, ['password'] as const),
) {
@IsOptional()
@IsString()
avatarUrl: string;

View File

@ -5,7 +5,6 @@ import {
} from '@nestjs/common';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { hashPassword } from '../../helpers/utils';
@Injectable()
export class UserService {
@ -29,7 +28,6 @@ export class UserService {
user.name = updateUserDto.name;
}
// todo need workspace scoping
if (updateUserDto.email && user.email != updateUserDto.email) {
if (await this.userRepo.findByEmail(updateUserDto.email, workspaceId)) {
throw new BadRequestException('A user with this email already exists');
@ -41,10 +39,6 @@ export class UserService {
user.avatarUrl = updateUserDto.avatarUrl;
}
if (updateUserDto.password) {
updateUserDto.password = await hashPassword(updateUserDto.password);
}
await this.userRepo.updateUser(updateUserDto, userId, workspaceId);
return user;
}

View File

@ -100,7 +100,7 @@ export class WorkspaceService {
await this.spaceMemberService.addUserToSpace(
user.id,
createdSpace.id,
SpaceRole.OWNER,
SpaceRole.ADMIN,
workspace.id,
trx,
);

View File

@ -5,12 +5,12 @@ export enum UserRole {
}
export enum SpaceRole {
OWNER = 'owner', // can add members, remove, and delete space
ADMIN = 'admin', // can manage space settings, members, and delete space
WRITER = 'writer', // can read and write pages in space
READER = 'reader', // can only read pages in space
}
export enum SpaceVisibility {
OPEN = 'open', // any workspace member can see and join.
OPEN = 'open', // any workspace member can see that it exists and join.
PRIVATE = 'private', // only added space users can see
}

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;