From 9875b665b739a0b3f488c2a77b7480bb42cece35 Mon Sep 17 00:00:00 2001 From: andres Date: Tue, 11 Jun 2024 12:33:50 +0200 Subject: [PATCH] feat: admin endpoints --- src/modules/auth/guards/admin.guard.ts | 22 ++ src/modules/decks/decks.controller.ts | 14 +- .../decks/infrastructure/decks.repository.ts | 195 ++++++++++++++++++ .../use-cases/delete-deck-by-id-use-case.ts | 5 +- .../use-cases/get-all-decks-use-case-v2.ts | 5 +- src/modules/users/api/users.controller.ts | 47 ++++- .../users/infrastructure/users.repository.ts | 32 ++- src/modules/users/services/users.service.ts | 25 ++- src/modules/users/users.module.ts | 4 +- 9 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 src/modules/auth/guards/admin.guard.ts diff --git a/src/modules/auth/guards/admin.guard.ts b/src/modules/auth/guards/admin.guard.ts new file mode 100644 index 0000000..ce81ff5 --- /dev/null +++ b/src/modules/auth/guards/admin.guard.ts @@ -0,0 +1,22 @@ +import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common' + +import { JwtAuthGuard } from './jwt-auth.guard' + +@Injectable() +export class AdminGuard extends JwtAuthGuard { + async canActivate(context: ExecutionContext): Promise { + const canActivate = await super.canActivate(context) + + if (!canActivate) { + return false + } + const request = context.switchToHttp().getRequest() + const user = request.user + + if (!user.isAdmin) { + throw new UnauthorizedException('You do not have permission to access this resource') + } + + return true + } +} diff --git a/src/modules/decks/decks.controller.ts b/src/modules/decks/decks.controller.ts index 045ee24..2ff2771 100644 --- a/src/modules/decks/decks.controller.ts +++ b/src/modules/decks/decks.controller.ts @@ -91,16 +91,22 @@ export class DecksController { findAllV2(@Query() query: GetAllDecksDto, @Req() req): Promise { const finalQuery = Pagination.getPaginationData(query) - return this.commandBus.execute(new GetAllDecksV2Command({ ...finalQuery, userId: req.user.id })) + return this.commandBus.execute( + new GetAllDecksV2Command({ + ...finalQuery, + userId: req.user.id, + isAdmin: req.user.isAdmin, + }) + ) } - @HttpCode(HttpStatus.OK) @ApiOperation({ description: 'Retrieve the minimum and maximum amount of cards in a deck.', summary: 'Minimum and maximum amount of cards in a deck', }) - @ApiBearerAuth() @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Version('2') @Get('min-max-cards') @@ -169,7 +175,7 @@ export class DecksController { @Delete(':id') @ApiBearerAuth() remove(@Param('id') id: string, @Req() req): Promise { - return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id)) + return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id, req.user.isAdmin)) } @ApiOperation({ diff --git a/src/modules/decks/infrastructure/decks.repository.ts b/src/modules/decks/infrastructure/decks.repository.ts index 408c002..06551f9 100644 --- a/src/modules/decks/infrastructure/decks.repository.ts +++ b/src/modules/decks/infrastructure/decks.repository.ts @@ -67,6 +67,201 @@ export class DecksRepository { } } + async findAllDecksForAdmin({ + name = undefined, + authorId = undefined, + favoritedBy = undefined, + userId, + currentPage, + itemsPerPage, + minCardsCount, + maxCardsCount, + orderBy, + }: GetAllDecksDto): Promise { + 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 + + if (orderBy) { + const orderByParts = orderBy.split('-') + + if (orderByParts.length === 2) { + const field = orderByParts[0] + const direction = orderByParts[1].toUpperCase() + + // Validate the field and direction + if ( + ['cardsCount', 'updated', 'name', 'author.name', 'created'].includes(field) && + ['ASC', 'DESC'].includes(direction) + ) { + if (field === 'cardsCount') { + orderField = '"cardsCount"' + } else if (field === 'author.name') { + orderField = 'a.name' + } else { + orderField = `d.${field}` + } + + orderDirection = direction + } + } + } + try { + // Prepare the where clause conditions + const conditions = [] + + if (name) conditions.push(`d.name ILIKE ('%' || ? || '%')`) + if (authorId) conditions.push(`d."userId" = ?`) + if (userId) conditions.push(`(d."isPrivate" = FALSE OR (d."isPrivate" = TRUE))`) + if (favoritedBy) conditions.push(`fd."userId" = ?`) + + // Prepare the having clause for card count range + const havingConditions = [] + + if (minCardsCount != null) havingConditions.push(`COUNT(c.id) >= ?`) + if (maxCardsCount != null) havingConditions.push(`COUNT(c.id) <= ?`) + + // 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", + (fd."userId" IS NOT NULL) AS "isFavorite" + 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" AND fd."userId" = $1 + ${ + conditions.length + ? `WHERE ${conditions + .map((condition, index) => `${condition.replace('?', `$${index + 2}`)}`) + .join(' AND ')}` + : '' + } + GROUP BY d."id", a."id", fd."userId" + ${ + havingConditions.length + ? `HAVING ${havingConditions + .map( + (condition, index) => `${condition.replace('?', `$${conditions.length + index + 2}`)}` + ) + .join(' AND ')}` + : '' + } + ORDER BY ${orderField} ${orderDirection} + LIMIT $${conditions.length + havingConditions.length + 2} OFFSET $${ + conditions.length + havingConditions.length + 3 + }; + ` + + // Parameters for fetching decks + const deckQueryParams = [ + userId, + ...(name ? [name] : []), + ...(authorId ? [authorId] : []), + ...(userId ? [userId] : []), + ...(favoritedBy ? [favoritedBy] : []), + ...(minCardsCount != null ? [minCardsCount] : []), + ...(maxCardsCount != null ? [maxCardsCount] : []), + itemsPerPage, + (currentPage - 1) * itemsPerPage, + ] + + // Execute the raw SQL query for fetching decks + const decks = await this.prisma.$queryRawUnsafe< + Array< + Deck & { + authorId: string + authorName: string + isFavorite: boolean + } + > + >(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" + LEFT JOIN flashcards."favoriteDeck" AS fd ON d."id" = fd."deckId" AND fd."userId" = $1 + ${ + conditions.length + ? `WHERE ${conditions + .map((condition, index) => `${condition.replace('?', `$${index + 2}`)}`) + .join(' AND ')}` + : '' + } + GROUP BY d.id, fd."userId" + ${ + havingConditions.length + ? `HAVING ${havingConditions + .map( + (condition, index) => + `${condition.replace('?', `$${conditions.length + index + 2}`)}` + ) + .join(' AND ')}` + : '' + } + ) AS subquery; + ` + + // Parameters for total count query + const countQueryParams = [ + userId, + ...(name ? [name] : []), + ...(authorId ? [authorId] : []), + ...(userId ? [userId] : []), + ...(favoritedBy ? [favoritedBy] : []), + ...(minCardsCount != null ? [minCardsCount] : []), + ...(maxCardsCount != null ? [maxCardsCount] : []), + ] + + // Execute the raw SQL query for total count + const totalResult = await this.prisma.$queryRawUnsafe(countQuery, ...countQueryParams) + const total = Number(totalResult[0]?.total) ?? 1 + + const modifiedDecks = decks.map(deck => { + const cardsCount = deck.cardsCount + + return omit( + { + ...deck, + cardsCount: typeof cardsCount === 'bigint' ? Number(cardsCount) : cardsCount, + isPrivate: !!deck.isPrivate, + author: { + id: deck.authorId, + name: deck.authorName, + }, + }, + ['authorId', 'authorName'] + ) + }) + + // Return the result with pagination data + return { + items: modifiedDecks, + pagination: { + totalItems: total, + currentPage, + itemsPerPage, + totalPages: Math.ceil(total / itemsPerPage), + }, + } + } catch (e) { + this.logger.error(e?.message) + throw new InternalServerErrorException(e?.message) + } + } + async findAllDecks({ name = undefined, authorId = undefined, diff --git a/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts b/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts index b2e70cc..0448482 100644 --- a/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts +++ b/src/modules/decks/use-cases/delete-deck-by-id-use-case.ts @@ -6,7 +6,8 @@ import { DecksRepository } from '../infrastructure/decks.repository' export class DeleteDeckByIdCommand { constructor( public readonly id: string, - public readonly userId: string + public readonly userId: string, + public readonly isAdmin?: boolean ) {} } @@ -18,7 +19,7 @@ export class DeleteDeckByIdHandler implements ICommandHandler { + if (command.params.isAdmin) + return (await this.deckRepository.findAllDecksForAdmin(command.params)) as any + return await this.deckRepository.findAllDecks(command.params) } } diff --git a/src/modules/users/api/users.controller.ts b/src/modules/users/api/users.controller.ts index 1a9abe5..c11bdb0 100644 --- a/src/modules/users/api/users.controller.ts +++ b/src/modules/users/api/users.controller.ts @@ -14,7 +14,9 @@ import { ApiTags } from '@nestjs/swagger' import { Pagination } from '../../../infrastructure/common/pagination/pagination.service' import { BaseAuthGuard } from '../../auth/guards' +import { AdminGuard } from '../../auth/guards/admin.guard' import { CreateUserCommand } from '../../auth/use-cases' +import { DecksRepository } from '../../decks/infrastructure/decks.repository' import { CreateUserDto } from '../dto/create-user.dto' import { UsersService } from '../services/users.service' @@ -23,25 +25,56 @@ import { UsersService } from '../services/users.service' export class UsersController { constructor( private usersService: UsersService, + private decksRepository: DecksRepository, private commandBus: CommandBus ) {} + @UseGuards(AdminGuard) @Get() async findAll(@Query() query) { const { currentPage, itemsPerPage } = Pagination.getPaginationData(query) - const users = await this.usersService.getUsers( - currentPage, - itemsPerPage, - query.name, - query.email - ) + const users = await this.usersService.getUsers({ + page: currentPage, + pageSize: itemsPerPage, + name: query.name, + email: query.email, + orderBy: query.orderBy, + id: query.id, + }) if (!users) throw new NotFoundException('Users not found') return users } + @UseGuards(AdminGuard) + @Get('empty-decks') + async getEmptyDecks() { + const decks = await this.decksRepository.findAllDecksForAdmin({}) + const emptyDecks = decks.items.filter(deck => deck.cardsCount === 0) + const emptyDecksCount = emptyDecks.length + const privateEmptyDecks = emptyDecks.filter(deck => deck.isPrivate).length + const publicEmptyDecks = emptyDecksCount - privateEmptyDecks + + return { + totalDecks: decks.items.length, + emptyDecksCount, + privateEmptyDecks, + publicEmptyDecks, + } + } + + @UseGuards(AdminGuard) + @Delete('empty-decks') + async removeEmptyDecks() { + const decks = await this.decksRepository.findAllDecksForAdmin({}) + const emptyDecks = decks.items.filter(deck => deck.cardsCount === 0) + const emptyDeckIds = emptyDecks.map(deck => deck.id) + + return this.decksRepository.deleteManyById(emptyDeckIds) + } + @Get('/test-user-name') async testUserNamePage() { const user = await this.usersService.getUserByEmail('example@google.com') @@ -63,7 +96,7 @@ export class UsersController { ) } - @UseGuards(BaseAuthGuard) + @UseGuards(AdminGuard) @Delete(':id') async remove(@Param('id') id: string) { return await this.usersService.deleteUserById(id) diff --git a/src/modules/users/infrastructure/users.repository.ts b/src/modules/users/infrastructure/users.repository.ts index ac2f757..5eb33c4 100644 --- a/src/modules/users/infrastructure/users.repository.ts +++ b/src/modules/users/infrastructure/users.repository.ts @@ -8,6 +8,7 @@ import { Prisma } from '@prisma/client' import { addHours } from 'date-fns' import { v4 as uuidv4 } from 'uuid' +import { getOrderByObject } from '../../../infrastructure/common/helpers/get-order-by-object' import { Pagination } from '../../../infrastructure/common/pagination/pagination.service' import { PrismaService } from '../../../prisma.service' import { @@ -24,12 +25,26 @@ export class UsersRepository { private readonly logger = new Logger(UsersRepository.name) - async getUsers( - currentPage: number, - itemsPerPage: number, - searchNameTerm: string, + async getUsers({ + currentPage, + itemsPerPage, + orderBy, + id, + searchNameTerm, + searchEmailTerm, + }: { + currentPage: number + itemsPerPage: number + searchNameTerm: string searchEmailTerm: string - ): Promise> { + orderBy?: string | null + id?: string + }): Promise> { + if (!orderBy || orderBy === 'null') { + orderBy = 'updated-desc' + } + const { key, direction } = getOrderByObject(orderBy) || {} + try { const where: Prisma.userWhereInput = { name: { @@ -38,6 +53,7 @@ export class UsersRepository { email: { contains: searchEmailTerm || undefined, }, + ...(id && { id }), } const res = await this.prisma.$transaction([ this.prisma.user.count({ where }), @@ -48,7 +64,12 @@ export class UsersRepository { name: true, email: true, isEmailVerified: true, + isAdmin: true, + avatar: true, + created: true, + updated: true, }, + orderBy: { [key]: direction as Prisma.SortOrder }, skip: (currentPage - 1) * itemsPerPage, take: itemsPerPage, }), @@ -106,6 +127,7 @@ export class UsersRepository { throw new InternalServerErrorException(e) } } + async deleteUserById(id: string): Promise { try { const result = await this.prisma.user.delete({ diff --git a/src/modules/users/services/users.service.ts b/src/modules/users/services/users.service.ts index 464d406..3471e93 100644 --- a/src/modules/users/services/users.service.ts +++ b/src/modules/users/services/users.service.ts @@ -13,8 +13,29 @@ export class UsersService { private logger = new Logger(UsersService.name) - async getUsers(page: number, pageSize: number, name: string, email: string) { - return await this.usersRepository.getUsers(page, pageSize, name, email) + async getUsers({ + page, + pageSize, + name, + email, + orderBy, + id, + }: { + page: number + pageSize: number + name: string + email: string + orderBy?: string | null + id?: string + }) { + return await this.usersRepository.getUsers({ + currentPage: page, + itemsPerPage: pageSize, + searchNameTerm: name, + searchEmailTerm: email, + orderBy, + id, + }) } async getUserById(id: string) { diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 8ec6e9f..8743337 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' import { CqrsModule } from '@nestjs/cqrs' +import { DecksRepository } from '../decks/infrastructure/decks.repository' + import { UsersController } from './api/users.controller' import { UsersRepository } from './infrastructure/users.repository' import { UsersService } from './services/users.service' @@ -8,7 +10,7 @@ import { UsersService } from './services/users.service' @Module({ imports: [CqrsModule], controllers: [UsersController], - providers: [UsersService, UsersRepository], + providers: [UsersService, UsersRepository, DecksRepository], exports: [UsersRepository, UsersService, CqrsModule], }) export class UsersModule {}