diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f1504a7..810a43d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} \ No newline at end of file diff --git a/src/infrastructure/decorators/is-uuid-or-caller.ts b/src/infrastructure/decorators/is-uuid-or-caller.ts new file mode 100644 index 0000000..7444ef6 --- /dev/null +++ b/src/infrastructure/decorators/is-uuid-or-caller.ts @@ -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, + }) + } +} diff --git a/src/modules/decks/decks.controller.ts b/src/modules/decks/decks.controller.ts index 9d1ab73..144a88a 100644 --- a/src/modules/decks/decks.controller.ts +++ b/src/modules/decks/decks.controller.ts @@ -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 { + return await this.commandBus.execute(new AddDeckToFavoritesCommand(req.user.id, deckId)) + } } diff --git a/src/modules/decks/decks.module.ts b/src/modules/decks/decks.module.ts index 6a0deed..20fef37 100644 --- a/src/modules/decks/decks.module.ts +++ b/src/modules/decks/decks.module.ts @@ -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({ diff --git a/src/modules/decks/dto/get-all-decks.dto.ts b/src/modules/decks/dto/get-all-decks.dto.ts index 13252b4..87ac5da 100644 --- a/src/modules/decks/dto/get-all-decks.dto.ts +++ b/src/modules/decks/dto/get-all-decks.dto.ts @@ -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 diff --git a/src/modules/decks/infrastructure/decks.repository.ts b/src/modules/decks/infrastructure/decks.repository.ts index a5c0f98..fdfd6bd 100644 --- a/src/modules/decks/infrastructure/decks.repository.ts +++ b/src/modules/decks/infrastructure/decks.repository.ts @@ -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> { + 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 { + 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 { + 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 } diff --git a/src/modules/decks/use-cases/add-card-to-favorites.use-case.ts b/src/modules/decks/use-cases/add-card-to-favorites.use-case.ts new file mode 100644 index 0000000..fcbf4c1 --- /dev/null +++ b/src/modules/decks/use-cases/add-card-to-favorites.use-case.ts @@ -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 { + constructor(private readonly decksRepository: DecksRepository) {} + + async execute(command: AddDeckToFavoritesCommand): Promise { + 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) + } +} diff --git a/src/modules/decks/use-cases/index.ts b/src/modules/decks/use-cases/index.ts index 060d46c..5828c01 100644 --- a/src/modules/decks/use-cases/index.ts +++ b/src/modules/decks/use-cases/index.ts @@ -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'