mirror of
https://github.com/ershisan99/flashcards-api.git
synced 2025-12-16 20:59:26 +00:00
feat: admin endpoints
This commit is contained in:
22
src/modules/auth/guards/admin.guard.ts
Normal file
22
src/modules/auth/guards/admin.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user