add decks documentation

This commit is contained in:
2023-07-16 23:23:46 +02:00
parent 971b165be8
commit 9c13a57804
21 changed files with 239 additions and 78 deletions

View File

@@ -12,3 +12,10 @@ export class PaginationDto {
@IsNumber()
itemsPerPage?: number
}
export class Pagination {
currentPage: number
itemsPerPage: number
totalPages: number
totalItems: number
}

View File

@@ -19,7 +19,7 @@ async function bootstrap() {
app.setGlobalPrefix('v1')
const config = new DocumentBuilder()
.setTitle('Flashcards')
.setDescription('The config API description')
.setDescription('Flashcards API')
.setVersion('1.0')
.addTag('Auth')
.addTag('Decks')

View File

@@ -52,7 +52,7 @@ import {
export class AuthController {
constructor(private commandBus: CommandBus) {}
@ApiOperation({ description: 'Retrieve current user data.' })
@ApiOperation({ description: 'Retrieve current user data.', summary: 'Current user data' })
@ApiUnauthorizedResponse({ description: 'Not logged in' })
@ApiBadRequestResponse({ description: 'User not found' })
@UseGuards(JwtAuthGuard)
@@ -63,7 +63,10 @@ export class AuthController {
return await this.commandBus.execute(new GetCurrentUserDataCommand(userId))
}
@ApiOperation({ description: 'Sign in using email and password. Must have an account to do so.' })
@ApiOperation({
description: 'Sign in using email and password. Must have an account to do so.',
summary: 'Sign in using email and password. Must have an account to do so.',
})
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
@ApiBody({ type: LoginDto })
@HttpCode(HttpStatus.OK)
@@ -90,7 +93,7 @@ export class AuthController {
return { accessToken: req.user.data.accessToken }
}
@ApiOperation({ description: 'Create a new user account' })
@ApiOperation({ description: 'Create a new user account', summary: 'Create a new user account' })
@ApiBadRequestResponse({ description: 'Email already exists' })
@HttpCode(HttpStatus.CREATED)
@Post('sign-up')
@@ -98,7 +101,7 @@ export class AuthController {
return await this.commandBus.execute(new CreateUserCommand(registrationData))
}
@ApiOperation({ description: 'Verify user email' })
@ApiOperation({ description: 'Verify user email', summary: 'Verify user email' })
@ApiBadRequestResponse({ description: 'Email has already been verified' })
@ApiNotFoundResponse({ description: 'User not found' })
@ApiNoContentResponse({ description: 'Email verified successfully' })
@@ -108,7 +111,10 @@ export class AuthController {
return await this.commandBus.execute(new VerifyEmailCommand(body.code))
}
@ApiOperation({ description: 'Send verification email again' })
@ApiOperation({
description: 'Send verification email again',
summary: 'Send verification email again',
})
@ApiBadRequestResponse({ description: 'Email has already been verified' })
@ApiNotFoundResponse({ description: 'User not found' })
@ApiNoContentResponse({ description: 'Verification email sent successfully' })
@@ -118,7 +124,7 @@ export class AuthController {
return await this.commandBus.execute(new ResendVerificationEmailCommand(body.userId))
}
@ApiOperation({ description: 'Sign current user out' })
@ApiOperation({ description: 'Sign current user out', summary: 'Sign current user out' })
@ApiUnauthorizedResponse({ description: 'Not logged in' })
@ApiNoContentResponse({ description: 'Logged out successfully' })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -136,7 +142,10 @@ export class AuthController {
return null
}
@ApiOperation({ description: 'Get new access token using refresh token' })
@ApiOperation({
description: 'Get new access token using refresh token',
summary: 'Get new access token using refresh token',
})
@ApiUnauthorizedResponse({ description: 'Invalid or missing refreshToken' })
@ApiNoContentResponse({ description: 'New tokens generated successfully' })
@HttpCode(HttpStatus.NO_CONTENT)
@@ -165,7 +174,10 @@ export class AuthController {
return null
}
@ApiOperation({ description: 'Send password recovery email' })
@ApiOperation({
description: 'Send password recovery email',
summary: 'Send password recovery email',
})
@ApiBadRequestResponse({ description: 'Email has already been verified' })
@ApiNotFoundResponse({ description: 'User not found' })
@ApiNoContentResponse({ description: 'Password recovery email sent successfully' })
@@ -175,7 +187,7 @@ export class AuthController {
return await this.commandBus.execute(new SendPasswordRecoveryEmailCommand(body.email))
}
@ApiOperation({ description: 'Reset password' })
@ApiOperation({ description: 'Reset password', summary: 'Reset password' })
@ApiBadRequestResponse({ description: 'Password is required' })
@ApiNotFoundResponse({ description: 'Incorrect or expired password reset token' })
@ApiNoContentResponse({ description: 'Password reset successfully' })

View File

@@ -3,4 +3,5 @@ export * from './login.dto'
export * from './recover-password.dto'
export * from './registration.dto'
export * from './resend-verification-email.dto'
export * from './save-grade.dto'
export * from './update-auth.dto'

View File

@@ -0,0 +1,4 @@
export class SaveGradeDto {
cardId: string
grade: number
}

View File

@@ -1 +1,20 @@
export class Card {}
import { Pagination } from '../../../infrastructure/common/pagination/pagination.dto'
export class Card {
id: string
deckId: string
userId: string
question: string
answer: string
shots: number
answerImg: string
questionImg: string
rating: number
created: Date
updated: Date
}
export class PaginatedCards {
items: Card[]
pagination: Pagination
}

View File

@@ -4,6 +4,7 @@ import { createPrismaOrderBy } from '../../../infrastructure/common/helpers/get-
import { Pagination } from '../../../infrastructure/common/pagination/pagination.service'
import { PrismaService } from '../../../prisma.service'
import { CreateCardDto, GetAllCardsInDeckDto, UpdateCardDto } from '../dto'
import { PaginatedCards } from '../entities/cards.entity'
@Injectable()
export class CardsRepository {
@@ -59,7 +60,7 @@ export class CardsRepository {
itemsPerPage,
orderBy,
}: GetAllCardsInDeckDto
) {
): Promise<PaginatedCards> {
try {
const where = {
decks: {

View File

@@ -3,6 +3,8 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
@@ -15,14 +17,25 @@ import {
} from '@nestjs/common'
import { CommandBus } from '@nestjs/cqrs'
import { FileFieldsInterceptor } from '@nestjs/platform-express'
import { ApiTags } from '@nestjs/swagger'
import {
ApiConsumes,
ApiNoContentResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger'
import { Pagination } from '../../infrastructure/common/pagination/pagination.service'
import { SaveGradeDto } from '../auth/dto'
import { JwtAuthGuard } from '../auth/guards'
import { CreateCardDto, GetAllCardsInDeckDto } from '../cards/dto'
import { Card, PaginatedCards } from '../cards/entities/cards.entity'
import { DecksService } from './decks.service'
import { UpdateDeckDto, CreateDeckDto, GetAllDecksDto } from './dto'
import { Deck, PaginatedDecks } from './entities/deck.entity'
import {
CreateDeckCommand,
DeleteDeckByIdCommand,
@@ -40,6 +53,20 @@ import {
export class DecksController {
constructor(private readonly decksService: DecksService, private commandBus: CommandBus) {}
@HttpCode(HttpStatus.PARTIAL_CONTENT)
@ApiOperation({ description: 'Retrieve paginated decks list.', summary: 'Paginated decks list' })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get()
findAll(@Query() query: GetAllDecksDto, @Req() req): Promise<PaginatedDecks> {
const finalQuery = Pagination.getPaginationData(query)
return this.commandBus.execute(new GetAllDecksCommand({ ...finalQuery, userId: req.user.id }))
}
@ApiConsumes('multipart/form-data')
@ApiOperation({ description: 'Create a deck', summary: 'Create a deck' })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileFieldsInterceptor([{ name: 'cover', maxCount: 1 }]))
@Post()
@@ -50,7 +77,7 @@ export class DecksController {
cover: Express.Multer.File[]
},
@Body() createDeckDto: CreateDeckDto
) {
): Promise<Deck> {
const userId = req.user.id
return this.commandBus.execute(
@@ -58,42 +85,64 @@ export class DecksController {
)
}
@UseGuards(JwtAuthGuard)
@Get()
findAll(@Query() query: GetAllDecksDto, @Req() req) {
const finalQuery = Pagination.getPaginationData(query)
return this.commandBus.execute(new GetAllDecksCommand({ ...finalQuery, userId: req.user.id }))
}
@ApiOperation({ description: 'Retrieve a deck by id', summary: 'Retrieve a deck by id' })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get(':id')
findOne(@Param('id') id: string) {
findOne(@Param('id') id: string): Promise<Deck> {
return this.commandBus.execute(new GetDeckByIdCommand(id))
}
@ApiConsumes('multipart/form-data')
@ApiOperation({ description: 'Update a deck', summary: 'Update a deck' })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@ApiNotFoundResponse({ description: 'Deck not found' })
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileFieldsInterceptor([{ name: 'cover', maxCount: 1 }]))
@Patch(':id')
update(
@Param('id') id: string,
@UploadedFiles()
files: {
cover: Express.Multer.File[]
},
@Body() updateDeckDto: UpdateDeckDto,
@Req() req
): Promise<Deck> {
return this.commandBus.execute(
new UpdateDeckCommand(id, updateDeckDto, req.user.id, files?.cover?.[0])
)
}
@UseGuards(JwtAuthGuard)
@ApiOperation({ description: 'Delete a deck', summary: 'Delete a deck' })
@ApiOkResponse({ description: 'Deck deleted', type: Deck })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@ApiNotFoundResponse({ description: 'Deck not found' })
@Delete(':id')
remove(@Param('id') id: string, @Req() req): Promise<Deck> {
return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id))
}
@ApiOperation({
description: 'Retrieve paginated cards in a deck',
summary: 'Retrieve cards in a deck',
})
@UseGuards(JwtAuthGuard)
@Get(':id/cards')
findCardsInDeck(@Param('id') id: string, @Req() req, @Query() query: GetAllCardsInDeckDto) {
findCardsInDeck(
@Param('id') id: string,
@Req() req,
@Query() query: GetAllCardsInDeckDto
): Promise<PaginatedCards> {
const finalQuery = Pagination.getPaginationData(query)
return this.commandBus.execute(new GetAllCardsInDeckCommand(req.user.id, id, finalQuery))
}
@UseGuards(JwtAuthGuard)
@Get(':id/learn')
findRandomCardInDeck(@Param('id') id: string, @Req() req) {
return this.commandBus.execute(new GetRandomCardInDeckCommand(req.user.id, id))
}
@UseGuards(JwtAuthGuard)
@Post(':id/learn')
saveGrade(@Param('id') id: string, @Req() req, @Body() body: any) {
return this.commandBus.execute(
new SaveGradeCommand(req.user.id, { cardId: body.cardId, grade: body.grade })
)
}
@ApiConsumes('multipart/form-data')
@ApiOperation({ description: 'Create card in a deck', summary: 'Create a card' })
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@ApiNotFoundResponse({ description: 'Deck not found' })
@UseGuards(JwtAuthGuard)
@UseInterceptors(
FileFieldsInterceptor([
@@ -108,32 +157,36 @@ export class DecksController {
@UploadedFiles()
files: { questionImg: Express.Multer.File[]; answerImg: Express.Multer.File[] },
@Body() card: CreateCardDto
) {
): Promise<Card> {
return this.commandBus.execute(
new CreateCardCommand(req.user.id, id, card, files.answerImg?.[0], files.questionImg?.[0])
)
}
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@UseInterceptors(FileFieldsInterceptor([{ name: 'cover', maxCount: 1 }]))
@Patch(':id')
update(
@Param('id') id: string,
@UploadedFiles()
files: {
cover: Express.Multer.File[]
},
@Body() updateDeckDto: UpdateDeckDto,
@Req() req
) {
return this.commandBus.execute(
new UpdateDeckCommand(id, updateDeckDto, req.user.id, files?.cover?.[0])
)
@ApiOperation({
description: 'Retrieve a random card in a deck. The cards priority is based on the grade',
summary: 'Retrieve a random card',
})
@Get(':id/learn')
findRandomCardInDeck(@Param('id') id: string, @Req() req): Promise<Card> {
return this.commandBus.execute(new GetRandomCardInDeckCommand(req.user.id, id))
}
@UseGuards(JwtAuthGuard)
@Delete(':id')
remove(@Param('id') id: string, @Req() req) {
return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id))
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@ApiNotFoundResponse({ description: 'Card not found' })
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse({ description: 'Grade saved' })
@Post(':id/learn')
@ApiOperation({
description: 'Save the grade of a card',
summary: 'Save the grade of a card',
})
saveGrade(@Param('id') id: string, @Req() req, @Body() body: SaveGradeDto): Promise<void> {
return this.commandBus.execute(
new SaveGradeCommand(req.user.id, { cardId: body.cardId, grade: body.grade })
)
}
}

View File

@@ -1,18 +1,25 @@
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'
import { Transform } from 'class-transformer'
import { IsBoolean, IsOptional, Length } from 'class-validator'
export class CreateDeckDto {
@Length(3, 30)
name: string
/**
* Cover image (binary)
*/
@IsOptional()
@Length(0, 0)
@ApiProperty({ type: 'string', format: 'binary' })
cover?: string
/**
* Private decks are not visible to other users
*/
@IsOptional()
@IsBoolean()
@Transform((val: string) => [true, 'true', 1, '1'].indexOf(val) > -1)
isPrivate?: boolean
@ApiHideProperty()
userId: string
}

View File

@@ -1,3 +1,4 @@
import { ApiHideProperty } from '@nestjs/swagger'
import { IsUUID } from 'class-validator'
import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto'
@@ -10,15 +11,23 @@ export class GetAllDecksDto extends PaginationDto {
@IsOptionalOrEmptyString()
maxCardsCount?: string
/** Search by deck name */
@IsOptionalOrEmptyString()
name?: string
/** Filter by deck authorId */
@IsOptionalOrEmptyString()
@IsUUID(4)
authorId?: string
userId: string
@ApiHideProperty()
userId?: string
/** A string that represents the name of the field to order by and the order direction.
* The format is: "field_name-order_direction".
* Available directions: "asc" and "desc".
* @example "name-desc"
* */
@IsOrderBy()
orderBy?: string | null
}

View File

@@ -1,4 +1,5 @@
import { PartialType } from '@nestjs/mapped-types'
import { ApiProperty } from '@nestjs/swagger'
import { IsBoolean } from 'class-validator'
import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators'
@@ -7,12 +8,13 @@ import { CreateDeckDto } from './create-deck.dto'
export class UpdateDeckDto extends PartialType(CreateDeckDto) {
@IsOptionalOrEmptyString()
name: string
name?: string
@IsOptionalOrEmptyString()
@IsBoolean()
isPrivate: boolean
isPrivate?: boolean
@IsOptionalOrEmptyString()
cover: string
@ApiProperty({ type: 'string', format: 'binary' })
cover?: string
}

View File

@@ -1 +1,26 @@
export class Deck {}
import { Pagination } from '../../../infrastructure/common/pagination/pagination.dto'
export class Deck {
id: string
userId: string
name: string
isPrivate: boolean
shots: number
cover: string | null
rating: number
created: Date
updated: Date
cardsCount: number
author: DeckAuthor
}
export class DeckAuthor {
id: string
name: string
}
export class PaginatedDecks {
items: Deck[]
pagination: Pagination
maxCardsCount: number
}

View File

@@ -4,6 +4,7 @@ import { createPrismaOrderBy } from '../../../infrastructure/common/helpers/get-
import { Pagination } from '../../../infrastructure/common/pagination/pagination.service'
import { PrismaService } from '../../../prisma.service'
import { GetAllDecksDto } from '../dto'
import { Deck, PaginatedDecks } from '../entities/deck.entity'
@Injectable()
export class DecksRepository {
@@ -21,7 +22,7 @@ export class DecksRepository {
userId: string
cover?: string
isPrivate?: boolean
}) {
}): Promise<Deck> {
try {
return await this.prisma.deck.create({
data: {
@@ -35,6 +36,14 @@ export class DecksRepository {
cover,
isPrivate,
},
include: {
author: {
select: {
id: true,
name: true,
},
},
},
})
} catch (e) {
this.logger.error(e?.message)
@@ -51,9 +60,7 @@ export class DecksRepository {
minCardsCount,
maxCardsCount,
orderBy,
}: GetAllDecksDto) {
console.log(minCardsCount)
console.log(Number(minCardsCount))
}: GetAllDecksDto): Promise<PaginatedDecks> {
try {
const where = {
cardsCount: {
@@ -163,13 +170,21 @@ export class DecksRepository {
public async updateDeckById(
id: string,
data: { name?: string; cover?: string; isPrivate?: boolean }
) {
): Promise<Deck> {
try {
return await this.prisma.deck.update({
where: {
id,
},
data,
include: {
author: {
select: {
id: true,
name: true,
},
},
},
})
} catch (e) {
this.logger.error(e?.message)

View File

@@ -2,6 +2,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { FileUploadService } from '../../../infrastructure/file-upload-service/file-upload.service'
import { CreateCardDto } from '../../cards/dto'
import { Card } from '../../cards/entities/cards.entity'
import { CardsRepository } from '../../cards/infrastructure/cards.repository'
export class CreateCardCommand {
@@ -21,7 +22,7 @@ export class CreateCardHandler implements ICommandHandler<CreateCardCommand> {
private readonly fileUploadService: FileUploadService
) {}
async execute(command: CreateCardCommand) {
async execute(command: CreateCardCommand): Promise<Card> {
let questionImg, answerImg
if (command.questionImg && command.answerImg) {

View File

@@ -2,6 +2,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { FileUploadService } from '../../../infrastructure/file-upload-service/file-upload.service'
import { CreateDeckDto } from '../dto'
import { Deck } from '../entities/deck.entity'
import { DecksRepository } from '../infrastructure/decks.repository'
export class CreateDeckCommand {
@@ -15,7 +16,7 @@ export class CreateDeckHandler implements ICommandHandler<CreateDeckCommand> {
private readonly fileUploadService: FileUploadService
) {}
async execute(command: CreateDeckCommand) {
async execute(command: CreateDeckCommand): Promise<Deck> {
let cover
if (command.cover) {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { ForbiddenException, NotFoundException } from '@nestjs/common'
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { DecksRepository } from '../infrastructure/decks.repository'
@@ -16,7 +16,7 @@ export class DeleteDeckByIdHandler implements ICommandHandler<DeleteDeckByIdComm
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`)
throw new ForbiddenException(`You can't delete a deck that you don't own`)
}
return await this.deckRepository.deleteDeckById(command.id)

View File

@@ -2,6 +2,7 @@ import { ForbiddenException, NotFoundException } from '@nestjs/common'
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { GetAllCardsInDeckDto } from '../../cards/dto'
import { PaginatedCards } from '../../cards/entities/cards.entity'
import { CardsRepository } from '../../cards/infrastructure/cards.repository'
import { DecksRepository } from '../infrastructure/decks.repository'
@@ -20,7 +21,7 @@ export class GetAllCardsInDeckHandler implements ICommandHandler<GetAllCardsInDe
private readonly decksRepository: DecksRepository
) {}
async execute(command: GetAllCardsInDeckCommand) {
async execute(command: GetAllCardsInDeckCommand): Promise<PaginatedCards> {
const deck = await this.decksRepository.findDeckById(command.deckId)
if (!deck) throw new NotFoundException(`Deck with id ${command.deckId} not found`)

View File

@@ -1,6 +1,7 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { GetAllDecksDto } from '../dto'
import { PaginatedDecks } from '../entities/deck.entity'
import { DecksRepository } from '../infrastructure/decks.repository'
export class GetAllDecksCommand {
@@ -11,7 +12,7 @@ export class GetAllDecksCommand {
export class GetAllDecksHandler implements ICommandHandler<GetAllDecksCommand> {
constructor(private readonly deckRepository: DecksRepository) {}
async execute(command: GetAllDecksCommand) {
async execute(command: GetAllDecksCommand): Promise<PaginatedDecks> {
return await this.deckRepository.findAllDecks(command.params)
}
}

View File

@@ -3,6 +3,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { Prisma } from '@prisma/client'
import { pick } from 'remeda'
import { Card } from '../../cards/entities/cards.entity'
import { CardsRepository } from '../../cards/infrastructure/cards.repository'
import { DecksRepository } from '../infrastructure/decks.repository'
@@ -19,7 +20,7 @@ export class GetRandomCardInDeckHandler implements ICommandHandler<GetRandomCard
private readonly decksRepository: DecksRepository
) {}
private async getSmartRandomCard(cards: Array<CardWithGrade>) {
private async getSmartRandomCard(cards: Array<CardWithGrade>): Promise<Card> {
const selectionPool: Array<CardWithGrade> = []
cards.forEach(card => {

View File

@@ -23,7 +23,7 @@ export class SaveGradeHandler implements ICommandHandler<SaveGradeCommand> {
private readonly gradesRepository: GradesRepository
) {}
async execute(command: SaveGradeCommand) {
async execute(command: SaveGradeCommand): Promise<void> {
const deck = await this.decksRepository.findDeckByCardId(command.args.cardId)
if (!deck)
@@ -33,7 +33,7 @@ export class SaveGradeHandler implements ICommandHandler<SaveGradeCommand> {
throw new ForbiddenException(`You can't save cards to a private deck that you don't own`)
}
return await this.gradesRepository.createGrade({
await this.gradesRepository.createGrade({
userId: command.userId,
grade: command.args.grade,
cardId: command.args.cardId,

View File

@@ -3,6 +3,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { FileUploadService } from '../../../infrastructure/file-upload-service/file-upload.service'
import { UpdateDeckDto } from '../dto'
import { Deck } from '../entities/deck.entity'
import { DecksRepository } from '../infrastructure/decks.repository'
export class UpdateDeckCommand {
@@ -21,7 +22,7 @@ export class UpdateDeckHandler implements ICommandHandler<UpdateDeckCommand> {
private readonly fileUploadService: FileUploadService
) {}
async execute(command: UpdateDeckCommand) {
async execute(command: UpdateDeckCommand): Promise<Deck> {
const deck = await this.deckRepository.findDeckById(command.deckId)
if (!deck) {