Compare commits

..

No commits in common. "138ff1df6549a5b83f5f126db5e0f9a7d01aec5d" and "1ae6b6634c0d38b54e24205b340f6f01dc2ccc61" have entirely different histories.

19 changed files with 35 additions and 417 deletions

View File

@ -17,7 +17,6 @@
"@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",
@ -3675,10 +3674,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/class-transformer": { "node_modules/class-transformer": {
"version": "0.5.1", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==",
"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,7 +28,6 @@
"@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

@ -1,14 +0,0 @@
/*
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,15 +21,11 @@ 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 {
@ -42,18 +38,17 @@ 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
@ -63,19 +58,20 @@ 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
action ChallengeAction? @relation(fields: [actionId], references: [id])
actionId Int? @unique actionId Int? @unique
trip TrainTrip? @relation(fields: [tripId], references: [id])
tripId String? @unique tripId String? @unique
action ChallengeAction? @relation(fields: [actionId], references: [id])
trip TrainTrip? @relation(fields: [tripId], references: [id])
user User @relation(fields: [userId], references: [id])
} }
enum MoneyUpdateType { enum MoneyUpdateType {

View File

@ -3,10 +3,9 @@ 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, GeolocationsModule], imports: [PrismaModule, UsersModule, AuthModule],
providers: [PrismaService], providers: [PrismaService],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,9 +1,5 @@
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

@ -1,42 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,24 +0,0 @@
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

@ -1,35 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,11 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -1,34 +0,0 @@
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({ transform: true })) app.useGlobalPipes(new ValidationPipe())
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, Query, Req, UseGuards } from '@nestjs/common' import { Body, Controller, Get, HttpCode, NotFoundException, Param, ParseIntPipe, Patch, Post, 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 { AuthenticatedRequest, JwtAuthGuard } from 'src/auth/jwt-auth.guard' import { 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'
import { ApiOkResponsePaginated, paginateOutput } from 'src/common/utils/pagination.utils' export type AuthenticatedRequest = Request & { user: User }
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()
@ApiOkResponsePaginated(UserEntity) @ApiOkResponse({ type: UserEntity, isArray: true })
@ApiUnauthorizedResponse({ description: "Non authentifié⋅e" }) @ApiUnauthorizedResponse({ description: "Non authentifié⋅e" })
@ApiForbiddenResponse({ description: "Permission refusée" }) @ApiForbiddenResponse({ description: "Permission refusée" })
async findAll(@Query() queryPagination?: QueryPaginationDto): Promise<PaginateOutputDto<UserEntity>> { async findAll() {
const [users, total] = await this.usersService.findAll(queryPagination) const users = await this.usersService.findAll()
return paginateOutput<UserEntity>(users.map(user => new UserEntity(user)), total, queryPagination) return users.map(user => new UserEntity(user))
} }
@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() body: UpdatePasswordDto) { async updatePassword(@Req() request: AuthenticatedRequest, @Body() { password }: UpdatePasswordDto) {
const user = request.user const user = request.user
await this.usersService.updatePassword(user, body) await this.usersService.updatePassword(user, password)
} }
} }

View File

@ -2,26 +2,20 @@ 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(queryPagination?: QueryPaginationDto): Promise<[User[], number]> { async findAll() {
return [ return await this.prisma.user.findMany()
await this.prisma.user.findMany({ ...paginate(queryPagination) }),
await this.prisma.user.count()
]
} }
async findOne(id: number): Promise<User> { async findOne(id: number) {
return await this.prisma.user.findUnique({ where: { id } }) return await this.prisma.user.findUnique({ where: { id } })
} }
async updatePassword(user: User, { password }: UpdatePasswordDto): Promise<void> { async updatePassword(user: User, password: string) {
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 },