diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aea7d79..d16d8ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -105,26 +105,20 @@ model Card { } model Deck { - id String @id @default(cuid()) - userId String - userName String - name String - private Boolean - path String - grade Int - shots Int - cardsCount Int - deckCover String? - type String - rating Int - moreId String? - isDeleted Boolean? - isBlocked Boolean? - created DateTime @default(now()) - updated DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - Card Card[] // One-to-many relation - Grade Grade[] + id String @id @default(cuid()) + userId String + name String + isPrivate Boolean @default(false) + shots Int @default(0) + cover String? + rating Int @default(0) + isDeleted Boolean? + isBlocked Boolean? + created DateTime @default(now()) + updated DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + Card Card[] + Grade Grade[] @@index([userId]) } diff --git a/src/app.module.ts b/src/app.module.ts index d57cb03..e05adb1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { MailerModule } from '@nestjs-modules/mailer' import * as process from 'process' import { JwtRefreshStrategy } from './modules/auth/strategies/jwt-refresh.strategy' import { CqrsModule } from '@nestjs/cqrs' +import { DecksModule } from './modules/decks/decks.module' @Module({ imports: [ @@ -15,6 +16,7 @@ import { CqrsModule } from '@nestjs/cqrs' ConfigModule, UsersModule, AuthModule, + DecksModule, PrismaModule, MailerModule.forRoot({ diff --git a/src/infrastructure/common/pagination/pagination.constants.ts b/src/infrastructure/common/pagination/pagination.constants.ts new file mode 100644 index 0000000..19d9f72 --- /dev/null +++ b/src/infrastructure/common/pagination/pagination.constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_PAGE_SIZE = 10 +export const DEFAULT_PAGE_NUMBER = 1 diff --git a/src/infrastructure/common/pagination/pagination.dto.ts b/src/infrastructure/common/pagination/pagination.dto.ts new file mode 100644 index 0000000..65468e1 --- /dev/null +++ b/src/infrastructure/common/pagination/pagination.dto.ts @@ -0,0 +1,14 @@ +import { IsNumber, IsOptional } from 'class-validator' +import { Type } from 'class-transformer' + +export class PaginationDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + currentPage?: number + + @Type(() => Number) + @IsOptional() + @IsNumber() + pageSize?: number +} diff --git a/src/infrastructure/common/pagination.service.ts b/src/infrastructure/common/pagination/pagination.service.ts similarity index 100% rename from src/infrastructure/common/pagination.service.ts rename to src/infrastructure/common/pagination/pagination.service.ts diff --git a/src/infrastructure/decorators/is-optional-or-empty-string.ts b/src/infrastructure/decorators/is-optional-or-empty-string.ts new file mode 100644 index 0000000..6d09835 --- /dev/null +++ b/src/infrastructure/decorators/is-optional-or-empty-string.ts @@ -0,0 +1,7 @@ +import { ValidateIf, ValidationOptions } from 'class-validator' + +export function IsOptionalOrEmptyString(validationOptions?: ValidationOptions) { + return ValidateIf((obj, value) => { + return value !== null && value !== undefined && value !== '' + }, validationOptions) +} diff --git a/src/main.ts b/src/main.ts index 3482ee2..aafe09f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' -import { BadRequestException, Logger, ValidationPipe } from '@nestjs/common' +import { Logger } from '@nestjs/common' import { HttpExceptionFilter } from './exception.filter' import * as cookieParser from 'cookie-parser' -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { pipesSetup } from './settings/pipes-setup' async function bootstrap() { const app = await NestFactory.create(AppModule) @@ -15,18 +16,7 @@ async function bootstrap() { .build() const document = SwaggerModule.createDocument(app, config) SwaggerModule.setup('docs', app, document) - app.useGlobalPipes( - new ValidationPipe({ - stopAtFirstError: false, - exceptionFactory: errors => { - const customErrors = errors.map(e => { - const firstError = JSON.stringify(e.constraints) - return { field: e.property, message: firstError } - }) - throw new BadRequestException(customErrors) - }, - }) - ) + pipesSetup(app) app.useGlobalFilters(new HttpExceptionFilter()) app.use(cookieParser()) await app.listen(process.env.PORT || 3000) diff --git a/src/modules/decks/decks.controller.ts b/src/modules/decks/decks.controller.ts new file mode 100644 index 0000000..4ada1ad --- /dev/null +++ b/src/modules/decks/decks.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + Req, + Request, + UseGuards, +} from '@nestjs/common' +import { DecksService } from './decks.service' +import { CreateDeckDto } from './dto/create-deck.dto' +import { UpdateDeckDto } from './dto/update-deck.dto' +import { CommandBus } from '@nestjs/cqrs' +import { CreateDeckCommand } from './use-cases' +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' +import { GetAllDecksCommand } from './use-cases/get-all-decks-use-case' +import { GetAllDecksDto } from './dto/get-all-decks.dto' +import { GetDeckByIdCommand } from './use-cases/get-deck-by-id-use-case' +import { DeleteDeckByIdCommand } from './use-cases/delete-deck-by-id-use-case' +import { UpdateDeckCommand } from './use-cases/update-deck-use-case' + +@Controller('decks') +export class DecksController { + constructor(private readonly decksService: DecksService, private commandBus: CommandBus) {} + + @UseGuards(JwtAuthGuard) + @Post() + create(@Request() req, @Body() createDeckDto: CreateDeckDto) { + const userId = req.user.id + return this.commandBus.execute(new CreateDeckCommand({ ...createDeckDto, userId: userId })) + } + + @UseGuards(JwtAuthGuard) + @Get() + findAll(@Query() query: GetAllDecksDto, @Req() req) { + return this.commandBus.execute(new GetAllDecksCommand({ ...query, userId: req.user.id })) + } + + @UseGuards(JwtAuthGuard) + @Get(':id') + findOne(@Param('id') id: string) { + return this.commandBus.execute(new GetDeckByIdCommand(id)) + } + + @UseGuards(JwtAuthGuard) + @Patch(':id') + update(@Param('id') id: string, @Body() updateDeckDto: UpdateDeckDto, @Req() req) { + return this.commandBus.execute(new UpdateDeckCommand(id, updateDeckDto, req.user.id)) + } + + @UseGuards(JwtAuthGuard) + @Delete(':id') + remove(@Param('id') id: string, @Req() req) { + return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id)) + } +} diff --git a/src/modules/decks/decks.module.ts b/src/modules/decks/decks.module.ts new file mode 100644 index 0000000..7569fcf --- /dev/null +++ b/src/modules/decks/decks.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common' +import { DecksService } from './decks.service' +import { DecksController } from './decks.controller' +import { CqrsModule } from '@nestjs/cqrs' +import { CreateDeckHandler } from './use-cases' +import { DecksRepository } from './infrastructure/decks.repository' +import { GetAllDecksHandler } from './use-cases/get-all-decks-use-case' +import { GetDeckByIdHandler } from './use-cases/get-deck-by-id-use-case' +import { DeleteDeckByIdHandler } from './use-cases/delete-deck-by-id-use-case' +import { UpdateDeckHandler } from './use-cases/update-deck-use-case' + +const commandHandlers = [ + CreateDeckHandler, + GetAllDecksHandler, + GetDeckByIdHandler, + DeleteDeckByIdHandler, + UpdateDeckHandler, +] + +@Module({ + imports: [CqrsModule], + controllers: [DecksController], + providers: [DecksService, DecksRepository, ...commandHandlers], + exports: [CqrsModule], +}) +export class DecksModule {} diff --git a/src/modules/decks/decks.service.ts b/src/modules/decks/decks.service.ts new file mode 100644 index 0000000..f0ef70a --- /dev/null +++ b/src/modules/decks/decks.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common' + +@Injectable() +export class DecksService {} diff --git a/src/modules/decks/dto/create-deck.dto.ts b/src/modules/decks/dto/create-deck.dto.ts new file mode 100644 index 0000000..5547560 --- /dev/null +++ b/src/modules/decks/dto/create-deck.dto.ts @@ -0,0 +1,16 @@ +import { IsBoolean, IsOptional, IsString, Length } from 'class-validator' + +export class CreateDeckDto { + @Length(3, 30) + name: string + + @IsOptional() + @IsString() + cover?: string + + @IsOptional() + @IsBoolean() + isPrivate?: boolean + + userId: string +} diff --git a/src/modules/decks/dto/get-all-decks.dto.ts b/src/modules/decks/dto/get-all-decks.dto.ts new file mode 100644 index 0000000..73103b7 --- /dev/null +++ b/src/modules/decks/dto/get-all-decks.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsUUID, Length } from 'class-validator' +import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' +import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto' + +export class GetAllDecksDto extends PaginationDto { + @IsOptional() + @Length(3, 30) + name?: string + + @IsOptionalOrEmptyString() + @IsUUID(4) + authorId?: string + + userId: string +} diff --git a/src/modules/decks/dto/update-deck.dto.ts b/src/modules/decks/dto/update-deck.dto.ts new file mode 100644 index 0000000..47115b0 --- /dev/null +++ b/src/modules/decks/dto/update-deck.dto.ts @@ -0,0 +1,16 @@ +import { PartialType } from '@nestjs/mapped-types' +import { CreateDeckDto } from './create-deck.dto' +import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' +import { IsBoolean } from 'class-validator' + +export class UpdateDeckDto extends PartialType(CreateDeckDto) { + @IsOptionalOrEmptyString() + name: string + + @IsOptionalOrEmptyString() + @IsBoolean() + isPrivate: boolean + + @IsOptionalOrEmptyString() + cover: string +} diff --git a/src/modules/decks/entities/deck.entity.ts b/src/modules/decks/entities/deck.entity.ts new file mode 100644 index 0000000..1414ccc --- /dev/null +++ b/src/modules/decks/entities/deck.entity.ts @@ -0,0 +1 @@ +export class Deck {} diff --git a/src/modules/decks/infrastructure/decks.repository.ts b/src/modules/decks/infrastructure/decks.repository.ts new file mode 100644 index 0000000..78c02b2 --- /dev/null +++ b/src/modules/decks/infrastructure/decks.repository.ts @@ -0,0 +1,126 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common' +import { PrismaService } from '../../../prisma.service' +import { GetAllDecksDto } from '../dto/get-all-decks.dto' +import { + DEFAULT_PAGE_NUMBER, + DEFAULT_PAGE_SIZE, +} from '../../../infrastructure/common/pagination/pagination.constants' + +@Injectable() +export class DecksRepository { + constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(DecksRepository.name) + async createDeck({ + name, + userId, + cover, + isPrivate, + }: { + name: string + userId: string + cover?: string + isPrivate?: boolean + }) { + try { + return await this.prisma.deck.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + + name, + cover, + isPrivate, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + + async findAllDecks({ + name = undefined, + authorId = undefined, + userId, + currentPage = DEFAULT_PAGE_NUMBER, + pageSize = DEFAULT_PAGE_SIZE, + }: GetAllDecksDto) { + try { + return await this.prisma.deck.findMany({ + where: { + name: { + contains: name, + }, + user: { + id: authorId || undefined, + }, + OR: [ + { + AND: [ + { + isPrivate: true, + }, + { + userId: userId, + }, + ], + }, + { + isPrivate: false, + }, + ], + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + public async findDeckById(id: string) { + try { + return await this.prisma.deck.findUnique({ + where: { + id, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + + public async deleteDeckById(id: string) { + try { + return await this.prisma.deck.delete({ + where: { + id, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + + public async updateDeckById( + id: string, + data: { name?: string; cover?: string; isPrivate?: boolean } + ) { + try { + return await this.prisma.deck.update({ + where: { + id, + }, + data, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } +} diff --git a/src/modules/decks/use-cases/create-deck-use-case.ts b/src/modules/decks/use-cases/create-deck-use-case.ts new file mode 100644 index 0000000..c9754e5 --- /dev/null +++ b/src/modules/decks/use-cases/create-deck-use-case.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CreateDeckDto } from '../dto/create-deck.dto' +import { DecksRepository } from '../infrastructure/decks.repository' + +export class CreateDeckCommand { + constructor(public readonly deck: CreateDeckDto) {} +} + +@CommandHandler(CreateDeckCommand) +export class CreateDeckHandler implements ICommandHandler { + constructor(private readonly deckRepository: DecksRepository) {} + + async execute(command: CreateDeckCommand) { + return await this.deckRepository.createDeck(command.deck) + } +} diff --git a/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts b/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts new file mode 100644 index 0000000..3091769 --- /dev/null +++ b/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts @@ -0,0 +1,21 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { DecksRepository } from '../infrastructure/decks.repository' +import { BadRequestException, NotFoundException } from '@nestjs/common' + +export class DeleteDeckByIdCommand { + constructor(public readonly id: string, public readonly userId: string) {} +} + +@CommandHandler(DeleteDeckByIdCommand) +export class DeleteDeckByIdHandler implements ICommandHandler { + constructor(private readonly deckRepository: DecksRepository) {} + + async execute(command: DeleteDeckByIdCommand) { + const deck = await this.deckRepository.findDeckById(command.id) + if (!deck) throw new NotFoundException(`Deck with id ${command.id} not found`) + if (deck.userId !== command.userId) { + throw new BadRequestException(`You can't delete a deck that you don't own`) + } + return await this.deckRepository.deleteDeckById(command.id) + } +} diff --git a/src/modules/decks/use-cases/get-all-decks-use-case.ts b/src/modules/decks/use-cases/get-all-decks-use-case.ts new file mode 100644 index 0000000..7c1072b --- /dev/null +++ b/src/modules/decks/use-cases/get-all-decks-use-case.ts @@ -0,0 +1,16 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { DecksRepository } from '../infrastructure/decks.repository' +import { GetAllDecksDto } from '../dto/get-all-decks.dto' + +export class GetAllDecksCommand { + constructor(public readonly params: GetAllDecksDto) {} +} + +@CommandHandler(GetAllDecksCommand) +export class GetAllDecksHandler implements ICommandHandler { + constructor(private readonly deckRepository: DecksRepository) {} + + async execute(command: GetAllDecksCommand) { + return await this.deckRepository.findAllDecks(command.params) + } +} diff --git a/src/modules/decks/use-cases/get-deck-by-id-use-case.ts b/src/modules/decks/use-cases/get-deck-by-id-use-case.ts new file mode 100644 index 0000000..fc05d14 --- /dev/null +++ b/src/modules/decks/use-cases/get-deck-by-id-use-case.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { DecksRepository } from '../infrastructure/decks.repository' + +export class GetDeckByIdCommand { + constructor(public readonly id: string) {} +} + +@CommandHandler(GetDeckByIdCommand) +export class GetDeckByIdHandler implements ICommandHandler { + constructor(private readonly deckRepository: DecksRepository) {} + + async execute(command: GetDeckByIdCommand) { + return await this.deckRepository.findDeckById(command.id) + } +} diff --git a/src/modules/decks/use-cases/index.ts b/src/modules/decks/use-cases/index.ts new file mode 100644 index 0000000..e29d8aa --- /dev/null +++ b/src/modules/decks/use-cases/index.ts @@ -0,0 +1 @@ +export * from './create-deck-use-case' diff --git a/src/modules/decks/use-cases/update-deck-use-case.ts b/src/modules/decks/use-cases/update-deck-use-case.ts new file mode 100644 index 0000000..bf008cb --- /dev/null +++ b/src/modules/decks/use-cases/update-deck-use-case.ts @@ -0,0 +1,27 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { DecksRepository } from '../infrastructure/decks.repository' +import { UpdateDeckDto } from '../dto/update-deck.dto' +import { BadRequestException, NotFoundException } from '@nestjs/common' + +export class UpdateDeckCommand { + constructor( + public readonly deckId: string, + public readonly deck: UpdateDeckDto, + public readonly userId: string + ) {} +} + +@CommandHandler(UpdateDeckCommand) +export class UpdateDeckHandler implements ICommandHandler { + constructor(private readonly deckRepository: DecksRepository) {} + + async execute(command: UpdateDeckCommand) { + const deck = await this.deckRepository.findDeckById(command.deckId) + if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`) + if (deck.userId !== command.userId) { + throw new BadRequestException(`You can't change a deck that you don't own`) + } + + return await this.deckRepository.updateDeckById(command.deckId, command.deck) + } +} diff --git a/src/modules/users/api/users.controller.ts b/src/modules/users/api/users.controller.ts index 29298ac..8fcedd5 100644 --- a/src/modules/users/api/users.controller.ts +++ b/src/modules/users/api/users.controller.ts @@ -11,7 +11,7 @@ import { } from '@nestjs/common' import { UsersService } from '../services/users.service' import { CreateUserDto } from '../dto/create-user.dto' -import { Pagination } from '../../../infrastructure/common/pagination.service' +import { Pagination } from '../../../infrastructure/common/pagination/pagination.service' import { BaseAuthGuard } from '../../auth/guards/base-auth.guard' import { CommandBus } from '@nestjs/cqrs' import { CreateUserCommand } from '../../auth/use-cases' diff --git a/src/settings/pipes-setup.ts b/src/settings/pipes-setup.ts index 910d6f9..ad28275 100644 --- a/src/settings/pipes-setup.ts +++ b/src/settings/pipes-setup.ts @@ -20,6 +20,7 @@ export function pipesSetup(app: INestApplication) { new ValidationPipe({ whitelist: true, transform: true, + forbidNonWhitelisted: true, stopAtFirstError: true, exceptionFactory: (errors: ValidationError[]) => {