fix pagination and create pagination service

This commit is contained in:
andres
2023-07-12 13:06:37 +02:00
parent 3db8bfb0f8
commit 68942e904f
14 changed files with 233 additions and 141 deletions

View File

@@ -9,19 +9,19 @@ generator client {
previewFeatures = ["fullTextSearch", "fullTextIndex"] previewFeatures = ["fullTextSearch", "fullTextIndex"]
} }
model Verification { model verification {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
isEmailVerified Boolean @default(false) isEmailVerified Boolean @default(false)
verificationToken String? @unique @default(uuid()) verificationToken String? @unique @default(uuid())
verificationTokenExpiry DateTime? verificationTokenExpiry DateTime?
verificationEmailsSent Int @default(0) verificationEmailsSent Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user user @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
} }
model User { model user {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
@@ -34,51 +34,51 @@ model User {
deleteTime Int? deleteTime Int?
created DateTime @default(now()) created DateTime @default(now())
updated DateTime @updatedAt updated DateTime @updatedAt
cards Card[] cards card[]
decks Deck[] decks deck[]
grades Grade[] grades grade[]
generalChatMessages GeneralChatMessage[] generalChatMessages GeneralChatMessage[]
verification Verification? verification verification?
RevokedToken RevokedToken[] revokedToken revokedToken[]
RefreshToken RefreshToken[] RefreshToken refreshToken[]
ResetPassword ResetPassword? resetPassword resetPassword?
@@fulltext([name, email]) @@fulltext([name, email])
} }
model RevokedToken { model revokedToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
token String @unique token String @unique
revokedAt DateTime @default(now()) revokedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user user @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
} }
model RefreshToken { model refreshToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
token String @unique @db.VarChar(255) token String @unique @db.VarChar(255)
expiresAt DateTime expiresAt DateTime
isRevoked Boolean @default(false) isRevoked Boolean @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user user @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
} }
model ResetPassword { model resetPassword {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
resetPasswordToken String? @unique @default(uuid()) resetPasswordToken String? @unique @default(uuid())
resetPasswordTokenExpiry DateTime? resetPasswordTokenExpiry DateTime?
resetPasswordEmailsSent Int @default(0) resetPasswordEmailsSent Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user user @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId]) @@index([userId])
} }
model Card { model card {
id String @id @default(cuid()) id String @id @default(cuid())
deckId String deckId String
userId String userId String
@@ -96,34 +96,35 @@ model Card {
moreId String? moreId String?
created DateTime @default(now()) created DateTime @default(now())
updated DateTime @updatedAt updated DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) author user @relation(fields: [userId], references: [id], onDelete: Cascade)
decks Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) decks deck @relation(fields: [deckId], references: [id], onDelete: Cascade)
grades Grade[] grades grade[]
@@index([userId]) @@index([userId])
@@index([deckId]) @@index([deckId])
} }
model Deck { model deck {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
name String name String
isPrivate Boolean @default(false) isPrivate Boolean @default(false)
shots Int @default(0) shots Int @default(0)
cover String? cover String?
rating Int @default(0) rating Int @default(0)
isDeleted Boolean? isDeleted Boolean?
isBlocked Boolean? isBlocked Boolean?
created DateTime @default(now()) created DateTime @default(now())
updated DateTime @updatedAt updated DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) author user @relation(fields: [userId], references: [id])
Card Card[] cardsCount Int @default(0)
Grade Grade[] card card[]
grade grade[]
@@index([userId]) @@index([userId])
} }
model Grade { model grade {
id String @id @default(cuid()) id String @id @default(cuid())
deckId String deckId String
cardId String cardId String
@@ -133,9 +134,9 @@ model Grade {
moreId String? moreId String?
created DateTime @default(now()) created DateTime @default(now())
updated DateTime @updatedAt updated DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user user @relation(fields: [userId], references: [id])
card Card @relation(fields: [cardId], references: [id]) card card @relation(fields: [cardId], references: [id])
decks Deck @relation(fields: [deckId], references: [id]) decks deck @relation(fields: [deckId], references: [id])
@@index([userId]) @@index([userId])
@@index([deckId]) @@index([deckId])
@@ -151,7 +152,7 @@ model GeneralChatMessage {
message String message String
created DateTime @default(now()) created DateTime @default(now())
updated DateTime @updatedAt updated DateTime @updatedAt
user User @relation(fields: [userId], references: [id]) user user @relation(fields: [userId], references: [id])
@@index([userId]) @@index([userId])
} }

View File

@@ -0,0 +1,8 @@
import { omit } from 'remeda'
export const setCountKey = <K extends string, L extends string>(key: K, newKey: L) => {
return <T extends Record<string, any>>(obj: T) => {
obj[newKey] = obj['_count'][key]
return omit(obj, ['_count']) as Omit<T, '_count'> & { [P in L]: number }
}
}

View File

@@ -10,5 +10,5 @@ export class PaginationDto {
@Type(() => Number) @Type(() => Number)
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
pageSize?: number itemsPerPage?: number
} }

View File

@@ -1,9 +1,43 @@
import { isObject } from 'remeda'
export class Pagination { export class Pagination {
static getPaginationData(query) { static getPaginationData<T>(query: T) {
const page = typeof query.PageNumber === 'string' ? +query.PageNumber : 1 if (!isObject(query)) throw new Error('Pagination.getPaginationData: query is not an object')
const pageSize = typeof query.PageSize === 'string' ? +query.PageSize : 10
const searchNameTerm = typeof query.SearchNameTerm === 'string' ? query.SearchNameTerm : '' const currentPage =
const searchEmailTerm = typeof query.SearchEmailTerm === 'string' ? query.SearchEmailTerm : '' 'currentPage' in query &&
return { page, pageSize, searchNameTerm, searchEmailTerm } typeof query.currentPage === 'string' &&
!isNaN(Number(query.currentPage))
? +query.currentPage
: 1
const itemsPerPage =
'itemsPerPage' in query &&
typeof query.itemsPerPage === 'string' &&
!isNaN(Number(query.itemsPerPage))
? +query.itemsPerPage
: 10
return { currentPage, itemsPerPage, ...query }
}
static transformPaginationData<T>(
[count, items]: [number, T],
{
currentPage,
itemsPerPage,
}: {
currentPage: number
itemsPerPage: number
}
) {
const totalPages = Math.ceil(count / itemsPerPage)
return {
pagination: {
totalPages,
currentPage,
itemsPerPage,
totalItems: count,
},
items,
}
} }
} }

View File

@@ -1,7 +1,8 @@
import { IsEmail, Length } from 'class-validator' import { IsEmail, Length, IsOptional } from 'class-validator'
export class RegistrationDto { export class RegistrationDto {
@Length(3, 30) @Length(3, 30)
@IsOptional()
name: string name: string
@Length(3, 30) @Length(3, 30)
password: string password: string

View File

@@ -1,32 +1,44 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common' import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'
import { PrismaService } from '../../../prisma.service' import { PrismaService } from '../../../prisma.service'
import { GetAllCardsInDeckDto } from '../dto/get-all-cards.dto' import { GetAllCardsInDeckDto } from '../dto/get-all-cards.dto'
import {
DEFAULT_PAGE_NUMBER,
DEFAULT_PAGE_SIZE,
} from '../../../infrastructure/common/pagination/pagination.constants'
import { CreateCardDto } from '../dto/create-card.dto' import { CreateCardDto } from '../dto/create-card.dto'
@Injectable() @Injectable()
export class CardsRepository { export class CardsRepository {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
private readonly logger = new Logger(CardsRepository.name) private readonly logger = new Logger(CardsRepository.name)
async createCard(deckId: string, userId: string, card: CreateCardDto) { async createCard(deckId: string, userId: string, card: CreateCardDto) {
try { try {
return await this.prisma.card.create({ return await this.prisma.$transaction(async tx => {
data: { const created = await tx.card.create({
user: { data: {
connect: { author: {
id: userId, connect: {
id: userId,
},
},
decks: {
connect: {
id: deckId,
},
},
...card,
},
})
await tx.deck.update({
where: {
id: deckId,
},
data: {
cardsCount: {
increment: 1,
}, },
}, },
decks: { })
connect: { return created
id: deckId,
},
},
...card,
},
}) })
} catch (e) { } catch (e) {
this.logger.error(e?.message) this.logger.error(e?.message)
@@ -36,12 +48,7 @@ export class CardsRepository {
async findCardsByDeckId( async findCardsByDeckId(
deckId: string, deckId: string,
{ { answer = undefined, question = undefined, currentPage, itemsPerPage }: GetAllCardsInDeckDto
answer = undefined,
question = undefined,
currentPage = DEFAULT_PAGE_NUMBER,
pageSize = DEFAULT_PAGE_SIZE,
}: GetAllCardsInDeckDto
) { ) {
try { try {
return await this.prisma.card.findMany({ return await this.prisma.card.findMany({
@@ -56,14 +63,15 @@ export class CardsRepository {
contains: answer || undefined, contains: answer || undefined,
}, },
}, },
skip: (currentPage - 1) * pageSize, skip: (currentPage - 1) * itemsPerPage,
take: pageSize, take: itemsPerPage,
}) })
} catch (e) { } catch (e) {
this.logger.error(e?.message) this.logger.error(e?.message)
throw new InternalServerErrorException(e?.message) throw new InternalServerErrorException(e?.message)
} }
} }
public async findDeckById(id: string) { public async findDeckById(id: string) {
try { try {
return await this.prisma.deck.findUnique({ return await this.prisma.deck.findUnique({

View File

@@ -26,8 +26,9 @@ import {
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard' import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'
import { GetAllDecksDto } from './dto/get-all-decks.dto' import { GetAllDecksDto } from './dto/get-all-decks.dto'
import { GetAllCardsInDeckDto } from '../cards/dto/get-all-cards.dto' import { GetAllCardsInDeckDto } from '../cards/dto/get-all-cards.dto'
import { CreateCardCommand } from './use-cases/create-card-use-case' import { CreateCardCommand } from './use-cases'
import { CreateCardDto } from '../cards/dto/create-card.dto' import { CreateCardDto } from '../cards/dto/create-card.dto'
import { Pagination } from '../../infrastructure/common/pagination/pagination.service'
@Controller('decks') @Controller('decks')
export class DecksController { export class DecksController {
@@ -43,7 +44,8 @@ export class DecksController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get() @Get()
findAll(@Query() query: GetAllDecksDto, @Req() req) { findAll(@Query() query: GetAllDecksDto, @Req() req) {
return this.commandBus.execute(new GetAllDecksCommand({ ...query, userId: req.user.id })) const finalQuery = Pagination.getPaginationData(query)
return this.commandBus.execute(new GetAllDecksCommand({ ...finalQuery, userId: req.user.id }))
} }
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)

View File

@@ -1,10 +1,15 @@
import { IsOptional, IsUUID, Length } from 'class-validator' import { IsUUID } from 'class-validator'
import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string' import { IsOptionalOrEmptyString } from '../../../infrastructure/decorators/is-optional-or-empty-string'
import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto' import { PaginationDto } from '../../../infrastructure/common/pagination/pagination.dto'
export class GetAllDecksDto extends PaginationDto { export class GetAllDecksDto extends PaginationDto {
@IsOptional() @IsOptionalOrEmptyString()
@Length(3, 30) minCardsCount?: string
@IsOptionalOrEmptyString()
maxCardsCount?: string
@IsOptionalOrEmptyString()
name?: string name?: string
@IsOptionalOrEmptyString() @IsOptionalOrEmptyString()

View File

@@ -1,15 +1,14 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common' import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'
import { PrismaService } from '../../../prisma.service' import { PrismaService } from '../../../prisma.service'
import { GetAllDecksDto } from '../dto/get-all-decks.dto' import { GetAllDecksDto } from '../dto/get-all-decks.dto'
import { import { Pagination } from '../../../infrastructure/common/pagination/pagination.service'
DEFAULT_PAGE_NUMBER,
DEFAULT_PAGE_SIZE,
} from '../../../infrastructure/common/pagination/pagination.constants'
@Injectable() @Injectable()
export class DecksRepository { export class DecksRepository {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
private readonly logger = new Logger(DecksRepository.name) private readonly logger = new Logger(DecksRepository.name)
async createDeck({ async createDeck({
name, name,
userId, userId,
@@ -24,7 +23,7 @@ export class DecksRepository {
try { try {
return await this.prisma.deck.create({ return await this.prisma.deck.create({
data: { data: {
user: { author: {
connect: { connect: {
id: userId, id: userId,
}, },
@@ -45,42 +44,74 @@ export class DecksRepository {
name = undefined, name = undefined,
authorId = undefined, authorId = undefined,
userId, userId,
currentPage = DEFAULT_PAGE_NUMBER, currentPage,
pageSize = DEFAULT_PAGE_SIZE, itemsPerPage,
minCardsCount,
maxCardsCount,
}: GetAllDecksDto) { }: GetAllDecksDto) {
console.log({ name, authorId, userId, currentPage, itemsPerPage, minCardsCount, maxCardsCount })
try { try {
return await this.prisma.deck.findMany({ const where = {
where: { cardsCount: {
name: { gte: Number(minCardsCount) ?? undefined,
contains: name, lte: Number(maxCardsCount) ?? undefined,
},
user: {
id: authorId || undefined,
},
OR: [
{
AND: [
{
isPrivate: true,
},
{
userId: userId,
},
],
},
{
isPrivate: false,
},
],
}, },
skip: (currentPage - 1) * pageSize, name: {
take: pageSize, contains: name,
}) },
author: {
id: authorId || undefined,
},
OR: [
{
AND: [
{
isPrivate: true,
},
{
userId: userId,
},
],
},
{
isPrivate: false,
},
],
}
const [count, items, max] = await this.prisma.$transaction([
this.prisma.deck.count({
where,
}),
this.prisma.deck.findMany({
where,
orderBy: {
created: 'desc',
},
include: {
author: {
select: {
id: true,
name: true,
},
},
},
skip: (currentPage - 1) * itemsPerPage,
take: itemsPerPage,
}),
this.prisma
.$queryRaw`SELECT MAX(card_count) as maxCardsCount FROM (SELECT COUNT(*) as card_count FROM card GROUP BY deckId) AS card_counts;`,
])
return {
maxCardsCount: Number(max[0].maxCardsCount),
...Pagination.transformPaginationData([count, items], { currentPage, itemsPerPage }),
}
} catch (e) { } catch (e) {
this.logger.error(e?.message) this.logger.error(e?.message)
throw new InternalServerErrorException(e?.message) throw new InternalServerErrorException(e?.message)
} }
} }
public async findDeckById(id: string) { public async findDeckById(id: string) {
try { try {
return await this.prisma.deck.findUnique({ return await this.prisma.deck.findUnique({

View File

@@ -22,8 +22,9 @@ export class UsersController {
@Get() @Get()
async findAll(@Query() query) { async findAll(@Query() query) {
const { page, pageSize, searchNameTerm, searchEmailTerm } = Pagination.getPaginationData(query) const { page, pageSize } = Pagination.getPaginationData(query)
const users = await this.usersService.getUsers(page, pageSize, searchNameTerm, searchEmailTerm)
const users = await this.usersService.getUsers(page, pageSize, query.name, query.email)
if (!users) throw new NotFoundException('Users not found') if (!users) throw new NotFoundException('Users not found')
return users return users
} }

View File

@@ -1,6 +1,6 @@
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
export class User implements Prisma.UserUncheckedCreateInput { export class User implements Prisma.userUncheckedCreateInput {
id: string id: string
email: string email: string
password: string password: string

View File

@@ -14,8 +14,8 @@ import {
import { addHours } from 'date-fns' import { addHours } from 'date-fns'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { PrismaService } from '../../../prisma.service' import { PrismaService } from '../../../prisma.service'
import { pick } from 'remeda'
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
import { Pagination } from '../../../infrastructure/common/pagination/pagination.service'
@Injectable() @Injectable()
export class UsersRepository { export class UsersRepository {
@@ -30,31 +30,29 @@ export class UsersRepository {
searchEmailTerm: string searchEmailTerm: string
): Promise<EntityWithPaginationType<UserViewType>> { ): Promise<EntityWithPaginationType<UserViewType>> {
try { try {
const where = { const where: Prisma.userWhereInput = {
name: { name: {
search: searchNameTerm || undefined, contains: searchNameTerm || undefined,
}, },
email: { email: {
search: searchEmailTerm || undefined, contains: searchEmailTerm || undefined,
}, },
} }
const [totalItems, users] = await this.prisma.$transaction([ const res = await this.prisma.$transaction([
this.prisma.user.count({ where }), this.prisma.user.count({ where }),
this.prisma.user.findMany({ this.prisma.user.findMany({
where, where,
select: {
id: true,
name: true,
email: true,
isEmailVerified: true,
},
skip: (currentPage - 1) * itemsPerPage, skip: (currentPage - 1) * itemsPerPage,
take: itemsPerPage, take: itemsPerPage,
}), }),
]) ])
const totalPages = Math.ceil(totalItems / itemsPerPage) return Pagination.transformPaginationData(res, { currentPage, itemsPerPage })
const usersView = users.map(u => pick(u, ['id', 'name', 'email', 'isEmailVerified']))
return {
totalPages,
currentPage,
itemsPerPage,
totalItems,
items: usersView,
}
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2025') { if (e.code === 'P2025') {
@@ -124,7 +122,7 @@ export class UsersRepository {
} }
} }
async findUserById(id: string, include?: Prisma.UserInclude) { async findUserById(id: string, include?: Prisma.userInclude) {
try { try {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id }, where: { id },
@@ -134,7 +132,7 @@ export class UsersRepository {
return null return null
} }
return user as Prisma.UserGetPayload<{ include: typeof include }> return user as Prisma.userGetPayload<{ include: typeof include }>
} catch (e) { } catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2015') { if (e.code === 'P2015') {

View File

@@ -9,8 +9,8 @@ export class UsersService {
private logger = new Logger(UsersService.name) private logger = new Logger(UsersService.name)
async getUsers(page: number, pageSize: number, searchNameTerm: string, searchEmailTerm: string) { async getUsers(page: number, pageSize: number, name: string, email: string) {
return await this.usersRepository.getUsers(page, pageSize, searchNameTerm, searchEmailTerm) return await this.usersRepository.getUsers(page, pageSize, name, email)
} }
async getUserById(id: string) { async getUserById(id: string) {

View File

@@ -1,4 +1,5 @@
import { Prisma } from '@prisma/client' import { Prisma } from '@prisma/client'
export type NewestLikesType = { export type NewestLikesType = {
id: string id: string
login: string login: string
@@ -46,12 +47,13 @@ export type CommentType = {
myStatus: string myStatus: string
} }
} }
export type EntityWithPaginationType<T> = { export type EntityWithPaginationType<T> = {
totalPages: number pagination: {
currentPage: number totalPages: number
itemsPerPage: number currentPage: number
totalItems: number itemsPerPage: number
totalItems: number
}
items: T[] items: T[]
} }
@@ -65,22 +67,22 @@ export type ErrorMessageType = {
field: string field: string
} }
const userInclude: Prisma.UserInclude = { const userInclude: Prisma.userInclude = {
verification: true, verification: true,
} }
export type VerificationWithUser = Prisma.VerificationGetPayload<{ export type VerificationWithUser = Prisma.verificationGetPayload<{
include: { user: true } include: { user: true }
}> }>
export type User = Prisma.UserGetPayload<{ export type User = Prisma.userGetPayload<{
include: typeof userInclude include: typeof userInclude
}> }>
export type CreateUserInput = Omit<Prisma.UserCreateInput & Prisma.VerificationCreateInput, 'user'> export type CreateUserInput = Omit<Prisma.userCreateInput & Prisma.verificationCreateInput, 'user'>
export type UserType = { export type UserType = {
accountData: Prisma.UserCreateInput accountData: Prisma.userCreateInput
emailConfirmation: EmailConfirmationType emailConfirmation: EmailConfirmationType
} }
@@ -139,4 +141,5 @@ export enum LikeAction {
Dislike = 'Dislike', Dislike = 'Dislike',
None = 'None', None = 'None',
} }
export type LikeActionType = 'Like' | 'Dislike' | 'None' export type LikeActionType = 'Like' | 'Dislike' | 'None'