diff --git a/apps/server/src/core/group/dto/add-group-user.dto.ts b/apps/server/src/core/group/dto/add-group-user.dto.ts index 86ff372..8cc6048 100644 --- a/apps/server/src/core/group/dto/add-group-user.dto.ts +++ b/apps/server/src/core/group/dto/add-group-user.dto.ts @@ -1,8 +1,16 @@ -import { IsNotEmpty, IsUUID } from 'class-validator'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; import { GroupIdDto } from './group-id.dto'; export class AddGroupUserDto extends GroupIdDto { - @IsNotEmpty() - @IsUUID() - userId: string; + // @IsOptional() + // @IsUUID() + // userId: string; + + @IsArray() + @ArrayMaxSize(50, { + message: 'userIds must an array with no more than 50 elements', + }) + @ArrayMinSize(1) + @IsUUID(4, { each: true }) + userIds: string[]; } diff --git a/apps/server/src/core/group/dto/create-group.dto.ts b/apps/server/src/core/group/dto/create-group.dto.ts index ce05d42..a2b93be 100644 --- a/apps/server/src/core/group/dto/create-group.dto.ts +++ b/apps/server/src/core/group/dto/create-group.dto.ts @@ -1,16 +1,31 @@ -import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; export class CreateGroupDto { @MinLength(2) - @MaxLength(64) + @MaxLength(50) @IsString() name: string; @IsOptional() @IsString() description?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @IsUUID(4, { each: true }) + userIds?: string[]; } export enum DefaultGroup { - EVERYONE = 'internal_users', + EVERYONE = 'Everyone', + DESCRIPTION = 'Group for all users in this workspace.', } diff --git a/apps/server/src/core/group/dto/remove-group-user.dto.ts b/apps/server/src/core/group/dto/remove-group-user.dto.ts index c6dd185..4e868e6 100644 --- a/apps/server/src/core/group/dto/remove-group-user.dto.ts +++ b/apps/server/src/core/group/dto/remove-group-user.dto.ts @@ -1,3 +1,7 @@ -import { AddGroupUserDto } from './add-group-user.dto'; +import { GroupIdDto } from './group-id.dto'; +import { IsUUID } from 'class-validator'; -export class RemoveGroupUserDto extends AddGroupUserDto {} +export class RemoveGroupUserDto extends GroupIdDto { + @IsUUID() + userId: string; +} diff --git a/apps/server/src/core/group/group.controller.ts b/apps/server/src/core/group/group.controller.ts index fee1e9e..d122610 100644 --- a/apps/server/src/core/group/group.controller.ts +++ b/apps/server/src/core/group/group.controller.ts @@ -12,7 +12,7 @@ import { AuthUser } from '../../decorators/auth-user.decorator'; import { AuthWorkspace } from '../../decorators/auth-workspace.decorator'; import { GroupUserService } from './services/group-user.service'; import { GroupIdDto } from './dto/group-id.dto'; -import { PaginationOptions } from '../../kysely/pagination/pagination-options'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { AddGroupUserDto } from './dto/add-group-user.dto'; import { RemoveGroupUserDto } from './dto/remove-group-user.dto'; import { UpdateGroupDto } from './dto/update-group.dto'; @@ -62,6 +62,7 @@ export class GroupController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { + console.log(createGroupDto); return this.groupService.createGroup(user, workspace.id, createGroupDto); } @@ -104,8 +105,8 @@ export class GroupController { @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { - return this.groupUserService.addUserToGroup( - addGroupUserDto.userId, + return this.groupUserService.addUsersToGroupBatch( + addGroupUserDto.userIds, addGroupUserDto.groupId, workspace.id, ); diff --git a/apps/server/src/core/group/services/group-user.service.ts b/apps/server/src/core/group/services/group-user.service.ts index dbcf333..095475c 100644 --- a/apps/server/src/core/group/services/group-user.service.ts +++ b/apps/server/src/core/group/services/group-user.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; -import { PaginationOptions } from '../../../kysely/pagination/pagination-options'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { GroupService } from './group.service'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { executeTx } from '@docmost/db/utils'; @@ -17,8 +19,9 @@ export class GroupUserService { constructor( private groupRepo: GroupRepo, private groupUserRepo: GroupUserRepo, - private groupService: GroupService, private userRepo: UserRepo, + @Inject(forwardRef(() => GroupService)) + private groupService: GroupService, @InjectKysely() private readonly db: KyselyDB, ) {} @@ -55,6 +58,38 @@ export class GroupUserService { ); } + async addUsersToGroupBatch( + userIds: string[], + groupId: string, + workspaceId: string, + ): Promise { + await this.groupService.findAndValidateGroup(groupId, workspaceId); + + // make sure we have valid workspace users + const validUsers = await this.db + .selectFrom('users') + .select(['id', 'name']) + .where('users.id', 'in', userIds) + .where('users.workspaceId', '=', workspaceId) + .execute(); + + // prepare users to add to group + const groupUsersToInsert = []; + for (const user of validUsers) { + groupUsersToInsert.push({ + userId: user.id, + groupId: groupId, + }); + } + + // batch insert new group users + await this.db + .insertInto('groupUsers') + .values(groupUsersToInsert) + .onConflict((oc) => oc.columns(['userId', 'groupId']).doNothing()) + .execute(); + } + async addUserToGroup( userId: string, groupId: string, diff --git a/apps/server/src/core/group/services/group.service.ts b/apps/server/src/core/group/services/group.service.ts index 2d2501f..62db153 100644 --- a/apps/server/src/core/group/services/group.service.ts +++ b/apps/server/src/core/group/services/group.service.ts @@ -1,19 +1,38 @@ import { BadRequestException, + forwardRef, + Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto'; -import { PaginationOptions } from '../../../kysely/pagination/pagination-options'; +import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { UpdateGroupDto } from '../dto/update-group.dto'; import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { PaginationResult } from '@docmost/db/pagination/pagination'; +import { GroupUserService } from './group-user.service'; @Injectable() export class GroupService { - constructor(private groupRepo: GroupRepo) {} + constructor( + private groupRepo: GroupRepo, + @Inject(forwardRef(() => GroupUserService)) + private groupUserService: GroupUserService, + ) {} + + async getGroupInfo(groupId: string, workspaceId: string): Promise { + const group = await this.groupRepo.findById(groupId, workspaceId, { + includeMemberCount: true, + }); + + if (!group) { + throw new NotFoundException('Group not found'); + } + + return group; + } async createGroup( authUser: User, @@ -36,7 +55,17 @@ export class GroupService { workspaceId: workspaceId, }; - return await this.groupRepo.insertGroup(insertableGroup, trx); + const createdGroup = await this.groupRepo.insertGroup(insertableGroup, trx); + + if (createGroupDto?.userIds && createGroupDto.userIds.length > 0) { + await this.groupUserService.addUsersToGroupBatch( + createGroupDto.userIds, + createdGroup.id, + workspaceId, + ); + } + + return createdGroup; } async createDefaultGroup( @@ -60,6 +89,7 @@ export class GroupService { const group = await this.groupRepo.findById( updateGroupDto.groupId, workspaceId, + { includeMemberCount: true }, ); if (!group) { @@ -99,16 +129,6 @@ export class GroupService { return group; } - async getGroupInfo(groupId: string, workspaceId: string): Promise { - const group = await this.groupRepo.findById(groupId, workspaceId); - - if (!group) { - throw new NotFoundException('Group not found'); - } - - return group; - } - async getWorkspaceGroups( workspaceId: string, paginationOptions: PaginationOptions, diff --git a/apps/server/src/kysely/repos/group/group-user.repo.ts b/apps/server/src/kysely/repos/group/group-user.repo.ts index 1bbefcc..cffb481 100644 --- a/apps/server/src/kysely/repos/group/group-user.repo.ts +++ b/apps/server/src/kysely/repos/group/group-user.repo.ts @@ -2,12 +2,7 @@ import { 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 { - GroupUser, - InsertableGroupUser, - User, -} from '@docmost/db/types/entity.types'; -import { sql } from 'kysely'; +import { GroupUser, InsertableGroupUser } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '../../pagination/pagination-options'; import { executeWithPagination } from '@docmost/db/pagination/pagination'; @@ -45,7 +40,7 @@ export class GroupUserRepo { let query = this.db .selectFrom('groupUsers') .innerJoin('users', 'users.id', 'groupUsers.userId') - .select(sql`users.*` as any) + .selectAll('users') .where('groupId', '=', groupId) .orderBy('createdAt', 'asc'); @@ -55,11 +50,15 @@ export class GroupUserRepo { ); } - const result = executeWithPagination(query, { + const result = await executeWithPagination(query, { page: pagination.page, perPage: pagination.limit, }); + result.items.map((user) => { + delete user.password; + }); + return result; } diff --git a/apps/server/src/kysely/repos/group/group.repo.ts b/apps/server/src/kysely/repos/group/group.repo.ts index 2e7c627..5db93b1 100644 --- a/apps/server/src/kysely/repos/group/group.repo.ts +++ b/apps/server/src/kysely/repos/group/group.repo.ts @@ -16,38 +16,29 @@ import { executeWithPagination } from '@docmost/db/pagination/pagination'; export class GroupRepo { constructor(@InjectKysely() private readonly db: KyselyDB) {} - private baseFields: Array = [ - 'id', - 'name', - 'description', - 'isDefault', - 'workspaceId', - 'creatorId', - 'createdAt', - 'updatedAt', - ]; - - countGroupMembers(eb: ExpressionBuilder) { - return eb - .selectFrom('groupUsers') - .select((eb) => eb.fn.countAll().as('count')) - .whereRef('groupUsers.groupId', '=', 'groups.id') - .as('memberCount'); - } - - async findById(groupId: string, workspaceId: string): Promise { + async findById( + groupId: string, + workspaceId: string, + opts?: { includeMemberCount: boolean }, + ): Promise { return await this.db .selectFrom('groups') - .select((eb) => [...this.baseFields, this.countGroupMembers(eb)]) + .selectAll('groups') + .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .where('id', '=', groupId) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); } - async findByName(groupName: string, workspaceId: string): Promise { + async findByName( + groupName: string, + workspaceId: string, + opts?: { includeMemberCount: boolean }, + ): Promise { return await this.db .selectFrom('groups') - .select((eb) => [...this.baseFields, this.countGroupMembers(eb)]) + .selectAll('groups') + .$if(opts?.includeMemberCount, (qb) => qb.select(this.withMemberCount)) .where(sql`LOWER(name)`, '=', sql`LOWER(${groupName})`) .where('workspaceId', '=', workspaceId) .executeTakeFirst(); @@ -83,19 +74,24 @@ export class GroupRepo { trx: KyselyTransaction, ): Promise { const db = dbOrTx(this.db, trx); - return db - .selectFrom('groups') - .select((eb) => [...this.baseFields, this.countGroupMembers(eb)]) - .where('isDefault', '=', true) - .where('workspaceId', '=', workspaceId) - .executeTakeFirst(); + return ( + db + .selectFrom('groups') + .selectAll() + // .select((eb) => this.withMemberCount(eb)) + .where('isDefault', '=', true) + .where('workspaceId', '=', workspaceId) + .executeTakeFirst() + ); } async getGroupsPaginated(workspaceId: string, pagination: PaginationOptions) { let query = this.db .selectFrom('groups') - .select((eb) => [...this.baseFields, this.countGroupMembers(eb)]) + .selectAll('groups') + .select((eb) => this.withMemberCount(eb)) .where('workspaceId', '=', workspaceId) + .orderBy('memberCount', 'desc') .orderBy('createdAt', 'asc'); if (pagination.query) { @@ -116,6 +112,14 @@ export class GroupRepo { return result; } + withMemberCount(eb: ExpressionBuilder) { + return eb + .selectFrom('groupUsers') + .select((eb) => eb.fn.countAll().as('count')) + .whereRef('groupUsers.groupId', '=', 'groups.id') + .as('memberCount'); + } + async delete(groupId: string, workspaceId: string): Promise { await this.db .deleteFrom('groups')