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[]
|
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])
|
||||||
|
}
|
||||||
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 { 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 +129,42 @@ 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
|
${
|
||||||
? `WHERE ${conditions.map((_, index) => `${_.replace('?', `$${index + 1}`)}`).join(' AND ')}`
|
conditions.length
|
||||||
: ''
|
? `WHERE ${conditions
|
||||||
}
|
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
||||||
GROUP BY d."id", a."id"
|
.join(' AND ')}`
|
||||||
${
|
: ''
|
||||||
havingConditions.length
|
}
|
||||||
? `HAVING ${havingConditions
|
GROUP BY d."id", a."id"
|
||||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
${
|
||||||
.join(' AND ')}`
|
havingConditions.length
|
||||||
: ''
|
? `HAVING ${havingConditions
|
||||||
}
|
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||||
ORDER BY ${orderField} ${orderDirection}
|
.join(' AND ')}`
|
||||||
LIMIT $${conditions.length + havingConditions.length + 1} OFFSET $${
|
: ''
|
||||||
|
}
|
||||||
|
ORDER BY ${orderField} ${orderDirection}
|
||||||
|
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
|
||||||
const deckQueryParams = [
|
const deckQueryParams = [
|
||||||
...(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,36 +180,39 @@ 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
|
||||||
FROM (
|
FROM (
|
||||||
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
|
${
|
||||||
? `WHERE ${conditions
|
conditions.length
|
||||||
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
? `WHERE ${conditions
|
||||||
.join(' AND ')}`
|
.map((_, index) => `${_.replace('?', `$${index + 1}`)}`)
|
||||||
: ''
|
.join(' AND ')}`
|
||||||
}
|
: ''
|
||||||
GROUP BY d.id
|
}
|
||||||
${
|
GROUP BY d.id
|
||||||
havingConditions.length
|
${
|
||||||
? `HAVING ${havingConditions
|
havingConditions.length
|
||||||
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
? `HAVING ${havingConditions
|
||||||
.join(' AND ')}`
|
.map((_, index) => `${_.replace('?', `$${conditions.length + index + 1}`)}`)
|
||||||
: ''
|
.join(' AND ')}`
|
||||||
}
|
: ''
|
||||||
) 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 }
|
||||||
|
|||||||
@@ -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 './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'
|
||||||
|
|||||||
Reference in New Issue
Block a user