diff --git a/server/package-lock.json b/server/package-lock.json index 6d88903..44ff913 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,6 +17,7 @@ "@nestjs/swagger": "^8.1.0", "@prisma/client": "^6.0.1", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -3674,12 +3675,10 @@ "license": "MIT" }, "node_modules/class-transformer": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", - "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==", - "license": "MIT", - "optional": true, - "peer": true + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.1", diff --git a/server/package.json b/server/package.json index f4eee9e..4e724af 100644 --- a/server/package.json +++ b/server/package.json @@ -28,6 +28,7 @@ "@nestjs/swagger": "^8.1.0", "@prisma/client": "^6.0.1", "bcrypt": "^5.1.1", + "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", diff --git a/server/src/common/dto/pagination-output.dto.ts b/server/src/common/dto/pagination-output.dto.ts new file mode 100644 index 0000000..e78b214 --- /dev/null +++ b/server/src/common/dto/pagination-output.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from "@nestjs/swagger" +import { IsNumber, IsOptional } from "class-validator" + +export const DEFAULT_PAGE_NUMBER = 1 +export const DEFAULT_PAGE_SIZE = 20 + +export class MetaPaginateOutputDto { + @IsNumber() + @ApiProperty() + total: number + + @IsNumber() + @ApiProperty() + lastPage: number + + @IsNumber() + @ApiProperty({ default: DEFAULT_PAGE_NUMBER }) + currentPage: number = DEFAULT_PAGE_NUMBER + + @IsNumber() + @ApiProperty({ default: DEFAULT_PAGE_SIZE }) + totalPerPage: number = DEFAULT_PAGE_SIZE + + @IsOptional() + @IsNumber() + @ApiProperty({ required: false, nullable: true, default: null }) + prevPage?: number | null + + @IsOptional() + @IsNumber() + @ApiProperty({ required: false, nullable: true, default: null }) + nextPage?: number | null + } + + export class PaginateOutputDto { + @ApiProperty({ isArray: true }) + data: T[] + + @ApiProperty() + meta: MetaPaginateOutputDto + } + \ No newline at end of file diff --git a/server/src/common/dto/pagination-query.dto.ts b/server/src/common/dto/pagination-query.dto.ts new file mode 100644 index 0000000..0b094c2 --- /dev/null +++ b/server/src/common/dto/pagination-query.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Type } from 'class-transformer' +import { IsNumber, IsOptional } from 'class-validator' + +export class QueryPaginationDto { + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiProperty({default: 1, required: false, description: "Numéro de page à charger"}) + page?: number = 1 + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiProperty({default: 20, required: false, description: "Nombre d'éléments à charger par page"}) + size?: number = 20 +} diff --git a/server/src/common/utils/pagination.utils.ts b/server/src/common/utils/pagination.utils.ts new file mode 100644 index 0000000..8c79f6d --- /dev/null +++ b/server/src/common/utils/pagination.utils.ts @@ -0,0 +1,86 @@ +import { applyDecorators, NotFoundException, Type } from '@nestjs/common' +import { QueryPaginationDto } from '../dto/pagination-query.dto' +import { ApiExtraModels, ApiOkResponse, ApiResponseNoStatusOptions, getSchemaPath } from '@nestjs/swagger' +import { DEFAULT_PAGE_NUMBER, DEFAULT_PAGE_SIZE, PaginateOutputDto } from '../dto/pagination-output.dto' + +export interface PrismaPaginationParams { + skip: number + take: number +} + +export const paginate = ( + query: QueryPaginationDto, +): PrismaPaginationParams => { + const size = query.size || DEFAULT_PAGE_SIZE + const page = query.page || DEFAULT_PAGE_NUMBER + return { + skip: size * (page - 1), + take: size, + } +} + +export const paginateOutput = ( + data: T[], + total: number, + query: QueryPaginationDto, +): PaginateOutputDto => { + const page = query.page || DEFAULT_PAGE_NUMBER + const size = query.size || DEFAULT_PAGE_SIZE + + const lastPage = Math.ceil(total / size) + + // if data is empty, return empty array + if (!data.length) { + return { + data, + meta: { + total, + lastPage, + currentPage: page, + totalPerPage: size, + prevPage: null, + nextPage: null, + }, + } + } + + // if page is greater than last page, throw an error + if (page > lastPage) { + throw new NotFoundException( + `Page ${page} not found. Last page is ${lastPage}`, + ) + } + + return { + data, + meta: { + total, + lastPage, + currentPage: page, + totalPerPage: size, + prevPage: page > 1 ? page - 1 : null, + nextPage: page < lastPage ? page + 1 : null, + }, + } +} + +export const ApiOkResponsePaginated = >(dataDto: DataDto, options?: ApiResponseNoStatusOptions) => + applyDecorators( + ApiExtraModels(PaginateOutputDto, dataDto), + ApiOkResponse({ + ...options, + schema: { + allOf: [ + { $ref: getSchemaPath(PaginateOutputDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(dataDto) }, + }, + }, + }, + ], + }, + }), + ) diff --git a/server/src/geolocations/geolocations.controller.ts b/server/src/geolocations/geolocations.controller.ts index e544f08..2b0c6e7 100644 --- a/server/src/geolocations/geolocations.controller.ts +++ b/server/src/geolocations/geolocations.controller.ts @@ -1,9 +1,12 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseGuards, HttpCode, Req, NotFoundException } from '@nestjs/common' +import { Controller, Get, Post, Body, Patch, Param, Delete, ParseIntPipe, UseGuards, HttpCode, Req, NotFoundException, Query } from '@nestjs/common' import { GeolocationsService } from './geolocations.service' import { CreateGeolocationDto } from './dto/create-geolocation.dto' import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { ApiBearerAuth, ApiCreatedResponse, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger' import { GeolocationEntity } from './entities/geolocation.entity' +import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils' +import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto' +import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto' @Controller('geolocations') export class GeolocationsController { @@ -26,13 +29,13 @@ export class GeolocationsController { @Get() @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOkResponse({ type: GeolocationEntity, isArray: true }) + @ApiOkResponsePaginated(GeolocationEntity) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiForbiddenResponse({ description: "Permission refusée" }) @ApiNotFoundResponse({ description: "Objet non trouvé" }) - async findAll(): Promise { - const geolocations = await this.geolocationsService.findAll() - return geolocations.map(geolocation => new GeolocationEntity(geolocation)) + async findAll(@Query() queryPagination?: QueryPaginationDto): Promise> { + const [geolocations, total] = await this.geolocationsService.findAll(queryPagination) + return paginateOutput(geolocations.map(geolocation => new GeolocationEntity(geolocation)), total, queryPagination) } @Get(':id') diff --git a/server/src/geolocations/geolocations.service.ts b/server/src/geolocations/geolocations.service.ts index fb15a54..e8e2ac2 100644 --- a/server/src/geolocations/geolocations.service.ts +++ b/server/src/geolocations/geolocations.service.ts @@ -2,6 +2,8 @@ import { Injectable } from '@nestjs/common' import { CreateGeolocationDto } from './dto/create-geolocation.dto' import { PrismaService } from 'src/prisma/prisma.service' import { Geolocation, User } from '@prisma/client' +import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto' +import { paginate } from 'src/common/utils/pagination.utils' @Injectable() export class GeolocationsService { @@ -12,8 +14,13 @@ export class GeolocationsService { return await this.prisma.geolocation.create({ data: data }) } - async findAll(): Promise { - return await this.prisma.geolocation.findMany() + async findAll(queryPagination?: QueryPaginationDto): Promise<[Geolocation[], number]> { + return [ + await this.prisma.geolocation.findMany({ + ...paginate(queryPagination), + }), + await this.prisma.geolocation.count(), + ] } async findOne(id: number): Promise { diff --git a/server/src/main.ts b/server/src/main.ts index 4fa383a..acabe9f 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -6,7 +6,7 @@ import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common' async function bootstrap() { const app = await NestFactory.create(AppModule) - app.useGlobalPipes(new ValidationPipe()) + app.useGlobalPipes(new ValidationPipe({ transform: true })) app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) const config = new DocumentBuilder() diff --git a/server/src/users/users.controller.ts b/server/src/users/users.controller.ts index 2c64851..a4b4e6b 100644 --- a/server/src/users/users.controller.ts +++ b/server/src/users/users.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Req, UseGuards } from '@nestjs/common' +import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Query, Req, UseGuards } from '@nestjs/common' import { UsersService } from './users.service' import { ApiBadRequestResponse, ApiBearerAuth, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger' import { UserEntity } from './entities/user.entity' import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { UpdatePasswordDto } from './dto/user_password.dto' +import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto' +import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils' +import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto' @Controller('users') export class UsersController { @@ -12,12 +15,12 @@ export class UsersController { @Get() @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOkResponse({ type: UserEntity, isArray: true }) + @ApiOkResponsePaginated(UserEntity) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiForbiddenResponse({ description: "Permission refusée" }) - async findAll() { - const users = await this.usersService.findAll() - return users.map(user => new UserEntity(user)) + async findAll(@Query() queryPagination?: QueryPaginationDto): Promise> { + const [users, total] = await this.usersService.findAll(queryPagination) + return paginateOutput(users.map(user => new UserEntity(user)), total, queryPagination) } @Get(':id') diff --git a/server/src/users/users.service.ts b/server/src/users/users.service.ts index 88bf22f..1845b34 100644 --- a/server/src/users/users.service.ts +++ b/server/src/users/users.service.ts @@ -3,20 +3,25 @@ import { User } from '@prisma/client' import { PrismaService } from 'src/prisma/prisma.service' import * as bcrypt from 'bcrypt' import { UpdatePasswordDto } from './dto/user_password.dto' +import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto' +import { paginate } from 'src/common/utils/pagination.utils' @Injectable() export class UsersService { constructor(private prisma: PrismaService) {} - async findAll() { - return await this.prisma.user.findMany() + async findAll(queryPagination?: QueryPaginationDto): Promise<[User[], number]> { + return [ + await this.prisma.user.findMany({ ...paginate(queryPagination) }), + await this.prisma.user.count() + ] } - async findOne(id: number) { + async findOne(id: number): Promise { return await this.prisma.user.findUnique({ where: { id } }) } - async updatePassword(user: User, { password }: UpdatePasswordDto) { + async updatePassword(user: User, { password }: UpdatePasswordDto): Promise { const hashedPassword = await bcrypt.hash(password, 10) await this.prisma.user.update({ where: { id: user.id },