feat: groups

This commit is contained in:
Philipinho
2024-03-05 16:22:24 +00:00
parent 181bd78951
commit 528b9d70b1
21 changed files with 596 additions and 7 deletions

View File

@ -9,6 +9,7 @@ import { EnvironmentModule } from '../environment/environment.module';
import { CommentModule } from './comment/comment.module';
import { SearchModule } from './search/search.module';
import { SpaceModule } from './space/space.module';
import { GroupModule } from './group/group.module';
@Module({
imports: [
@ -23,6 +24,7 @@ import { SpaceModule } from './space/space.module';
CommentModule,
SearchModule,
SpaceModule,
GroupModule,
],
})
export class CoreModule {}

View File

@ -0,0 +1,8 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { GroupIdDto } from './group-id.dto';
export class AddGroupUserDto extends GroupIdDto {
@IsNotEmpty()
@IsUUID()
userId: string;
}

View File

@ -0,0 +1,12 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateGroupDto {
@MinLength(2)
@MaxLength(64)
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
}

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class GroupIdDto {
@IsNotEmpty()
@IsUUID()
groupId: string;
}

View File

@ -0,0 +1,3 @@
import { AddGroupUserDto } from './add-group-user.dto';
export class RemoveGroupUserDto extends AddGroupUserDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateGroupDto } from './create-group.dto';
export class UpdateGroupDto extends PartialType(CreateGroupDto) {}

View File

@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Group } from './group.entity';
@Entity('group_users')
@Unique(['groupId', 'userId'])
export class GroupUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
userId: string;
@ManyToOne(() => User, (user) => user.workspaceUsers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: User;
@Column()
groupId: string;
@ManyToOne(() => Group, (group) => group.groupUsers, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'groupId' })
group: Group;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,50 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { GroupUser } from './group-user.entity';
import { Workspace } from '../../workspace/entities/workspace.entity';
import { User } from '../../user/entities/user.entity';
@Entity('groups')
export class Group {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column()
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.groups, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Workspace;
@Column()
creatorId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'creatorId' })
creator: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => GroupUser, (groupUser) => groupUser.group)
groupUsers: GroupUser[];
}

View File

@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GroupController } from './group.controller';
import { GroupService } from './services/group.service';
describe('GroupController', () => {
let controller: GroupController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GroupController],
providers: [GroupService],
}).compile();
controller = module.get<GroupController>(GroupController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -0,0 +1,121 @@
import {
Controller,
Post,
Body,
UseGuards,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { GroupService } from './services/group.service';
import { CreateGroupDto } from './dto/create-group.dto';
import { JwtGuard } from '../auth/guards/jwt.guard';
import { AuthUser } from '../../decorators/auth-user.decorator';
import { CurrentWorkspace } from '../../decorators/current-workspace.decorator';
import { User } from '../user/entities/user.entity';
import { Workspace } from '../workspace/entities/workspace.entity';
import { GroupUserService } from './services/group-user.service';
import { GroupIdDto } from './dto/group-id.dto';
import { PaginationOptions } from '../../helpers/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';
@UseGuards(JwtGuard)
@Controller('groups')
export class GroupController {
constructor(
private readonly groupService: GroupService,
private readonly groupUserService: GroupUserService,
) {}
@HttpCode(HttpStatus.OK)
@Post('/')
getWorkspaceGroups(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupService.getGroupsInWorkspace(workspace.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('/details')
getGroup(
@Body() groupIdDto: GroupIdDto,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupService.getGroup(groupIdDto.groupId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('create')
createGroup(
@Body() createGroupDto: CreateGroupDto,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupService.createGroup(user, workspace.id, createGroupDto);
}
@HttpCode(HttpStatus.OK)
@Post('update')
updateGroup(
@Body() updateGroupDto: UpdateGroupDto,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupService.updateGroup(workspace.id, updateGroupDto);
}
@HttpCode(HttpStatus.OK)
@Post('members')
getGroupMembers(
@Body() groupIdDto: GroupIdDto,
@Body() pagination: PaginationOptions,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupUserService.getGroupUsers(
groupIdDto.groupId,
workspace.id,
pagination,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/add')
addGroupMember(
@Body() addGroupUserDto: AddGroupUserDto,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupUserService.addUserToGroup(
addGroupUserDto.userId,
addGroupUserDto.groupId,
workspace.id,
);
}
@HttpCode(HttpStatus.OK)
@Post('members/remove')
removeGroupMember(
@Body() removeGroupUserDto: RemoveGroupUserDto,
//@AuthUser() user: User,
//@CurrentWorkspace() workspace: Workspace,
) {
return this.groupUserService.removeUserFromGroup(
removeGroupUserDto.userId,
removeGroupUserDto.groupId,
);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
deleteGroup(
@Body() groupIdDto: GroupIdDto,
@AuthUser() user: User,
@CurrentWorkspace() workspace: Workspace,
) {
return this.groupService.deleteGroup(groupIdDto.groupId, workspace.id);
}
}

View File

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { GroupService } from './services/group.service';
import { GroupController } from './group.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '../auth/auth.module';
import { Group } from './entities/group.entity';
import { GroupUser } from './entities/group-user.entity';
import { GroupRepository } from './respositories/group.repository';
import { GroupUserRepository } from './respositories/group-user.repository';
import { GroupUserService } from './services/group-user.service';
@Module({
imports: [TypeOrmModule.forFeature([Group, GroupUser]), AuthModule],
controllers: [GroupController],
providers: [
GroupService,
GroupUserService,
GroupRepository,
GroupUserRepository,
],
})
export class GroupModule {}

View File

@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { GroupUser } from '../entities/group-user.entity';
@Injectable()
export class GroupUserRepository extends Repository<GroupUser> {
constructor(private dataSource: DataSource) {
super(GroupUser, dataSource.createEntityManager());
}
}

View File

@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Group } from '../entities/group.entity';
@Injectable()
export class GroupRepository extends Repository<Group> {
constructor(private dataSource: DataSource) {
super(Group, dataSource.createEntityManager());
}
}

View File

@ -0,0 +1,121 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { GroupUserRepository } from '../respositories/group-user.repository';
import { PaginationOptions } from '../../../helpers/pagination/pagination-options';
import {
WorkspaceUser,
WorkspaceUserRole,
} from '../../workspace/entities/workspace-user.entity';
import { transactionWrapper } from '../../../helpers/db.helper';
import { User } from '../../user/entities/user.entity';
import { GroupUser } from '../entities/group-user.entity';
import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto';
import { PaginatedResult } from '../../../helpers/pagination/paginated-result';
@Injectable()
export class GroupUserService {
constructor(
private groupUserRepository: GroupUserRepository,
private dataSource: DataSource,
) {}
async getGroupUsers(
groupId,
workspaceId: string,
paginationOptions: PaginationOptions,
): Promise<PaginatedResult<User>> {
const [groupUsers, count] = await this.groupUserRepository.findAndCount({
relations: ['user'],
where: {
group: {
workspaceId: workspaceId,
},
},
take: paginationOptions.limit,
skip: paginationOptions.skip,
});
const users = groupUsers.map((groupUser: GroupUser) => groupUser.user);
const paginationMeta = new PaginationMetaDto({ count, paginationOptions });
return new PaginatedResult(users, paginationMeta);
}
async addUserToGroup(
userId: string,
groupId: string,
workspaceId: string,
manager?: EntityManager,
): Promise<WorkspaceUser> {
let addedUser;
await transactionWrapper(
async (manager) => {
// TODO: make duplicate code reusable
const userExists = await manager.exists(User, {
where: { id: userId },
});
if (!userExists) {
throw new NotFoundException('User not found');
}
// only workspace users can be added to workspace groups
const workspaceUser = await manager.findOneBy(WorkspaceUser, {
userId: userId,
workspaceId: workspaceId,
});
if (!workspaceUser) {
throw new NotFoundException('User is not a member of this workspace');
}
const existingGroupUser = await manager.findOneBy(GroupUser, {
userId: userId,
groupId: groupId,
});
if (existingGroupUser) {
throw new BadRequestException(
'User is already a member of this group',
);
}
const groupUser = new GroupUser();
groupUser.userId = userId;
groupUser.groupId = groupId;
addedUser = await manager.save(groupUser);
},
this.dataSource,
manager,
);
return addedUser;
}
async removeUserFromGroup(userId: string, groupId: string): Promise<void> {
const groupUser = await this.findGroupUser(userId, groupId);
if (!groupUser) {
throw new BadRequestException('Group member not found');
}
await this.groupUserRepository.delete({
userId,
groupId,
});
}
async findGroupUser(userId: string, groupId: string): Promise<GroupUser> {
return await this.groupUserRepository.findOneBy({
userId,
groupId,
});
}
}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { GroupService } from './group.service';
describe('GroupService', () => {
let service: GroupService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GroupService],
}).compile();
service = module.get<GroupService>(GroupService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,82 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateGroupDto } from '../dto/create-group.dto';
import { GroupRepository } from '../respositories/group.repository';
import { Group } from '../entities/group.entity';
import { plainToInstance } from 'class-transformer';
import { User } from '../../user/entities/user.entity';
import { PaginationMetaDto } from '../../../helpers/pagination/pagination-meta-dto';
import { PaginatedResult } from '../../../helpers/pagination/paginated-result';
import { PaginationOptions } from '../../../helpers/pagination/pagination-options';
import { UpdateGroupDto } from '../dto/update-group.dto';
@Injectable()
export class GroupService {
constructor(private groupRepository: GroupRepository) {}
async createGroup(
authUser: User,
workspaceId: string,
createGroupDto: CreateGroupDto,
): Promise<Group> {
const group = plainToInstance(Group, createGroupDto);
group.creatorId = authUser.id;
group.workspaceId = workspaceId;
return await this.groupRepository.save(group);
}
async updateGroup(
workspaceId: string,
updateGroupDto: UpdateGroupDto,
): Promise<Group> {
const group = new Group();
if (updateGroupDto.name) {
group.name = updateGroupDto.name;
}
if (updateGroupDto.description) {
group.description = updateGroupDto.description;
}
return await this.groupRepository.save(group);
}
async getGroup(groupId: string, workspaceId: string): Promise<Group> {
const group = await this.groupRepository.findOneBy({
id: groupId,
workspaceId: workspaceId,
});
//TODO: get group member count
if (!group) {
throw new NotFoundException('Group not found');
}
return group;
}
async getGroupsInWorkspace(
workspaceId: string,
paginationOptions: PaginationOptions,
): Promise<PaginatedResult<Group>> {
const [groupsInWorkspace, count] = await this.groupRepository.findAndCount({
where: {
workspaceId: workspaceId,
},
take: paginationOptions.limit,
skip: paginationOptions.skip,
});
const paginationMeta = new PaginationMetaDto({ count, paginationOptions });
return new PaginatedResult(groupsInWorkspace, paginationMeta);
}
async deleteGroup(groupId: string, workspaceId: string) {
await this.getGroup(groupId, workspaceId);
await this.groupRepository.delete(groupId);
}
}

View File

@ -1,4 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateSpaceDto } from './dto/create-space.dto';
import { Space } from './entities/space.entity';
import { plainToInstance } from 'class-transformer';
@ -7,6 +11,8 @@ import { SpaceUserRepository } from './repositories/space-user.repository';
import { SpaceUser } from './entities/space-user.entity';
import { transactionWrapper } from '../../helpers/db.helper';
import { DataSource, EntityManager } from 'typeorm';
import { WorkspaceUser } from '../workspace/entities/workspace-user.entity';
import { User } from '../user/entities/user.entity';
@Injectable()
export class SpaceService {
@ -51,14 +57,33 @@ export class SpaceService {
userId: string,
spaceId: string,
role: string,
workspaceId,
manager?: EntityManager,
): Promise<SpaceUser> {
let addedUser: SpaceUser;
await transactionWrapper(
async (manager: EntityManager) => {
const existingSpaceUser = await manager.findOne(SpaceUser, {
where: { userId: userId, spaceId: spaceId },
const userExists = await manager.exists(User, {
where: { id: userId },
});
if (!userExists) {
throw new NotFoundException('User not found');
}
// only workspace users can be added to workspace spaces
const workspaceUser = await manager.findOneBy(WorkspaceUser, {
userId: userId,
workspaceId: workspaceId,
});
if (!workspaceUser) {
throw new NotFoundException('User is not a member of this workspace');
}
const existingSpaceUser = await manager.findOneBy(SpaceUser, {
userId: userId,
spaceId: spaceId,
});
if (existingSpaceUser) {

View File

@ -15,6 +15,7 @@ import { Page } from '../../page/entities/page.entity';
import { WorkspaceInvitation } from './workspace-invitation.entity';
import { Comment } from '../../comment/entities/comment.entity';
import { Space } from '../../space/entities/space.entity';
import { Group } from '../../group/entities/group.entity';
@Entity('workspaces')
export class Workspace {
@ -82,4 +83,7 @@ export class Workspace {
@OneToMany(() => Space, (space) => space.workspace)
spaces: [];
@OneToMany(() => Group, (group) => group.workspace)
groups: [];
}

View File

@ -34,10 +34,6 @@ export class WorkspaceUserService {
await transactionWrapper(
async (manager) => {
const existingWorkspaceUser = await manager.findOne(WorkspaceUser, {
where: { userId: userId, workspaceId: workspaceId },
});
const userExists = await manager.exists(User, {
where: { id: userId },
});
@ -45,6 +41,11 @@ export class WorkspaceUserService {
throw new NotFoundException('User not found');
}
const existingWorkspaceUser = await manager.findOneBy(WorkspaceUser, {
userId: userId,
workspaceId: workspaceId,
});
if (existingWorkspaceUser) {
throw new BadRequestException(
'User is already a member of this workspace',

View File

@ -82,6 +82,7 @@ export class WorkspaceService {
userId,
createdSpace.id,
WorkspaceUserRole.OWNER,
createdWorkspace.id,
manager,
);
@ -110,6 +111,7 @@ export class WorkspaceService {
userId,
firstWorkspace[0].defaultSpaceId,
WorkspaceUserRole.MEMBER,
firstWorkspace[0].id,
manager,
);
}

View File

@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Groups1709644512305 implements MigrationInterface {
name = 'Groups1709644512305'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "group_users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "groupId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_129c2cb846b1f4beedf4c6373b5" UNIQUE ("groupId", "userId"), CONSTRAINT "PK_5df8869cdeffc693bd083153bcf" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "groups" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "description" text, "workspaceId" uuid NOT NULL, "creatorId" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_659d1483316afb28afd3a90646e" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "group_users" ADD CONSTRAINT "FK_ad937045ed48b757293b2011d36" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "group_users" ADD CONSTRAINT "FK_ba2d59b482905354e872896dba8" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "groups" ADD CONSTRAINT "FK_cce5e5fec33dae0fcc991795b4a" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "groups" ADD CONSTRAINT "FK_accb24ba8f4f213f33d08e2a20f" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "groups" DROP CONSTRAINT "FK_accb24ba8f4f213f33d08e2a20f"`);
await queryRunner.query(`ALTER TABLE "groups" DROP CONSTRAINT "FK_cce5e5fec33dae0fcc991795b4a"`);
await queryRunner.query(`ALTER TABLE "group_users" DROP CONSTRAINT "FK_ba2d59b482905354e872896dba8"`);
await queryRunner.query(`ALTER TABLE "group_users" DROP CONSTRAINT "FK_ad937045ed48b757293b2011d36"`);
await queryRunner.query(`DROP TABLE "groups"`);
await queryRunner.query(`DROP TABLE "group_users"`);
}
}