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", "@nestjs/swagger": "^8.1.0",
"@prisma/client": "^6.0.1", "@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
@ -3674,12 +3675,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": { "node_modules/class-transformer": {
"version": "0.4.0", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT", "license": "MIT"
"optional": true,
"peer": true
}, },
"node_modules/class-validator": { "node_modules/class-validator": {
"version": "0.14.1", "version": "0.14.1",

View File

@ -28,6 +28,7 @@
"@nestjs/swagger": "^8.1.0", "@nestjs/swagger": "^8.1.0",
"@prisma/client": "^6.0.1", "@prisma/client": "^6.0.1",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "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

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

View File

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

View File

@ -1,5 +1,9 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport' import { AuthGuard } from '@nestjs/passport'
import { User } from '@prisma/client'
import { Request } from 'express'
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {} 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() { async function bootstrap() {
const app = await NestFactory.create(AppModule) const app = await NestFactory.create(AppModule)
app.useGlobalPipes(new ValidationPipe()) app.useGlobalPipes(new ValidationPipe({ transform: true }))
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))) app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
const config = new DocumentBuilder() 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 { UsersService } from './users.service'
import { ApiBadRequestResponse, ApiBearerAuth, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger' import { ApiBadRequestResponse, ApiBearerAuth, ApiForbiddenResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { UserEntity } from './entities/user.entity' import { UserEntity } from './entities/user.entity'
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard'
import { User } from '@prisma/client'
import { UpdatePasswordDto } from './dto/user_password.dto' import { UpdatePasswordDto } from './dto/user_password.dto'
import { QueryPaginationDto } from 'src/common/dto/pagination-query.dto'
export type AuthenticatedRequest = Request & { user: User } import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils'
import { PaginateOutputDto } from 'src/common/dto/pagination-output.dto'
@Controller('users') @Controller('users')
export class UsersController { export class UsersController {
@ -15,12 +15,12 @@ export class UsersController {
@Get() @Get()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOkResponse({ type: UserEntity, isArray: true }) @ApiOkResponsePaginated(UserEntity)
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" }) @ApiForbiddenResponse({ description: "Permission refusée" })
async findAll() { async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<UserEntity>> {
const users = await this.usersService.findAll() const [users, total] = await this.usersService.findAll(queryPagination)
return users.map(user => new UserEntity(user)) return paginateOutput<UserEntity>(users.map(user => new UserEntity(user)), total, queryPagination)
} }
@Get(':id') @Get(':id')
@ -45,8 +45,8 @@ export class UsersController {
@ApiBadRequestResponse({description: "Erreur dans la saisie du nouveau mot de passe."}) @ApiBadRequestResponse({description: "Erreur dans la saisie du nouveau mot de passe."})
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusé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 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 { User } from '@prisma/client'
import { PrismaService } from 'src/prisma/prisma.service' import { PrismaService } from 'src/prisma/prisma.service'
import * as bcrypt from 'bcrypt' 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() @Injectable()
export class UsersService { export class UsersService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async findAll() { async findAll(queryPagination?: QueryPaginationDto): Promise<[User[], number]> {
return await this.prisma.user.findMany() 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 } }) 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) const hashedPassword = await bcrypt.hash(password, 10)
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: user.id }, where: { id: user.id },