Compare commits

...

2 Commits

Author SHA1 Message Date
138ff1df65
Ajout de la pagination sur l'API 2024-12-07 16:50:26 +01:00
86427bb41b
Ajout endpoint géolocalisation 2024-12-07 15:11:47 +01:00
19 changed files with 417 additions and 35 deletions

View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1,14 @@
/*
Warnings:
- Added the required column `accuracy` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `altitude` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `altitudeAccuracy` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
- Added the required column `speed` to the `Geolocation` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Geolocation" ADD COLUMN "accuracy" DOUBLE PRECISION NOT NULL,
ADD COLUMN "altitude" DOUBLE PRECISION NOT NULL,
ADD COLUMN "altitudeAccuracy" DOUBLE PRECISION NOT NULL,
ADD COLUMN "speed" DOUBLE PRECISION NOT NULL;

View File

@ -20,12 +20,16 @@ model User {
}
model Geolocation {
id Int @id @default(autoincrement())
userId Int
longitude Float
latitude Float
timestamp DateTime
user User @relation(fields: [userId], references: [id])
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
longitude Float
latitude Float
speed Float
accuracy Float
altitude Float
altitudeAccuracy Float
timestamp DateTime
}
model Challenge {
@ -38,17 +42,18 @@ model Challenge {
model ChallengeAction {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
challenge Challenge @relation(fields: [challengeId], references: [id])
challengeId Int @unique
active Boolean @default(false)
success Boolean @default(false)
challenge Challenge @relation(fields: [challengeId], references: [id])
user User @relation(fields: [userId], references: [id])
moneyUpdate MoneyUpdate?
}
model TrainTrip {
id String @id
user User @relation(fields: [userId], references: [id])
userId Int
distance Float
from String
@ -58,20 +63,19 @@ model TrainTrip {
infoJson Json
geometry String
moneyUpdate MoneyUpdate?
user User @relation(fields: [userId], references: [id])
}
model MoneyUpdate {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id])
userId Int
before Int
after Int
reason MoneyUpdateType
actionId Int? @unique
tripId String? @unique
action ChallengeAction? @relation(fields: [actionId], references: [id])
actionId Int? @unique
trip TrainTrip? @relation(fields: [tripId], references: [id])
user User @relation(fields: [userId], references: [id])
tripId String? @unique
}
enum MoneyUpdateType {

View File

@ -3,9 +3,10 @@ import { PrismaService } from './prisma/prisma.service'
import { PrismaModule } from './prisma/prisma.module'
import { UsersModule } from './users/users.module'
import { AuthModule } from './auth/auth.module'
import { GeolocationsModule } from './geolocations/geolocations.module'
@Module({
imports: [PrismaModule, UsersModule, AuthModule],
imports: [PrismaModule, UsersModule, AuthModule, GeolocationsModule],
providers: [PrismaService],
})
export class AppModule {}

View File

@ -1,5 +1,9 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
import { User } from '@prisma/client'
import { Request } from 'express'
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
export type AuthenticatedRequest = Request & { user: User }

View File

@ -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<T> {
@ApiProperty({ isArray: true })
data: T[]
@ApiProperty()
meta: MetaPaginateOutputDto
}

View File

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

View File

@ -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 = <T>(
data: T[],
total: number,
query: QueryPaginationDto,
): PaginateOutputDto<T> => {
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 extends Type<unknown>>(dataDto: DataDto, options?: ApiResponseNoStatusOptions) =>
applyDecorators(
ApiExtraModels(PaginateOutputDto, dataDto),
ApiOkResponse({
...options,
schema: {
allOf: [
{ $ref: getSchemaPath(PaginateOutputDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(dataDto) },
},
},
},
],
},
}),
)

View File

@ -0,0 +1,24 @@
import { ApiProperty } from "@nestjs/swagger"
export class CreateGeolocationDto {
@ApiProperty({description: "Longitude en degrés"})
longitude: number
@ApiProperty({description: "Latitude en degrés"})
latitude: number
@ApiProperty({description: "Vitesse en mètres par seconde"})
speed: number
@ApiProperty({description: "Précision en mètres de la position obtenue"})
accuracy: number
@ApiProperty({description: "Altitude en mètres"})
altitude: number
@ApiProperty({description: "Précision de l'altitude en mètres"})
altitudeAccuracy: number
@ApiProperty({description: "Date et heure de capture de la géolocalisation"})
timestamp: Date
}

View File

@ -0,0 +1,35 @@
import { ApiProperty } from "@nestjs/swagger"
import { Geolocation } from "@prisma/client"
export class GeolocationEntity implements Geolocation {
constructor(partial: Partial<GeolocationEntity>) {
Object.assign(this, partial)
}
@ApiProperty({description: "Identifiant unique"})
id: number
@ApiProperty({description: "Utilisateur⋅rice ayant émis la géolocalisation"})
userId: number
@ApiProperty({description: "Longitude en degrés"})
longitude: number
@ApiProperty({description: "Latitude en degrés"})
latitude: number
@ApiProperty({description: "Vitesse en mètres par seconde"})
speed: number
@ApiProperty({description: "Précision en mètres de la position obtenue"})
accuracy: number
@ApiProperty({description: "Altitude en mètres"})
altitude: number
@ApiProperty({description: "Précision de l'altitude en mètres"})
altitudeAccuracy: number
@ApiProperty({description: "Date et heure de capture de la géolocalisation"})
timestamp: Date
}

View File

@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing'
import { GeolocationsController } from './geolocations.controller'
import { GeolocationsService } from './geolocations.service'
describe('GeolocationsController', () => {
let controller: GeolocationsController
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [GeolocationsController],
providers: [GeolocationsService],
}).compile()
controller = module.get<GeolocationsController>(GeolocationsController)
})
it('should be defined', () => {
expect(controller).toBeDefined()
})
})

View File

@ -0,0 +1,66 @@
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 {
constructor(private readonly geolocationsService: GeolocationsService) {}
@Post()
@HttpCode(201)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: GeolocationEntity, description: "Objet créé avec succès" })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Object non trouvé" })
async create(@Req() request: AuthenticatedRequest, @Body() createGeolocationDto: CreateGeolocationDto): Promise<GeolocationEntity> {
const user = request.user
const geolocation = await this.geolocationsService.create(user, createGeolocationDto)
return new GeolocationEntity(geolocation)
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponsePaginated(GeolocationEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<GeolocationEntity>> {
const [geolocations, total] = await this.geolocationsService.findAll(queryPagination)
return paginateOutput<GeolocationEntity>(geolocations.map(geolocation => new GeolocationEntity(geolocation)), total, queryPagination)
}
@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: GeolocationEntity })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async findOne(@Param('id', ParseIntPipe) id: number): Promise<GeolocationEntity> {
const geolocation = await this.geolocationsService.findOne(+id)
if (!geolocation)
throw new NotFoundException(`Géolocalisation inexistante avec l'identifiant ${id}`)
return new GeolocationEntity(geolocation)
}
@Delete(':id')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiNoContentResponse({ description: "Objet supprimé avec succès" })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
@ApiNotFoundResponse({ description: "Objet non trouvé" })
async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.geolocationsService.remove(+id)
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { GeolocationsService } from './geolocations.service'
import { GeolocationsController } from './geolocations.controller'
import { PrismaModule } from 'src/prisma/prisma.module'
@Module({
controllers: [GeolocationsController],
providers: [GeolocationsService],
imports: [PrismaModule],
})
export class GeolocationsModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing'
import { GeolocationsService } from './geolocations.service'
describe('GeolocationsService', () => {
let service: GeolocationsService
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GeolocationsService],
}).compile()
service = module.get<GeolocationsService>(GeolocationsService)
})
it('should be defined', () => {
expect(service).toBeDefined()
})
})

View File

@ -0,0 +1,34 @@
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 {
constructor(private prisma: PrismaService) { }
async create(authenticatedUser: User, createGeolocationDto: CreateGeolocationDto): Promise<Geolocation> {
const data = { ...createGeolocationDto, userId: authenticatedUser.id }
return await this.prisma.geolocation.create({ data: data })
}
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<Geolocation> {
return await this.prisma.geolocation.findUnique({ where: { id } })
}
async remove(id: number): Promise<Geolocation> {
return await this.prisma.geolocation.delete({ where: { id } })
}
}

View File

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

View File

@ -1,12 +1,12 @@
import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Post, 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 { JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { User } from '@prisma/client'
import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { UpdatePasswordDto } from './dto/user_password.dto'
export type AuthenticatedRequest = Request & { user: User }
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 {
@ -15,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<PaginateOutputDto<UserEntity>> {
const [users, total] = await this.usersService.findAll(queryPagination)
return paginateOutput<UserEntity>(users.map(user => new UserEntity(user)), total, queryPagination)
}
@Get(':id')
@ -45,8 +45,8 @@ export class UsersController {
@ApiBadRequestResponse({description: "Erreur dans la saisie du nouveau mot de passe."})
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" })
async updatePassword(@Req() request: AuthenticatedRequest, @Body() { password }: UpdatePasswordDto) {
async updatePassword(@Req() request: AuthenticatedRequest, @Body() body: UpdatePasswordDto) {
const user = request.user
await this.usersService.updatePassword(user, password)
await this.usersService.updatePassword(user, body)
}
}

View File

@ -2,20 +2,26 @@ import { Injectable } from '@nestjs/common'
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<User> {
return await this.prisma.user.findUnique({ where: { id } })
}
async updatePassword(user: User, password: string) {
async updatePassword(user: User, { password }: UpdatePasswordDto): Promise<void> {
const hashedPassword = await bcrypt.hash(password, 10)
await this.prisma.user.update({
where: { id: user.id },