mirror of
https://github.com/ershisan99/flashcards-api.git
synced 2025-12-17 05:09:26 +00:00
feat: add to favorites
This commit is contained in:
@@ -44,6 +44,10 @@ model user {
|
||||
revokedToken revokedToken[]
|
||||
RefreshToken refreshToken[]
|
||||
resetPassword resetPassword?
|
||||
favoriteDecks favoriteDeck[]
|
||||
|
||||
@@index([email])
|
||||
@@index([id])
|
||||
}
|
||||
|
||||
model revokedToken {
|
||||
@@ -113,6 +117,7 @@ model deck {
|
||||
author user @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
card card[]
|
||||
grades grade[]
|
||||
favoritedBy favoriteDeck[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -158,3 +163,15 @@ model fileEntity {
|
||||
createdAt DateTime @default(now())
|
||||
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])
|
||||
}
|
||||
34
src/infrastructure/decorators/is-uuid-or-caller.ts
Normal file
34
src/infrastructure/decorators/is-uuid-or-caller.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { GetRandomCardDto } from './dto/get-random-card.dto'
|
||||
import { Deck, PaginatedDecks, PaginatedDecksWithMaxCardsCount } from './entities/deck.entity'
|
||||
import { MinMaxCards } from './entities/min-max-cards.entity'
|
||||
import {
|
||||
AddDeckToFavoritesCommand,
|
||||
CreateCardCommand,
|
||||
CreateDeckCommand,
|
||||
DeleteDeckByIdCommand,
|
||||
@@ -246,4 +247,18 @@ export class DecksController {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
UpdateDeckHandler,
|
||||
GetAllCardsInDeckHandler,
|
||||
CreateCardHandler,
|
||||
AddDeckToFavoritesHandler,
|
||||
SaveGradeHandler,
|
||||
GetRandomCardInDeckHandler,
|
||||
} from './use-cases'
|
||||
@@ -34,6 +35,7 @@ const commandHandlers = [
|
||||
GetAllCardsInDeckHandler,
|
||||
CreateCardHandler,
|
||||
SaveGradeHandler,
|
||||
AddDeckToFavoritesHandler,
|
||||
]
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'
|
||||
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 { IsOptionalOrEmptyString, IsOrderBy } from '../../../infrastructure/decorators'
|
||||
import { IsUUIDOrCaller } from '../../../infrastructure/decorators/is-uuid-or-caller'
|
||||
|
||||
export enum DecksOrderBy {
|
||||
'null' = 'null',
|
||||
@@ -34,11 +35,20 @@ export class GetAllDecksDto extends PaginationDto {
|
||||
@IsOptionalOrEmptyString()
|
||||
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()
|
||||
@IsUUID(4)
|
||||
@IsUUIDOrCaller()
|
||||
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()
|
||||
userId?: string
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ export class DecksRepository {
|
||||
async findAllDecks({
|
||||
name = undefined,
|
||||
authorId = undefined,
|
||||
favoritedBy = undefined,
|
||||
userId,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
@@ -80,6 +81,9 @@ export class DecksRepository {
|
||||
if (!orderBy || orderBy === 'null') {
|
||||
orderBy = DecksOrderBy['updated-desc']
|
||||
}
|
||||
if (authorId === '~caller') authorId = userId
|
||||
if (favoritedBy === '~caller') favoritedBy = userId
|
||||
|
||||
let orderField = 'd.updated' // default order field
|
||||
let orderDirection = 'DESC' // default order direction
|
||||
|
||||
@@ -115,6 +119,7 @@ export class DecksRepository {
|
||||
if (authorId) conditions.push(`d."userId" = ?`)
|
||||
if (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
|
||||
const havingConditions = []
|
||||
@@ -124,39 +129,42 @@ export class DecksRepository {
|
||||
|
||||
// Construct the raw SQL query for fetching decks
|
||||
const query = `
|
||||
SELECT
|
||||
d.*,
|
||||
COUNT(c.id) AS "cardsCount",
|
||||
a."id" AS "authorId",
|
||||
a."name" AS "authorName"
|
||||
FROM flashcards.deck AS "d"
|
||||
LEFT JOIN "flashcards"."card" AS c ON d."id" = c."deckId"
|
||||
LEFT JOIN "flashcards"."user" AS a ON d."userId" = a.id
|
||||
${
|
||||
conditions.length
|
||||
? `WHERE ${conditions.map((_, index) => `${_.replace('?', `$${index + 1}`)}`).join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
GROUP BY d."id", a."id"
|
||||
${
|
||||
havingConditions.length
|
||||
? `HAVING ${havingConditions
|
||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
ORDER BY ${orderField} ${orderDirection}
|
||||
LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
|
||||
SELECT
|
||||
d.*,
|
||||
COUNT(c.id) AS "cardsCount",
|
||||
a."id" AS "authorId",
|
||||
a."name" AS "authorName"
|
||||
FROM flashcards.deck AS "d"
|
||||
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"."favoriteDeck" AS fd ON d."id" = fd."deckId"
|
||||
${
|
||||
conditions.length
|
||||
? `WHERE ${conditions
|
||||
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
GROUP BY d."id", a."id"
|
||||
${
|
||||
havingConditions.length
|
||||
? `HAVING ${havingConditions
|
||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
ORDER BY ${orderField} ${orderDirection}
|
||||
LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
|
||||
conditions.length + havingConditions.length + 2
|
||||
};
|
||||
|
||||
`
|
||||
`
|
||||
|
||||
// Parameters for fetching decks
|
||||
const deckQueryParams = [
|
||||
...(name ? [name] : []),
|
||||
...(authorId ? [authorId] : []),
|
||||
...(userId ? [userId] : []),
|
||||
...(favoritedBy ? [favoritedBy] : []),
|
||||
...(minCardsCount != null ? [minCardsCount] : []),
|
||||
...(maxCardsCount != null ? [maxCardsCount] : []),
|
||||
itemsPerPage,
|
||||
@@ -172,36 +180,39 @@ LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
|
||||
}
|
||||
>
|
||||
>(query, ...deckQueryParams)
|
||||
|
||||
// Construct the raw SQL query for total count
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) AS total
|
||||
FROM (
|
||||
SELECT d.id
|
||||
FROM flashcards.deck AS d
|
||||
LEFT JOIN flashcards.card AS c ON d.id = c."deckId"
|
||||
${
|
||||
conditions.length
|
||||
? `WHERE ${conditions
|
||||
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
GROUP BY d.id
|
||||
${
|
||||
havingConditions.length
|
||||
? `HAVING ${havingConditions
|
||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
) AS subquery;
|
||||
`
|
||||
SELECT COUNT(*) AS total
|
||||
FROM (
|
||||
SELECT d.id
|
||||
FROM flashcards.deck AS d
|
||||
LEFT JOIN flashcards.card AS c ON d.id = c."deckId"
|
||||
LEFT JOIN flashcards."favoriteDeck" AS fd ON d."id" = fd."deckId"
|
||||
${
|
||||
conditions.length
|
||||
? `WHERE ${conditions
|
||||
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
GROUP BY d.id
|
||||
${
|
||||
havingConditions.length
|
||||
? `HAVING ${havingConditions
|
||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||
.join(' AND ')}`
|
||||
: ''
|
||||
}
|
||||
) AS subquery;
|
||||
`
|
||||
|
||||
// Parameters for total count query
|
||||
const countQueryParams = [
|
||||
...(name ? [name] : []),
|
||||
...(authorId ? [authorId] : []),
|
||||
...(userId ? [userId] : []),
|
||||
...(favoritedBy ? [favoritedBy] : []),
|
||||
...(minCardsCount != null ? [minCardsCount] : []),
|
||||
...(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(
|
||||
id: string,
|
||||
data: { name?: string; cover?: string; isPrivate?: boolean }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from './get-random-card-in-deck-use-case'
|
||||
export * from './save-grade-use-case'
|
||||
export * from './update-deck-use-case'
|
||||
export * from './get-min-max-cards-use-case'
|
||||
export * from './add-card-to-favorites.use-case'
|
||||
|
||||
Reference in New Issue
Block a user