Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/v2/product-improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
**solução**: integrar api de clima

#### implementação
- ao calcular rota, buscar previsão de clima para região/horário
- na hora de calcular a rota, buscar previsão de clima para região/horário
- mostrar cards de condições:
- temperatura (18°C - 24°C)
- probabilidade de chuva (20%)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "back-velox",
"version": "0.0.1",
"version": "2.0.0",
"description": "",
"author": "",
"private": true,
Expand Down
1 change: 1 addition & 0 deletions src/clients/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './google-maps.client'
export * from './strava.client'
export * from './weather.client'
134 changes: 134 additions & 0 deletions src/clients/weather.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { z } from 'zod'

const weatherApiResponseSchema = z.object({
list: z.array(
z.object({
dt: z.number(),
main: z.object({
temp: z.number(),
feels_like: z.number(),
humidity: z.number(),
pressure: z.number(),
}),
weather: z.array(
z.object({
id: z.number(),
main: z.string(),
description: z.string(),
}),
),
clouds: z.object({
all: z.number(),
}),
wind: z.object({
speed: z.number(),
deg: z.number().optional(),
}),
pop: z.number().optional(),
visibility: z.number().optional(),
}),
),
city: z.object({
name: z.string(),
coord: z.object({
lat: z.number(),
lon: z.number(),
}),
}),
})

export interface WeatherForecast {
temperature: number
feelsLike: number
humidity: number
description: string
rainProbability: number
windSpeed: number
windDirection: number | undefined
cityName: string
}

export interface WeatherData {
current: WeatherForecast
forecasts: WeatherForecast[]
timestamp: Date
}

@Injectable()
export class WeatherClient {
constructor(protected readonly configService: ConfigService) {
this.openWeatherApiKey = this.configService.getOrThrow('OPENWEATHER_API_KEY')
this.openWeatherApiUrl = this.configService.getOrThrow('OPENWEATHER_API_URL')
}

protected openWeatherApiKey: string
protected openWeatherApiUrl: string

async getWeatherForecast(latitude: number, longitude: number): Promise<WeatherData> {
try {
const url = `${this.openWeatherApiUrl}/forecast?lat=${latitude}&lon=${longitude}&units=metric&lang=pt_br&appid=${this.openWeatherApiKey}`

const response = await fetch(url)
const data = await response.json()
const parsedData = weatherApiResponseSchema.parse(data)

if (!parsedData.list.length) {
throw new HttpException('Weather data unavailable', HttpStatus.BAD_GATEWAY)
}

const firstItem = parsedData.list[0]
if (!firstItem) {
throw new HttpException('Weather data unavailable', HttpStatus.BAD_GATEWAY)
}

const current = this.mapWeatherData(firstItem, parsedData.city.name)
const forecasts = parsedData.list
.slice(1, 5)
.map(item => this.mapWeatherData(item, parsedData.city.name))

return {
current,
forecasts,
timestamp: new Date(),
}
} catch (error) {
throw new HttpException(
`Error fetching weather data: ${error instanceof Error ? error.message : 'unknown error'}`,
HttpStatus.BAD_GATEWAY,
)
}
}

private mapWeatherData(
item: z.infer<typeof weatherApiResponseSchema>['list'][0],
cityName: string,
): WeatherForecast {
const weather = item.weather[0]

if (!weather) {
return {
temperature: Math.round(item.main.temp),
feelsLike: Math.round(item.main.feels_like),
humidity: item.main.humidity,
description: 'indefinido',
rainProbability: Math.round((item.pop ?? 0) * 100),
windSpeed: Math.round(item.wind.speed * 3.6),
windDirection: item.wind.deg,
cityName,
}
}

return {
temperature: Math.round(item.main.temp),
feelsLike: Math.round(item.main.feels_like),
humidity: item.main.humidity,
description: weather.description,
rainProbability: Math.round((item.pop ?? 0) * 100),
windSpeed: Math.round(item.wind.speed * 3.6),
windDirection: item.wind.deg,
cityName,
}
}
}
2 changes: 2 additions & 0 deletions src/core/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import { ConfigModule, ConfigService } from '@nestjs/config'
import { TypeOrmModule } from '@nestjs/typeorm'
import { TrafficAlertEntity, TrafficHistoryEntity } from '@traffic/entities'
import { TrafficModule } from '@traffic/traffic.module'
import { WeatherModule } from '@weather/weather.module'
@Module({
imports: [
AthleteModule,
TrafficModule,
WeatherModule,
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
Expand Down
5 changes: 1 addition & 4 deletions src/traffic/traffic.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import { TrafficData, TrafficSeverity } from './interfaces'

@Injectable()
export class TrafficService {
constructor(
private readonly googleMapsClient: GoogleMapsClient,
) {}

constructor(private readonly googleMapsClient: GoogleMapsClient) {}

async getTrafficForPlannedRoute(
polyline: string,
Expand Down
6 changes: 6 additions & 0 deletions src/weather/dto/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
GetWeatherForecastInputDto,
GetWeatherForecastOutputDto,
RouteWeatherDataDto,
WeatherAlertDto,
} from './weather.dto'
33 changes: 33 additions & 0 deletions src/weather/dto/weather.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { WeatherCondition } from '@weather/interfaces'
import { createZodDto } from 'nestjs-zod'
import { z } from 'zod'

const getWeatherForecastInputDto = z.object({
latitude: z.number(),
longitude: z.number(),
})

const weatherAlertDto = z.object({
type: z.enum(['high_rain', 'extreme_temp', 'strong_wind']),
severity: z.enum(['low', 'medium', 'high']),
message: z.string(),
time: z.date().optional(),
})

const routeWeatherDataDto = z.object({
condition: z.nativeEnum(WeatherCondition),
temperature: z.number(),
rainProbability: z.number(),
windSpeed: z.number(),
alerts: z.array(weatherAlertDto),
timestamp: z.date(),
})

const getWeatherForecastOutputDto = z.object({
weather: routeWeatherDataDto,
})

export class GetWeatherForecastInputDto extends createZodDto(getWeatherForecastInputDto) {}
export class GetWeatherForecastOutputDto extends createZodDto(getWeatherForecastOutputDto) {}
export class RouteWeatherDataDto extends createZodDto(routeWeatherDataDto) {}
export class WeatherAlertDto extends createZodDto(weatherAlertDto) {}
1 change: 1 addition & 0 deletions src/weather/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WeatherCondition, WeatherAlert, RouteWeatherData } from './weather.interface'
23 changes: 23 additions & 0 deletions src/weather/interfaces/weather.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export enum WeatherCondition {
SUNNY = 'sunny',
CLOUDY = 'cloudy',
RAINY = 'rainy',
STORMY = 'stormy',
SNOWY = 'snowy',
}

export interface WeatherAlert {
type: 'high_rain' | 'extreme_temp' | 'strong_wind'
severity: 'low' | 'medium' | 'high'
message: string
time?: Date
}

export interface RouteWeatherData {
condition: WeatherCondition
temperature: number
rainProbability: number
windSpeed: number
alerts: WeatherAlert[]
timestamp: Date
}
32 changes: 32 additions & 0 deletions src/weather/weather.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'
import { GetWeatherForecastInputDto, GetWeatherForecastOutputDto } from '@weather/dto'
import { WeatherService } from '@weather/weather.service'

import { JwtAuthGuard } from '@auth'

@ApiTags('Weather')
@Controller('athlete/:athleteId/weather')
export class WeatherController {
constructor(private readonly weatherService: WeatherService) {}

@Post('/forecast')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get weather forecast for coordinates' })
@ApiResponse({
status: 200,
description: 'Weather data retrieved successfully',
type: GetWeatherForecastOutputDto,
})
async getWeatherForecast(
@Param('athleteId') athleteId: string,
@Body() data: GetWeatherForecastInputDto,
): Promise<GetWeatherForecastOutputDto> {
const weather = await this.weatherService.getWeatherForCoordinates(data.latitude, data.longitude)

return {
weather,
}
}
}
12 changes: 12 additions & 0 deletions src/weather/weather.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common'
import { WeatherController } from '@weather/weather.controller'
import { WeatherService } from '@weather/weather.service'

import { WeatherClient } from '@clients'

@Module({
controllers: [WeatherController],
providers: [WeatherService, WeatherClient],
exports: [WeatherService],
})
export class WeatherModule {}
70 changes: 70 additions & 0 deletions src/weather/weather.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common'
import { RouteWeatherData, WeatherCondition, WeatherAlert } from '@weather/interfaces'

import { WeatherClient, WeatherForecast } from '@clients'

@Injectable()
export class WeatherService {
constructor(private readonly weatherClient: WeatherClient) {}

async getWeatherForCoordinates(latitude: number, longitude: number): Promise<RouteWeatherData> {
const weatherData = await this.weatherClient.getWeatherForecast(latitude, longitude)
const forecast = weatherData.current

const condition = this.mapWeatherCondition(forecast.description)
const alerts = this.generateWeatherAlerts(forecast)

return {
condition,
temperature: forecast.temperature,
rainProbability: forecast.rainProbability,
windSpeed: forecast.windSpeed,
alerts,
timestamp: weatherData.timestamp,
}
}

private mapWeatherCondition(description: string): WeatherCondition {
const desc = description.toLowerCase()

if (desc.includes('claro') || desc.includes('ensolarado')) return WeatherCondition.SUNNY
if (desc.includes('nuvem') || desc.includes('encoberto')) return WeatherCondition.CLOUDY
if (desc.includes('chuva') || desc.includes('chuvisco')) return WeatherCondition.RAINY
if (desc.includes('tempestade') || desc.includes('trovejante')) return WeatherCondition.STORMY
if (desc.includes('neve')) return WeatherCondition.SNOWY

return WeatherCondition.CLOUDY
}

private generateWeatherAlerts(forecast: WeatherForecast): WeatherAlert[] {
const alerts: WeatherAlert[] = []

if (forecast.rainProbability > 70) {
alerts.push({
type: 'high_rain',
severity: forecast.rainProbability > 90 ? 'high' : 'medium',
message: `alta chance de chuva (${forecast.rainProbability}%), considere sair mais cedo`,
})
}

if (forecast.temperature < 10 || forecast.temperature > 30) {
const severity = forecast.temperature < 5 || forecast.temperature > 35 ? 'high' : 'medium'
const tempType = forecast.temperature < 10 ? 'frio' : 'calor'
alerts.push({
type: 'extreme_temp',
severity,
message: `${tempType} extremo previsto (${forecast.temperature}°c)`,
})
}

if (forecast.windSpeed > 25) {
alerts.push({
type: 'strong_wind',
severity: forecast.windSpeed > 40 ? 'high' : 'medium',
message: `vento forte (${forecast.windSpeed}km/h), cuidado em descidas`,
})
}

return alerts
}
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"@core/*": ["src/core/*"],

"@athlete/*": ["src/athlete/*"],
"@traffic/*": ["src/traffic/*"]
"@traffic/*": ["src/traffic/*"],
"@weather/*": ["src/weather/*"]
},
"esModuleInterop": true,
"skipLibCheck": true,
Expand Down