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

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