feat: add to favorites

This commit is contained in:
2024-05-30 18:11:36 +02:00
parent ece7cf312d
commit 6604b880bf
8 changed files with 225 additions and 50 deletions

View File

@@ -44,6 +44,10 @@ model user {
revokedToken revokedToken[] revokedToken revokedToken[]
RefreshToken refreshToken[] RefreshToken refreshToken[]
resetPassword resetPassword? resetPassword resetPassword?
favoriteDecks favoriteDeck[]
@@index([email])
@@index([id])
} }
model revokedToken { model revokedToken {
@@ -113,6 +117,7 @@ model deck {
author user @relation(fields: [userId], references: [id], onDelete: Cascade) author user @relation(fields: [userId], references: [id], onDelete: Cascade)
card card[] card card[]
grades grade[] grades grade[]
favoritedBy favoriteDeck[]
@@index([userId]) @@index([userId])
} }
@@ -158,3 +163,15 @@ model fileEntity {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model favoriteDeck {
id String @id @default(cuid())
userId String
deckId String
user user @relation(fields: [userId], references: [id], onDelete: Cascade)
deck deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
@@unique([userId, deckId])
@@index([userId])
@@index([deckId])
}

View File

@@ -0,0 +1,34 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator'
import { v4 as isUUID } from 'uuid'
@ValidatorConstraint({ async: false })
class IsUUIDOrCallerConstraint implements ValidatorConstraintInterface {
validate(value: any) {
if (typeof value === 'string') {
return isUUID(value) || value === '~caller'
}
return false
}
defaultMessage() {
return 'Text ($value) must be a UUID or the string "~caller"'
}
}
export function IsUUIDOrCaller(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsUUIDOrCallerConstraint,
})
}
}

View File

@@ -40,6 +40,7 @@ import { GetRandomCardDto } from './dto/get-random-card.dto'
import { Deck, PaginatedDecks, PaginatedDecksWithMaxCardsCount } from './entities/deck.entity' import { Deck, PaginatedDecks, PaginatedDecksWithMaxCardsCount } from './entities/deck.entity'
import { MinMaxCards } from './entities/min-max-cards.entity' import { MinMaxCards } from './entities/min-max-cards.entity'
import { import {
AddDeckToFavoritesCommand,
CreateCardCommand, CreateCardCommand,
CreateDeckCommand, CreateDeckCommand,
DeleteDeckByIdCommand, DeleteDeckByIdCommand,
@@ -246,4 +247,18 @@ export class DecksController {
new SaveGradeCommand(req.user.id, { cardId: body.cardId, grade: body.grade }) new SaveGradeCommand(req.user.id, { cardId: body.cardId, grade: body.grade })
) )
} }
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiUnauthorizedResponse({ description: 'Unauthorized' })
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse({ description: 'Added to favorites' })
@Post(':id/favorite')
@ApiOperation({
description: 'Add deck to favorites',
summary: 'Add deck to favorites',
})
async addToFavorites(@Req() req, @Param('id') deckId: string): Promise<CardWithGrade> {
return await this.commandBus.execute(new AddDeckToFavoritesCommand(req.user.id, deckId))
}
} }

View File

@@ -18,6 +18,7 @@ import {
UpdateDeckHandler, UpdateDeckHandler,
GetAllCardsInDeckHandler, GetAllCardsInDeckHandler,
CreateCardHandler, CreateCardHandler,
AddDeckToFavoritesHandler,
SaveGradeHandler, SaveGradeHandler,
GetRandomCardInDeckHandler, GetRandomCardInDeckHandler,
} from './use-cases' } from './use-cases'
@@ -34,6 +35,7 @@ const commandHandlers = [
GetAllCardsInDeckHandler, GetAllCardsInDeckHandler,
CreateCardHandler, CreateCardHandler,
SaveGradeHandler, SaveGradeHandler,
AddDeckToFavoritesHandler,
] ]
@Module({ @Module({

View File

@@ -1,9 +1,10 @@
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger' import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'
import { Type } from 'class-transformer' import { Type } from 'class-transformer'
import { IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator' import { IsEnum, IsNumber, IsOptional } from 'class-validator'
import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto' import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto'
import { IsOptionalOrEmptyString, IsOrderBy } from '../../../infrastructure/decorators' import { IsOptionalOrEmptyString, IsOrderBy } from '../../../infrastructure/decorators'
import { IsUUIDOrCaller } from '../../../infrastructure/decorators/is-uuid-or-caller'
export enum DecksOrderBy { export enum DecksOrderBy {
'null' = 'null', 'null' = 'null',
@@ -34,11 +35,20 @@ export class GetAllDecksDto extends PaginationDto {
@IsOptionalOrEmptyString() @IsOptionalOrEmptyString()
name?: string name?: string
/** Filter by deck authorId */ /** Filter by deck authorId
* If ~caller is passed, it will be replaced with the current user's id
*/
@IsOptionalOrEmptyString() @IsOptionalOrEmptyString()
@IsUUID(4) @IsUUIDOrCaller()
authorId?: string authorId?: string
/** Decks favorited by user
* If ~caller is passed, it will be replaced with the current user's id
* */
@IsOptionalOrEmptyString()
@IsUUIDOrCaller()
favoritedBy?: string
@ApiHideProperty() @ApiHideProperty()
userId?: string userId?: string

View File

@@ -70,6 +70,7 @@ export class DecksRepository {
async findAllDecks({ async findAllDecks({
name = undefined, name = undefined,
authorId = undefined, authorId = undefined,
favoritedBy = undefined,
userId, userId,
currentPage, currentPage,
itemsPerPage, itemsPerPage,
@@ -80,6 +81,9 @@ export class DecksRepository {
if (!orderBy || orderBy === 'null') { if (!orderBy || orderBy === 'null') {
orderBy = DecksOrderBy['updated-desc'] orderBy = DecksOrderBy['updated-desc']
} }
if (authorId === '~caller') authorId = userId
if (favoritedBy === '~caller') favoritedBy = userId
let orderField = 'd.updated' // default order field let orderField = 'd.updated' // default order field
let orderDirection = 'DESC' // default order direction let orderDirection = 'DESC' // default order direction
@@ -115,6 +119,7 @@ export class DecksRepository {
if (authorId) conditions.push(`d."userId" = ?`) if (authorId) conditions.push(`d."userId" = ?`)
if (userId) if (userId)
conditions.push(`(d."isPrivate" = FALSE OR (d."isPrivate" = TRUE AND d."userId" = ?))`) conditions.push(`(d."isPrivate" = FALSE OR (d."isPrivate" = TRUE AND d."userId" = ?))`)
if (favoritedBy) conditions.push(`fd."userId" = ?`)
// Prepare the having clause for card count range // Prepare the having clause for card count range
const havingConditions = [] const havingConditions = []
@@ -124,32 +129,34 @@ export class DecksRepository {
// Construct the raw SQL query for fetching decks // Construct the raw SQL query for fetching decks
const query = ` const query = `
SELECT SELECT
d.*, d.*,
COUNT(c.id) AS "cardsCount", COUNT(c.id) AS "cardsCount",
a."id" AS "authorId", a."id" AS "authorId",
a."name" AS "authorName" a."name" AS "authorName"
FROM flashcards.deck AS "d" FROM flashcards.deck AS "d"
LEFT JOIN "flashcards"."card" AS c ON d."id" = c."deckId" LEFT JOIN "flashcards"."card" AS c ON d."id" = c."deckId"
LEFT JOIN "flashcards"."user" AS a ON d."userId" = a.id LEFT JOIN "flashcards"."user" AS a ON d."userId" = a.id
${ LEFT JOIN "flashcards"."favoriteDeck" AS fd ON d."id" = fd."deckId"
${
conditions.length conditions.length
? `WHERE ${conditions.map((_, index) => `${_.replace('?', `$${index + 1}`)}`).join(' AND ')}` ? `WHERE ${conditions
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
.join(' AND ')}`
: '' : ''
} }
GROUP BY d."id", a."id" GROUP BY d."id", a."id"
${ ${
havingConditions.length havingConditions.length
? `HAVING ${havingConditions ? `HAVING ${havingConditions
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`) .map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
.join(' AND ')}` .join(' AND ')}`
: '' : ''
} }
ORDER BY ${orderField} ${orderDirection} ORDER BY ${orderField} ${orderDirection}
LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
conditions.length + havingConditions.length + 2 conditions.length + havingConditions.length + 2
}; };
` `
// Parameters for fetching decks // Parameters for fetching decks
@@ -157,6 +164,7 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
...(name ? [name] : []), ...(name ? [name] : []),
...(authorId ? [authorId] : []), ...(authorId ? [authorId] : []),
...(userId ? [userId] : []), ...(userId ? [userId] : []),
...(favoritedBy ? [favoritedBy] : []),
...(minCardsCount != null ? [minCardsCount] : []), ...(minCardsCount != null ? [minCardsCount] : []),
...(maxCardsCount != null ? [maxCardsCount] : []), ...(maxCardsCount != null ? [maxCardsCount] : []),
itemsPerPage, itemsPerPage,
@@ -172,6 +180,7 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
} }
> >
>(query, ...deckQueryParams) >(query, ...deckQueryParams)
// Construct the raw SQL query for total count // Construct the raw SQL query for total count
const countQuery = ` const countQuery = `
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
@@ -179,6 +188,7 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
SELECT d.id SELECT d.id
FROM flashcards.deck AS d FROM flashcards.deck AS d
LEFT JOIN flashcards.card AS c ON d.id = c."deckId" LEFT JOIN flashcards.card AS c ON d.id = c."deckId"
LEFT JOIN flashcards."favoriteDeck" AS fd ON d."id" = fd."deckId"
${ ${
conditions.length conditions.length
? `WHERE ${conditions ? `WHERE ${conditions
@@ -195,13 +205,14 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
: '' : ''
} }
) AS subquery; ) AS subquery;
` `
// Parameters for total count query // Parameters for total count query
const countQueryParams = [ const countQueryParams = [
...(name ? [name] : []), ...(name ? [name] : []),
...(authorId ? [authorId] : []), ...(authorId ? [authorId] : []),
...(userId ? [userId] : []), ...(userId ? [userId] : []),
...(favoritedBy ? [favoritedBy] : []),
...(minCardsCount != null ? [minCardsCount] : []), ...(minCardsCount != null ? [minCardsCount] : []),
...(maxCardsCount != null ? [maxCardsCount] : []), ...(maxCardsCount != null ? [maxCardsCount] : []),
] ]
@@ -322,6 +333,57 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
} }
} }
async findFavoritesByUserId(userId: string, deckId?: string): Promise<Array<string>> {
try {
const favorites = await this.prisma.favoriteDeck.findMany({
where: {
userId: userId,
...{ deckId: deckId ? deckId : undefined },
},
select: {
deckId: true,
},
})
return favorites.map(favorite => favorite.deckId)
} catch (e) {
this.logger.error(e?.message)
throw new InternalServerErrorException(e?.message)
}
}
async addDeckToFavorites(userId: string, deckId: string): Promise<void> {
try {
await this.prisma.favoriteDeck.create({
data: {
userId: userId,
deckId: deckId,
},
})
} catch (e) {
// Handle the case where the favorite already exists or any other error
this.logger.error(e?.message)
throw new InternalServerErrorException(e?.message)
}
}
async removeDeckFromFavorites(userId: string, deckId: string): Promise<void> {
try {
await this.prisma.favoriteDeck.delete({
where: {
userId_deckId: {
userId: userId,
deckId: deckId,
},
},
})
} catch (e) {
// Handle the case where the favorite does not exist or any other error
this.logger.error(e?.message)
throw new InternalServerErrorException(e?.message)
}
}
public async updateDeckById( public async updateDeckById(
id: string, id: string,
data: { name?: string; cover?: string; isPrivate?: boolean } data: { name?: string; cover?: string; isPrivate?: boolean }

View File

@@ -0,0 +1,34 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common'
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'
import { DecksRepository } from '../infrastructure/decks.repository'
export class AddDeckToFavoritesCommand {
constructor(
public readonly userId: string,
public readonly deckId: string
) {}
}
@CommandHandler(AddDeckToFavoritesCommand)
export class AddDeckToFavoritesHandler implements ICommandHandler<AddDeckToFavoritesCommand> {
constructor(private readonly decksRepository: DecksRepository) {}
async execute(command: AddDeckToFavoritesCommand): Promise<void> {
const deck = await this.decksRepository.findDeckById(command.deckId)
if (!deck) {
throw new NotFoundException(`Deck with id ${command.deckId} not found`)
}
const favorites = await this.decksRepository.findFavoritesByUserId(
command.userId,
command.deckId
)
if (favorites?.includes(command.deckId)) {
throw new ForbiddenException(`You can't add a deck that you already have in favorites`)
}
return await this.decksRepository.addDeckToFavorites(command.userId, command.deckId)
}
}

View File

@@ -9,3 +9,4 @@ export * from './get-random-card-in-deck-use-case'
export * from './save-grade-use-case' export * from './save-grade-use-case'
export * from './update-deck-use-case' export * from './update-deck-use-case'
export * from './get-min-max-cards-use-case' export * from './get-min-max-cards-use-case'
export * from './add-card-to-favorites.use-case'