mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 01:42:37 +10:00
space updates
* space UI * space management * space permissions * other fixes
This commit is contained in:
@ -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),
|
||||
|
||||
@ -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');
|
||||
|
||||
68
apps/server/src/core/casl/abilities/space-ability.factory.ts
Normal file
68
apps/server/src/core/casl/abilities/space-ability.factory.ts
Normal 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();
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
15
apps/server/src/core/casl/interfaces/space-ability.type.ts
Normal file
15
apps/server/src/core/casl/interfaces/space-ability.type.ts
Normal 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];
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
31
apps/server/src/core/space/dto/add-space-members.dto.ts
Normal file
31
apps/server/src/core/space/dto/add-space-members.dto.ts
Normal 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[];
|
||||
}
|
||||
14
apps/server/src/core/space/dto/remove-space-member.dto.ts
Normal file
14
apps/server/src/core/space/dto/remove-space-member.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -100,7 +100,7 @@ export class WorkspaceService {
|
||||
await this.spaceMemberService.addUserToSpace(
|
||||
user.id,
|
||||
createdSpace.id,
|
||||
SpaceRole.OWNER,
|
||||
SpaceRole.ADMIN,
|
||||
workspace.id,
|
||||
trx,
|
||||
);
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()`),
|
||||
)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
23
apps/server/src/kysely/repos/space/utils.ts
Normal file
23
apps/server/src/kysely/repos/space/utils.ts
Normal 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;
|
||||
}
|
||||
15
apps/server/src/kysely/types/db.d.ts
vendored
15
apps/server/src/kysely/types/db.d.ts
vendored
@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user