feat: admin endpoints

This commit is contained in:
2024-06-11 12:33:50 +02:00
parent 958f9bae30
commit 9875b665b7
9 changed files with 327 additions and 22 deletions

View File

@@ -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<boolean> {
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
}
}

View File

@@ -91,16 +91,22 @@ export class DecksController {
findAllV2(@Query() query: GetAllDecksDto, @Req() req): Promise<PaginatedDecks> {
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<Deck> {
return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id))
return this.commandBus.execute(new DeleteDeckByIdCommand(id, req.user.id, req.user.isAdmin))
}
@ApiOperation({

View File

@@ -67,6 +67,201 @@ export class DecksRepository {
}
}
async findAllDecksForAdmin({
name = undefined,
authorId = undefined,
favoritedBy = undefined,
userId,
currentPage,
itemsPerPage,
minCardsCount,
maxCardsCount,
orderBy,
}: GetAllDecksDto): Promise<PaginatedDecks> {
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<any[]>(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,

View File

@@ -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<DeleteDeckByIdComm
const deck = await this.deckRepository.findDeckById(command.id, command.userId)
if (!deck) throw new NotFoundException(`Deck with id ${command.id} not found`)
if (deck.userId !== command.userId) {
if (deck.userId !== command.userId && !command.isAdmin) {
throw new ForbiddenException(`You can't delete a deck that you don't own`)
}

View File

@@ -5,7 +5,7 @@ import { PaginatedDecks } from '../entities/deck.entity'
import { DecksRepository } from '../infrastructure/decks.repository'
export class GetAllDecksV2Command {
constructor(public readonly params: GetAllDecksDto) {}
constructor(public readonly params: GetAllDecksDto & { isAdmin?: boolean }) {}
}
@CommandHandler(GetAllDecksV2Command)
@@ -13,6 +13,9 @@ export class GetAllDecksV2Handler implements ICommandHandler<GetAllDecksV2Comman
constructor(private readonly deckRepository: DecksRepository) {}
async execute(command: GetAllDecksV2Command): Promise<PaginatedDecks> {
if (command.params.isAdmin)
return (await this.deckRepository.findAllDecksForAdmin(command.params)) as any
return await this.deckRepository.findAllDecks(command.params)
}
}

View File

@@ -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)

View File

@@ -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<EntityWithPaginationType<UserViewType>> {
orderBy?: string | null
id?: string
}): Promise<EntityWithPaginationType<UserViewType>> {
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<boolean> {
try {
const result = await this.prisma.user.delete({

View File

@@ -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) {

View File

@@ -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 {}