diff --git a/apps/server/config/typeorm.config.ts b/apps/server/config/typeorm.config.ts index 7df4673..a17433d 100644 --- a/apps/server/config/typeorm.config.ts +++ b/apps/server/config/typeorm.config.ts @@ -4,6 +4,8 @@ import { TypeOrmModuleOptions, TypeOrmOptionsFactory } from '@nestjs/typeorm'; import { Task } from '@/task/domain/task.entity'; import { Section } from '@/task/domain/section.entity'; import { Account } from '@/account/entity/account.entity'; +import { Project } from '@/project/entity/project.entity'; +import { Contributor } from '@/project/entity/contributor.entity'; @Injectable() export class TypeormConfig implements TypeOrmOptionsFactory { @@ -17,7 +19,7 @@ export class TypeormConfig implements TypeOrmOptionsFactory { username: this.configService.get('DATABASE_USER'), password: this.configService.get('DATABASE_PASSWORD'), database: this.configService.get('DATABASE_NAME'), - entities: [Task, Section, Account], + entities: [Task, Section, Account, Project, Contributor], synchronize: true, ssl: { rejectUnauthorized: false, diff --git a/apps/server/src/account/dto/create-user.dto.ts b/apps/server/src/account/dto/create-user.dto.ts index ec17983..ec8e9aa 100644 --- a/apps/server/src/account/dto/create-user.dto.ts +++ b/apps/server/src/account/dto/create-user.dto.ts @@ -1,9 +1,11 @@ -import { IsEmail, IsString, Length } from 'class-validator'; +import { IsNotEmpty, IsString, Length } from 'class-validator'; export class CreateUserDto { - @IsEmail() + @IsNotEmpty() + @Length(8, 15) username: string; + @IsNotEmpty() @IsString() @Length(8, 15) password: string; diff --git a/apps/server/src/account/entity/account.entity.ts b/apps/server/src/account/entity/account.entity.ts index 741d65d..058240f 100644 --- a/apps/server/src/account/entity/account.entity.ts +++ b/apps/server/src/account/entity/account.entity.ts @@ -1,7 +1,8 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { EntityTimestamp } from '@/common/entity-timestamp.entity'; @Entity() -export class Account { +export class Account extends EntityTimestamp { @PrimaryGeneratedColumn() id: number; diff --git a/apps/server/src/account/service/auth.service.ts b/apps/server/src/account/service/auth.service.ts index 09a7f11..24b7b7e 100644 --- a/apps/server/src/account/service/auth.service.ts +++ b/apps/server/src/account/service/auth.service.ts @@ -16,7 +16,7 @@ export class AuthService { async signUp(username: string, password: string) { const account = await this.accountService.findByUsername(username); if (account) { - throw new BadRequestException('Already used email'); + throw new BadRequestException('Already used username'); } const hash = await bcrypt.hash(password, 10); const user = await this.accountService.create(username, hash); diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index dfa1b4b..c5d423e 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -10,6 +10,7 @@ import { AppController } from './app.controller'; import { HttpLoggingInterceptor } from './common/httpLog.Interceptor'; import { AllExceptionsFilter } from './common/allException.filter'; import { AccountModule } from './account/account.module'; +import { ProjectModule } from './project/project.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { AccountModule } from './account/account.module'; }), TaskModule, AccountModule, + ProjectModule, ], controllers: [AppController], providers: [ diff --git a/apps/server/src/common/entity-timestamp.entity.ts b/apps/server/src/common/entity-timestamp.entity.ts new file mode 100644 index 0000000..60b1981 --- /dev/null +++ b/apps/server/src/common/entity-timestamp.entity.ts @@ -0,0 +1,9 @@ +import { CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export class EntityTimestamp { + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/apps/server/src/project/controller/project.controller.ts b/apps/server/src/project/controller/project.controller.ts new file mode 100644 index 0000000..d29a26a --- /dev/null +++ b/apps/server/src/project/controller/project.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ProjectService } from '../service/project.service'; +import { AccessTokenGuard } from '@/account/guard/accessToken.guard'; +import { CreateProjectRequest } from '../dto/create-project-request.dto'; +import { AuthUser } from '@/account/decorator/authUser.decorator'; +import { Account } from '@/account/entity/account.entity'; +import { InviteUserRequest } from '../dto/invite-user-request.dto'; +import { UpdateContributorRequest } from '../dto/update-contributor-request.dts'; + +@UseGuards(AccessTokenGuard) +@Controller('projects') +export class ProjectController { + constructor(private projectService: ProjectService) {} + + @Get() + getProjects(@AuthUser() user: Account) { + return this.projectService.getUserProjects(user.id); + } + + @Get(':id/members') + getContributors(@AuthUser() user: Account, @Param('id') projectId: number) { + return this.projectService.getContributors(user.id, projectId); + } + + @Get('invitation') + getInvitations(@AuthUser() user: Account) { + return this.projectService.getInvitations(user.id); + } + + @Post() + create(@AuthUser() user: Account, @Body() body: CreateProjectRequest) { + return this.projectService.create(user.id, body.title); + } + + @Post('invitation') + async invite(@AuthUser() user: Account, @Body() body: InviteUserRequest) { + await this.projectService.invite(user.id, body.projectId, body.username); + return { message: 'Successfully invite user', success: true }; + } + + @Patch('invitation') + async updateInvitation(@AuthUser() user: Account, @Body() body: UpdateContributorRequest) { + await this.projectService.updateInvitation(user.id, body.contributorId, body.status); + return { message: 'Successfully update invitation', success: true }; + } +} diff --git a/apps/server/src/project/dto/create-project-request.dto.ts b/apps/server/src/project/dto/create-project-request.dto.ts new file mode 100644 index 0000000..3ff1b1b --- /dev/null +++ b/apps/server/src/project/dto/create-project-request.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, Length } from 'class-validator'; + +export class CreateProjectRequest { + @IsNotEmpty() + @IsString() + @Length(1, 20) + title: string; +} diff --git a/apps/server/src/project/dto/create-project-response.dto.ts b/apps/server/src/project/dto/create-project-response.dto.ts new file mode 100644 index 0000000..5364723 --- /dev/null +++ b/apps/server/src/project/dto/create-project-response.dto.ts @@ -0,0 +1,12 @@ +import { Project } from '../entity/project.entity'; + +export class CreateProjectResponse { + id: number; + + title: string; + + constructor(project: Project) { + this.id = project.id; + this.title = project.title; + } +} diff --git a/apps/server/src/project/dto/invite-user-request.dto.ts b/apps/server/src/project/dto/invite-user-request.dto.ts new file mode 100644 index 0000000..1137afa --- /dev/null +++ b/apps/server/src/project/dto/invite-user-request.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsNumber, IsPositive, Length } from 'class-validator'; + +export class InviteUserRequest { + @IsNotEmpty() + @Length(8, 15) + username: string; + + @IsNotEmpty() + @IsNumber() + @IsPositive() + projectId: number; +} diff --git a/apps/server/src/project/dto/project-contributors-response-dto.ts b/apps/server/src/project/dto/project-contributors-response-dto.ts new file mode 100644 index 0000000..0bf1032 --- /dev/null +++ b/apps/server/src/project/dto/project-contributors-response-dto.ts @@ -0,0 +1,15 @@ +import { ContributorStatus } from '../enum/contributor-status.enum'; + +export class ProjectContributorsResponse { + id: number; + + username: string; + + role: ContributorStatus; + + constructor(id: number, username: string, role: ContributorStatus) { + this.id = id; + this.username = username; + this.role = role; + } +} diff --git a/apps/server/src/project/dto/update-contributor-request.dts.ts b/apps/server/src/project/dto/update-contributor-request.dts.ts new file mode 100644 index 0000000..ad3eeae --- /dev/null +++ b/apps/server/src/project/dto/update-contributor-request.dts.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsIn, IsNotEmpty, IsNumber, IsPositive } from 'class-validator'; +import { ContributorStatus } from '../enum/contributor-status.enum'; + +export class UpdateContributorRequest { + @IsNotEmpty() + @IsNumber() + @IsPositive() + contributorId: number; + + @IsNotEmpty() + @IsEnum(ContributorStatus) + @IsIn([ContributorStatus.ACCEPTED, ContributorStatus.REJECTED], { + message: 'Required ACCEPTED or REJECTED', + }) + status: ContributorStatus; +} diff --git a/apps/server/src/project/dto/user-invitation-response.dto.ts b/apps/server/src/project/dto/user-invitation-response.dto.ts new file mode 100644 index 0000000..44d6995 --- /dev/null +++ b/apps/server/src/project/dto/user-invitation-response.dto.ts @@ -0,0 +1,16 @@ +export class UserInvitationResponse { + contributorId: number; + + projectId: number; + + projectTitle: string; + + inviter: string; + + constructor(contributorId: number, projectId: number, projectTitle: string, inviter: string) { + this.contributorId = contributorId; + this.projectId = projectId; + this.projectTitle = projectTitle; + this.inviter = inviter; + } +} diff --git a/apps/server/src/project/dto/user-projects-response.dto.ts b/apps/server/src/project/dto/user-projects-response.dto.ts new file mode 100644 index 0000000..aea1bad --- /dev/null +++ b/apps/server/src/project/dto/user-projects-response.dto.ts @@ -0,0 +1,12 @@ +import { ContributorStatus } from '../enum/contributor-status.enum'; + +export class UserProjectsResponse { + role: ContributorStatus; + + project: { id: number; title: string; createdAt: Date }; + + constructor(role: ContributorStatus, project: { id: number; title: string; createdAt: Date }) { + this.role = role; + this.project = project; + } +} diff --git a/apps/server/src/project/entity/contributor.entity.ts b/apps/server/src/project/entity/contributor.entity.ts new file mode 100644 index 0000000..efbd232 --- /dev/null +++ b/apps/server/src/project/entity/contributor.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { ContributorStatus } from '../enum/contributor-status.enum'; +import { ProjectRole } from '../enum/project-role.enum'; +import { EntityTimestamp } from '@/common/entity-timestamp.entity'; + +@Entity() +export class Contributor extends EntityTimestamp { + @PrimaryGeneratedColumn() + id: number; + + @Column() + userId: number; + + @Column() + inviterId: number; + + @Column() + projectId: number; + + @Column({ type: 'enum', enum: ContributorStatus }) + status: ContributorStatus; + + @Column({ type: 'enum', enum: ProjectRole }) + role: ProjectRole; +} diff --git a/apps/server/src/project/entity/project.entity.ts b/apps/server/src/project/entity/project.entity.ts new file mode 100644 index 0000000..f884334 --- /dev/null +++ b/apps/server/src/project/entity/project.entity.ts @@ -0,0 +1,11 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { EntityTimestamp } from '@/common/entity-timestamp.entity'; + +@Entity() +export class Project extends EntityTimestamp { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; +} diff --git a/apps/server/src/project/enum/contributor-status.enum.ts b/apps/server/src/project/enum/contributor-status.enum.ts new file mode 100644 index 0000000..f672730 --- /dev/null +++ b/apps/server/src/project/enum/contributor-status.enum.ts @@ -0,0 +1,5 @@ +export enum ContributorStatus { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + REJECTED = 'REJECTED', +} diff --git a/apps/server/src/project/enum/project-role.enum.ts b/apps/server/src/project/enum/project-role.enum.ts new file mode 100644 index 0000000..739abf6 --- /dev/null +++ b/apps/server/src/project/enum/project-role.enum.ts @@ -0,0 +1,4 @@ +export enum ProjectRole { + ADMIN = 'ADMIN', + GUEST = 'GUEST', +} diff --git a/apps/server/src/project/project.module.ts b/apps/server/src/project/project.module.ts new file mode 100644 index 0000000..bd75d50 --- /dev/null +++ b/apps/server/src/project/project.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProjectService } from './service/project.service'; +import { ProjectController } from './controller/project.controller'; +import { Project } from './entity/project.entity'; +import { Contributor } from './entity/contributor.entity'; +import { Account } from '@/account/entity/account.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Project, Contributor, Account])], + controllers: [ProjectController], + providers: [ProjectService], +}) +export class ProjectModule {} diff --git a/apps/server/src/project/service/project.service.ts b/apps/server/src/project/service/project.service.ts new file mode 100644 index 0000000..2df5583 --- /dev/null +++ b/apps/server/src/project/service/project.service.ts @@ -0,0 +1,152 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Project } from '../entity/project.entity'; +import { Contributor } from '../entity/contributor.entity'; +import { ContributorStatus } from '../enum/contributor-status.enum'; +import { ProjectRole } from '../enum/project-role.enum'; +import { CreateProjectResponse } from '../dto/create-project-response.dto'; +import { UserProjectsResponse } from '../dto/user-projects-response.dto'; +import { Account } from '@/account/entity/account.entity'; +import { ProjectContributorsResponse } from '../dto/project-contributors-response-dto'; +import { UserInvitationResponse } from '../dto/user-invitation-response.dto'; + +@Injectable() +export class ProjectService { + constructor( + private dataSource: DataSource, + @InjectRepository(Project) private projectRepository: Repository, + @InjectRepository(Contributor) private contributorRepository: Repository, + @InjectRepository(Account) private accountRepository: Repository + ) {} + + async getUserProjects(userId: number) { + const result = await this.contributorRepository + .createQueryBuilder('c') + .leftJoin('project', 'p', 'c.projectId = p.id') + .where('c.userId = :userId', { userId }) + .andWhere('c.status = :status', { status: ContributorStatus.ACCEPTED }) + .addSelect(['p.title', 'p.createdAt']) + .getRawMany(); + return result.map( + (record: { + c_projectId: number; + p_title: string; + p_createdAt: Date; + c_role: ContributorStatus; + }) => { + return new UserProjectsResponse(record.c_role, { + id: record.c_projectId, + title: record.p_title, + createdAt: record.p_createdAt, + }); + } + ); + } + + async getContributors(userId: number, projectId: number) { + const userContributor = await this.contributorRepository.findOneBy({ userId, projectId }); + if (!userContributor) { + throw new NotFoundException('Does not found user contributor or project'); + } else if (userContributor.status !== ContributorStatus.ACCEPTED) { + throw new ForbiddenException('Permission denied'); + } + const result = await this.contributorRepository + .createQueryBuilder('c') + .leftJoin('account', 'a', 'c.userId = a.id') + .where('c.projectId = :projectId', { projectId }) + .andWhere('c.status = :status', { status: ContributorStatus.ACCEPTED }) + .addSelect(['a.id, a.username, c.role']) + .getRawMany(); + return result.map((record: { id: number; username: string; role: ContributorStatus }) => { + return new ProjectContributorsResponse(record.id, record.username, record.role); + }); + } + + async getInvitations(userId: number) { + const result = await this.contributorRepository + .createQueryBuilder('c') + .leftJoin('project', 'p', 'c.projectId = p.id') + .leftJoin('account', 'a', 'c.inviterId = a.id') + .where('c.userId = :userId', { userId }) + .andWhere('c.status = :status', { status: ContributorStatus.PENDING }) + .addSelect(['p.title', 'a.username']) + .getRawMany(); + return result.map( + (record: { c_id: number; c_projectId: number; p_title: string; a_username: string }) => { + return new UserInvitationResponse( + record.c_id, + record.c_projectId, + record.p_title, + record.a_username + ); + } + ); + } + + async create(userId: number, title: string) { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const project = await queryRunner.manager.save(Project, { title }); + await queryRunner.manager.save(Contributor, { + userId, + inviterId: userId, + projectId: project.id, + status: ContributorStatus.ACCEPTED, + role: ProjectRole.ADMIN, + }); + await queryRunner.commitTransaction(); + return new CreateProjectResponse(project); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + async invite(userId: number, projectId: number, username: string) { + const userContributor = await this.contributorRepository.findOneBy({ userId, projectId }); + if (!userContributor) { + throw new NotFoundException('Does not found user contributor or project'); + } else if (userContributor.role !== ProjectRole.ADMIN) { + throw new ForbiddenException('Permission denied'); + } + const invitee = await this.accountRepository.findOneBy({ username }); + if (!invitee) { + throw new NotFoundException('Does not found username'); + } + if (await this.contributorRepository.existsBy({ projectId, userId: invitee.id })) { + throw new BadRequestException('Already existed invitation'); + } + await this.contributorRepository.save({ + projectId, + userId: invitee.id, + inviterId: userId, + status: ContributorStatus.PENDING, + role: ProjectRole.GUEST, + }); + } + + async updateInvitation(userId: number, contributorId: number, status: ContributorStatus) { + const contributor = await this.contributorRepository.findOneBy({ id: contributorId }); + if (!contributor) { + throw new NotFoundException('Does not found invitation'); + } else if (contributor.userId !== userId) { + throw new ForbiddenException('Permission denied'); + } else if (contributor.status !== ContributorStatus.PENDING) { + throw new BadRequestException('Already update invitation'); + } + contributor.status = status; + await this.contributorRepository.save(contributor); + } +}