diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d16d8ab..547a221 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,23 +82,23 @@ model Card { id String @id @default(cuid()) deckId String userId String - question String - answer String - grade Int - shots Int + question String @db.Text + answer String @db.Text + grade Int @default(0) + shots Int @default(0) questionImg String? answerImg String? answerVideo String? questionVideo String? comments String? type String? - rating Int + rating Int @default(0) moreId String? created DateTime @default(now()) updated DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) decks Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) - grades Grade[] // One-to-many relation + grades Grade[] @@index([userId]) @@index([deckId]) diff --git a/src/app.module.ts b/src/app.module.ts index e05adb1..7829472 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ 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' +import { CardsModule } from './modules/cards/cards.module' @Module({ imports: [ @@ -17,6 +18,7 @@ import { DecksModule } from './modules/decks/decks.module' UsersModule, AuthModule, DecksModule, + CardsModule, PrismaModule, MailerModule.forRoot({ diff --git a/src/modules/cards/cards.controller.ts b/src/modules/cards/cards.controller.ts new file mode 100644 index 0000000..774bd47 --- /dev/null +++ b/src/modules/cards/cards.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, Param, Patch, Req, UseGuards } from '@nestjs/common' +import { CardsService } from './cards.service' +import { UpdateCardDto } from './dto/update-card.dto' +import { CommandBus } from '@nestjs/cqrs' +import { DeleteDeckByIdCommand, GetDeckByIdCommand, UpdateDeckCommand } from './use-cases' +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' + +@Controller('cards') +export class CardsController { + constructor(private readonly decksService: CardsService, private commandBus: CommandBus) {} + + @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: UpdateCardDto, @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/cards/cards.module.ts b/src/modules/cards/cards.module.ts new file mode 100644 index 0000000..ef0ca53 --- /dev/null +++ b/src/modules/cards/cards.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { CardsService } from './cards.service' +import { CardsController } from './cards.controller' +import { CqrsModule } from '@nestjs/cqrs' +import { + CreateCardHandler, + DeleteDeckByIdHandler, + GetDeckByIdHandler, + GetAllCardsInDeckHandler, + UpdateDeckHandler, +} from './use-cases' +import { CardsRepository } from './infrastructure/cards.repository' + +const commandHandlers = [ + CreateCardHandler, + GetAllCardsInDeckHandler, + GetDeckByIdHandler, + DeleteDeckByIdHandler, + UpdateDeckHandler, +] + +@Module({ + imports: [CqrsModule], + controllers: [CardsController], + providers: [CardsService, CardsRepository, ...commandHandlers], + exports: [CqrsModule], +}) +export class CardsModule {} diff --git a/src/modules/cards/cards.service.ts b/src/modules/cards/cards.service.ts new file mode 100644 index 0000000..301e017 --- /dev/null +++ b/src/modules/cards/cards.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common' + +@Injectable() +export class CardsService {} diff --git a/src/modules/cards/dto/create-card.dto.ts b/src/modules/cards/dto/create-card.dto.ts new file mode 100644 index 0000000..d0d31ad --- /dev/null +++ b/src/modules/cards/dto/create-card.dto.ts @@ -0,0 +1,9 @@ +import { Length } from 'class-validator' + +export class CreateCardDto { + @Length(3, 500) + question: string + + @Length(3, 500) + answer: string +} diff --git a/src/modules/cards/dto/get-all-cards.dto.ts b/src/modules/cards/dto/get-all-cards.dto.ts new file mode 100644 index 0000000..d2e7d96 --- /dev/null +++ b/src/modules/cards/dto/get-all-cards.dto.ts @@ -0,0 +1,13 @@ +import { Length } from 'class-validator' +import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto' +import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' + +export class GetAllCardsInDeckDto extends PaginationDto { + @IsOptionalOrEmptyString() + @Length(1, 30) + question?: string + + @IsOptionalOrEmptyString() + @Length(1, 30) + answer?: string +} diff --git a/src/modules/cards/dto/update-card.dto.ts b/src/modules/cards/dto/update-card.dto.ts new file mode 100644 index 0000000..b9e5c31 --- /dev/null +++ b/src/modules/cards/dto/update-card.dto.ts @@ -0,0 +1,16 @@ +import { PartialType } from '@nestjs/mapped-types' +import { CreateCardDto } from './create-card.dto' +import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' +import { IsBoolean } from 'class-validator' + +export class UpdateCardDto extends PartialType(CreateCardDto) { + @IsOptionalOrEmptyString() + name: string + + @IsOptionalOrEmptyString() + @IsBoolean() + isPrivate: boolean + + @IsOptionalOrEmptyString() + cover: string +} diff --git a/src/modules/cards/entities/cards.entity.ts b/src/modules/cards/entities/cards.entity.ts new file mode 100644 index 0000000..4c092d2 --- /dev/null +++ b/src/modules/cards/entities/cards.entity.ts @@ -0,0 +1 @@ +export class Card {} diff --git a/src/modules/cards/infrastructure/cards.repository.ts b/src/modules/cards/infrastructure/cards.repository.ts new file mode 100644 index 0000000..eb410b9 --- /dev/null +++ b/src/modules/cards/infrastructure/cards.repository.ts @@ -0,0 +1,109 @@ +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common' +import { PrismaService } from '../../../prisma.service' +import { GetAllCardsInDeckDto } from '../dto/get-all-cards.dto' +import { + DEFAULT_PAGE_NUMBER, + DEFAULT_PAGE_SIZE, +} from '../../../infrastructure/common/pagination/pagination.constants' +import { CreateCardDto } from '../dto/create-card.dto' + +@Injectable() +export class CardsRepository { + constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(CardsRepository.name) + async createCard(deckId: string, userId: string, card: CreateCardDto) { + try { + return await this.prisma.card.create({ + data: { + user: { + connect: { + id: userId, + }, + }, + decks: { + connect: { + id: deckId, + }, + }, + ...card, + }, + }) + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + + async findCardsByDeckId( + deckId: string, + { + answer = undefined, + question = undefined, + currentPage = DEFAULT_PAGE_NUMBER, + pageSize = DEFAULT_PAGE_SIZE, + }: GetAllCardsInDeckDto + ) { + try { + return await this.prisma.card.findMany({ + where: { + decks: { + id: deckId, + }, + question: { + contains: question || undefined, + }, + answer: { + contains: answer || undefined, + }, + }, + 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/cards/use-cases/delete-deck-by-id-use-case.ts b/src/modules/cards/use-cases/delete-deck-by-id-use-case.ts new file mode 100644 index 0000000..a0cbe48 --- /dev/null +++ b/src/modules/cards/use-cases/delete-deck-by-id-use-case.ts @@ -0,0 +1,21 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../infrastructure/cards.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: CardsRepository) {} + + 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/cards/use-cases/get-deck-by-id-use-case.ts b/src/modules/cards/use-cases/get-deck-by-id-use-case.ts new file mode 100644 index 0000000..975b828 --- /dev/null +++ b/src/modules/cards/use-cases/get-deck-by-id-use-case.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../infrastructure/cards.repository' + +export class GetDeckByIdCommand { + constructor(public readonly id: string) {} +} + +@CommandHandler(GetDeckByIdCommand) +export class GetDeckByIdHandler implements ICommandHandler { + constructor(private readonly deckRepository: CardsRepository) {} + + async execute(command: GetDeckByIdCommand) { + return await this.deckRepository.findDeckById(command.id) + } +} diff --git a/src/modules/cards/use-cases/index.ts b/src/modules/cards/use-cases/index.ts new file mode 100644 index 0000000..8626b90 --- /dev/null +++ b/src/modules/cards/use-cases/index.ts @@ -0,0 +1,5 @@ +export * from '../../decks/use-cases/create-card-use-case' +export * from '../../decks/use-cases/get-all-cards-in-deck-use-case' +export * from './get-deck-by-id-use-case' +export * from './delete-deck-by-id-use-case' +export * from './update-deck-use-case' diff --git a/src/modules/cards/use-cases/update-deck-use-case.ts b/src/modules/cards/use-cases/update-deck-use-case.ts new file mode 100644 index 0000000..7ca9eb7 --- /dev/null +++ b/src/modules/cards/use-cases/update-deck-use-case.ts @@ -0,0 +1,29 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../infrastructure/cards.repository' +import { UpdateCardDto } from '../dto/update-card.dto' +import { BadRequestException, NotFoundException } from '@nestjs/common' + +export class UpdateDeckCommand { + constructor( + public readonly deckId: string, + public readonly deck: UpdateCardDto, + public readonly userId: string + ) {} +} + +@CommandHandler(UpdateDeckCommand) +export class UpdateDeckHandler implements ICommandHandler { + constructor(private readonly deckRepository: CardsRepository) {} + + 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/decks/decks.controller.ts b/src/modules/decks/decks.controller.ts index 4117da1..1c2c6b6 100644 --- a/src/modules/decks/decks.controller.ts +++ b/src/modules/decks/decks.controller.ts @@ -18,12 +18,16 @@ import { CommandBus } from '@nestjs/cqrs' import { CreateDeckCommand, DeleteDeckByIdCommand, + GetAllCardsInDeckCommand, GetAllDecksCommand, GetDeckByIdCommand, UpdateDeckCommand, } from './use-cases' import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' import { GetAllDecksDto } from './dto/get-all-decks.dto' +import { GetAllCardsInDeckDto } from '../cards/dto/get-all-cards.dto' +import { CreateCardCommand } from './use-cases/create-card-use-case' +import { CreateCardDto } from '../cards/dto/create-card.dto' @Controller('decks') export class DecksController { @@ -48,6 +52,18 @@ export class DecksController { return this.commandBus.execute(new GetDeckByIdCommand(id)) } + @UseGuards(JwtAuthGuard) + @Get(':id/cards') + findCardsInDeck(@Param('id') id: string, @Req() req, @Query() query: GetAllCardsInDeckDto) { + return this.commandBus.execute(new GetAllCardsInDeckCommand(req.user.id, id, query)) + } + + @UseGuards(JwtAuthGuard) + @Post(':id/cards') + createCardInDeck(@Param('id') id: string, @Req() req, @Body() card: CreateCardDto) { + return this.commandBus.execute(new CreateCardCommand(req.user.id, id, card)) + } + @UseGuards(JwtAuthGuard) @Patch(':id') update(@Param('id') id: string, @Body() updateDeckDto: UpdateDeckDto, @Req() req) { diff --git a/src/modules/decks/decks.module.ts b/src/modules/decks/decks.module.ts index d4bf65a..5167c4a 100644 --- a/src/modules/decks/decks.module.ts +++ b/src/modules/decks/decks.module.ts @@ -8,8 +8,11 @@ import { GetDeckByIdHandler, GetAllDecksHandler, UpdateDeckHandler, + GetAllCardsInDeckHandler, } from './use-cases' import { DecksRepository } from './infrastructure/decks.repository' +import { CardsRepository } from '../cards/infrastructure/cards.repository' +import { CreateCardHandler } from './use-cases/create-card-use-case' const commandHandlers = [ CreateDeckHandler, @@ -17,12 +20,14 @@ const commandHandlers = [ GetDeckByIdHandler, DeleteDeckByIdHandler, UpdateDeckHandler, + GetAllCardsInDeckHandler, + CreateCardHandler, ] @Module({ imports: [CqrsModule], controllers: [DecksController], - providers: [DecksService, DecksRepository, ...commandHandlers], + providers: [DecksService, DecksRepository, CardsRepository, ...commandHandlers], exports: [CqrsModule], }) export class DecksModule {} diff --git a/src/modules/decks/use-cases/create-card-use-case.ts b/src/modules/decks/use-cases/create-card-use-case.ts new file mode 100644 index 0000000..57fac32 --- /dev/null +++ b/src/modules/decks/use-cases/create-card-use-case.ts @@ -0,0 +1,20 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CreateCardDto } from '../../cards/dto/create-card.dto' +import { CardsRepository } from '../../cards/infrastructure/cards.repository' + +export class CreateCardCommand { + constructor( + public readonly userId: string, + public readonly deckId: string, + public readonly card: CreateCardDto + ) {} +} + +@CommandHandler(CreateCardCommand) +export class CreateCardHandler implements ICommandHandler { + constructor(private readonly cardsRepository: CardsRepository) {} + + async execute(command: CreateCardCommand) { + return await this.cardsRepository.createCard(command.deckId, command.userId, command.card) + } +} diff --git a/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts b/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts new file mode 100644 index 0000000..912daa4 --- /dev/null +++ b/src/modules/decks/use-cases/get-all-cards-in-deck-use-case.ts @@ -0,0 +1,28 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs' +import { CardsRepository } from '../../cards/infrastructure/cards.repository' +import { GetAllCardsInDeckDto } from '../../cards/dto/get-all-cards.dto' +import { ForbiddenException, NotFoundException } from '@nestjs/common' + +export class GetAllCardsInDeckCommand { + constructor( + public readonly userId: string, + public readonly deckId: string, + public readonly params: GetAllCardsInDeckDto + ) {} +} + +@CommandHandler(GetAllCardsInDeckCommand) +export class GetAllCardsInDeckHandler implements ICommandHandler { + constructor(private readonly cardsRepository: CardsRepository) {} + + async execute(command: GetAllCardsInDeckCommand) { + const deck = await this.cardsRepository.findDeckById(command.deckId) + if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`) + + if (deck.userId !== command.userId && deck.isPrivate) { + throw new ForbiddenException(`You can't get a private deck that you don't own`) + } + + return await this.cardsRepository.findCardsByDeckId(command.deckId, command.params) + } +} diff --git a/src/modules/decks/use-cases/index.ts b/src/modules/decks/use-cases/index.ts index 1324f7f..1a8b55d 100644 --- a/src/modules/decks/use-cases/index.ts +++ b/src/modules/decks/use-cases/index.ts @@ -3,3 +3,4 @@ export * from './get-all-decks-use-case' export * from './get-deck-by-id-use-case' export * from './delete-deck-by-id-use-case' export * from './update-deck-use-case' +export * from './get-all-cards-in-deck-use-case'