mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-16 19:56:32 +02:00
- Serveur Rust (server-rs/, axum + sqlx) : les 148 adresses de l'API, l'interface, les migrations de démarrage et les tâches de fond ; même comportement que Node (banc : 2027 cas, 0 écart hors écarts déclarés USERS-02, RAPPRO-21, R4, R5). - Node retiré : server/, dépendances serveur, image Docker sans Node (51 Mo), docker-compose à un seul service applicatif. - R4 : écritures d'un autre site refusées (Sec-Fetch-Site / Origin). - R5 : « aujourd'hui » et sauvegarde automatique à l'heure de Paris. - R6 : mainteneur dans .github/CODEOWNERS. - Menu latéral : Accueil puis Calendrier en tête. - Plan et état : docs/PLAN-MIGRATION-RUST.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ke2qyS8rgacP3ap5iezDbM
874 lines
32 KiB
Rust
874 lines
32 KiB
Rust
// Météo : /api/weather/* (server/routes.ts, « Weather routes » et route de
|
|
// géolocalisation ; server/weatherService.ts ; storage.getWeatherSettings,
|
|
// getWeatherData, getNearestWeatherData, createWeatherData, clearWeatherCache).
|
|
//
|
|
// Les adresses de Visual Crossing et de Nominatim sont écrites dans le code,
|
|
// comme dans Node. Écart voulu (critère du lot 2, DASH-35) : chaque appel a un
|
|
// délai maximal (connexion 10 s, comme fetch dans Node, et 30 s en tout), là où
|
|
// Node attend jusqu'à 300 s une réponse lente.
|
|
// Comme Node aujourd'hui : tout utilisateur connecté peut changer la ville par
|
|
// géolocalisation (décision R3 du plan, « Ce qu'on ne reporte pas »).
|
|
|
|
use std::sync::LazyLock;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::{Path, State};
|
|
use axum::response::IntoResponse;
|
|
use axum::{Json, Router, routing::get, routing::post, routing::put};
|
|
use chrono::{Datelike, Duration as Days, NaiveDate};
|
|
use serde_json::{Map, Value, json};
|
|
use sqlx::postgres::PgArguments;
|
|
use sqlx::{Arguments, PgPool};
|
|
|
|
use crate::AppState;
|
|
use crate::auth::CurrentUser;
|
|
use crate::db::{self, Table, node_pg_text};
|
|
use crate::error::{ApiResult, AppError, OrInternal, json_or_empty};
|
|
use crate::js::{self, string_of, truthy};
|
|
use crate::request::{JsonBody, int_id};
|
|
use crate::schema::{WeatherDataRow, WeatherSettingsRow};
|
|
use crate::zod::Object;
|
|
|
|
const VISUAL_CROSSING_URL: &str =
|
|
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline";
|
|
const NOMINATIM_URL: &str = "https://nominatim.openstreetmap.org/reverse";
|
|
const NOMINATIM_USER_AGENT: &str = "LogiFlow-Weather-System/1.0 (Enterprise Weather Management Platform)";
|
|
|
|
/// Client des services météo : sans mandataire (fetch de Node n'en utilise pas),
|
|
/// avec les autorités de NODE_EXTRA_CA_CERTS que Node ajoute aux siennes
|
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
let mut builder = reqwest::Client::builder()
|
|
.no_proxy()
|
|
.connect_timeout(Duration::from_secs(10))
|
|
.timeout(Duration::from_secs(30))
|
|
.user_agent("node");
|
|
for certificate in extra_certificates() {
|
|
builder = builder.add_root_certificate(certificate);
|
|
}
|
|
builder.build().expect("client HTTP de la météo")
|
|
});
|
|
|
|
/// Certificats de NODE_EXTRA_CA_CERTS ; fichier illisible : avertissement, comme Node
|
|
fn extra_certificates() -> Vec<reqwest::Certificate> {
|
|
let Some(path) = std::env::var_os("NODE_EXTRA_CA_CERTS").filter(|path| !path.is_empty()) else {
|
|
return Vec::new();
|
|
};
|
|
std::fs::read(&path)
|
|
.map_err(|error| error.to_string())
|
|
.and_then(|pem| reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| error.to_string()))
|
|
.unwrap_or_else(|error| {
|
|
tracing::warn!(?path, error, "NODE_EXTRA_CA_CERTS ignoré");
|
|
Vec::new()
|
|
})
|
|
}
|
|
|
|
pub fn routes() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/api/weather/settings", get(get_settings).post(create_settings))
|
|
.route("/api/weather/settings/{id}", put(update_settings))
|
|
.route("/api/weather/test-connection", post(test_connection))
|
|
.route("/api/weather/current", get(current))
|
|
.route("/api/weather/geolocation", post(geolocation))
|
|
}
|
|
|
|
/// insertWeatherSettingsSchema
|
|
fn settings_schema() -> Object {
|
|
WeatherSettingsRow::insert_schema().omit(&["id", "createdAt", "updatedAt"])
|
|
}
|
|
|
|
fn admin_only(user: &CurrentUser) -> Result<(), AppError> {
|
|
if user.is_admin() {
|
|
Ok(())
|
|
} else {
|
|
Err(AppError::forbidden("Access denied"))
|
|
}
|
|
}
|
|
|
|
/// storage.getWeatherSettings : première ligne, sans tri
|
|
pub(crate) async fn weather_settings(db: &PgPool) -> Result<Option<WeatherSettingsRow>, sqlx::Error> {
|
|
sqlx::query_as(&WeatherSettingsRow::select("LIMIT 1"))
|
|
.persistent(false)
|
|
.fetch_optional(db)
|
|
.await
|
|
}
|
|
|
|
async fn get_settings(State(state): State<AppState>, user: CurrentUser) -> ApiResult {
|
|
admin_only(&user)?;
|
|
let settings = weather_settings(&state.db)
|
|
.await
|
|
.or_500("Failed to fetch weather settings")?;
|
|
Ok(json_or_empty(settings))
|
|
}
|
|
|
|
async fn create_settings(
|
|
State(state): State<AppState>,
|
|
user: CurrentUser,
|
|
JsonBody(body): JsonBody,
|
|
) -> ApiResult {
|
|
admin_only(&user)?;
|
|
let data = settings_schema()
|
|
.parse(&body)
|
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
|
let settings: WeatherSettingsRow = db::insert(&state.db, &data)
|
|
.await
|
|
.or_500("Failed to create weather settings")?;
|
|
Ok(Json(settings).into_response())
|
|
}
|
|
|
|
async fn update_settings(
|
|
State(state): State<AppState>,
|
|
user: CurrentUser,
|
|
Path(id): Path<String>,
|
|
JsonBody(body): JsonBody,
|
|
) -> ApiResult {
|
|
const FAILED: &str = "Failed to update weather settings";
|
|
admin_only(&user)?;
|
|
// Validation avant la base : un identifiant illisible n'échoue qu'à la requête
|
|
let data = settings_schema()
|
|
.partial()
|
|
.parse(&body)
|
|
.map_err(|issues| AppError::validation("Validation error", issues))?;
|
|
let id = int_id(&id, FAILED)?;
|
|
let settings = db::update_by_id::<WeatherSettingsRow>(&state.db, id, &data, true)
|
|
.await
|
|
.or_500(FAILED)?;
|
|
Ok(json_or_empty(settings))
|
|
}
|
|
|
|
async fn test_connection(user: CurrentUser, JsonBody(body): JsonBody) -> ApiResult {
|
|
admin_only(&user)?;
|
|
let given = |key: &str| body.get(key).filter(|value| truthy(value));
|
|
let (Some(api_key), Some(location)) = (given("apiKey"), given("location")) else {
|
|
return Err(AppError::bad_request("API key and location are required"));
|
|
};
|
|
let (success, message) = test_api_connection(&string_of(api_key), &string_of(location)).await;
|
|
Ok(Json(json!({ "success": success, "message": message })).into_response())
|
|
}
|
|
|
|
// ---------- Visual Crossing et Nominatim (server/weatherService.ts) ----------
|
|
|
|
/// encodeURIComponent
|
|
fn encode_uri_component(text: &str) -> String {
|
|
let mut encoded = String::with_capacity(text.len());
|
|
for byte in text.bytes() {
|
|
if byte.is_ascii_alphanumeric() || b"-_.!~*'()".contains(&byte) {
|
|
encoded.push(byte as char);
|
|
} else {
|
|
encoded.push_str(&format!("%{byte:02X}"));
|
|
}
|
|
}
|
|
encoded
|
|
}
|
|
|
|
/// Adresse de la chronologie Visual Crossing ; la clé est collée telle quelle, comme Node
|
|
fn timeline_url(location: &str, date: Option<&str>, api_key: &str, french: bool) -> String {
|
|
let date = date.map(|date| format!("/{date}")).unwrap_or_default();
|
|
let lang = if french { "&lang=fr" } else { "" };
|
|
format!(
|
|
"{VISUAL_CROSSING_URL}/{}{date}?unitGroup=metric&include=days&key={api_key}&contentType=json{lang}",
|
|
encode_uri_component(location)
|
|
)
|
|
}
|
|
|
|
/// Texte du statut (statusText de fetch)
|
|
fn status_text(response: &reqwest::Response) -> String {
|
|
response
|
|
.extensions()
|
|
.get::<hyper::ext::ReasonPhrase>()
|
|
.map(|reason| String::from_utf8_lossy(reason.as_bytes()).into_owned())
|
|
.or_else(|| response.status().canonical_reason().map(str::to_string))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// fetchCurrentWeather / fetchPreviousYearWeather : réponse JSON, None en cas d'échec
|
|
async fn fetch_timeline(url: &str) -> Option<Value> {
|
|
let response = match CLIENT.get(url).send().await {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
tracing::error!(%error, "Erreur lors de la récupération de la météo");
|
|
return None;
|
|
}
|
|
};
|
|
if !response.status().is_success() {
|
|
let status = response.status().as_u16();
|
|
let status_text = status_text(&response);
|
|
let reason: String = response
|
|
.text()
|
|
.await
|
|
.unwrap_or_default()
|
|
.chars()
|
|
.take(300)
|
|
.collect();
|
|
tracing::error!(status, status_text, reason, "Weather API error");
|
|
return None;
|
|
}
|
|
response
|
|
.json::<Value>()
|
|
.await
|
|
.map_err(|error| tracing::error!(%error, "Réponse météo illisible"))
|
|
.ok()
|
|
}
|
|
|
|
/// testApiConnection : { success, message }
|
|
pub(crate) async fn test_api_connection(api_key: &str, location: &str) -> (bool, String) {
|
|
const UNREACHABLE: &str = "Erreur de connexion à l'API météo";
|
|
let url = timeline_url(location, None, api_key, false);
|
|
let response = match CLIENT.get(url).send().await {
|
|
Ok(response) => response,
|
|
Err(error) => {
|
|
tracing::error!(%error, "Erreur test API météo");
|
|
return (false, UNREACHABLE.into());
|
|
}
|
|
};
|
|
let status = response.status();
|
|
if status.is_success() {
|
|
return match response.json::<Value>().await {
|
|
// data.days && data.days.length > 0 (null.days : TypeError)
|
|
Ok(Value::Null) | Err(_) => (false, UNREACHABLE.into()),
|
|
Ok(data) => {
|
|
let days = data.get("days").filter(|days| truthy(days));
|
|
if days.is_some_and(has_items) {
|
|
(true, "Connexion réussie à l'API météo".into())
|
|
} else {
|
|
(
|
|
false,
|
|
"Aucune donnée météo trouvée pour cette localisation".into(),
|
|
)
|
|
}
|
|
}
|
|
};
|
|
}
|
|
match status.as_u16() {
|
|
401 => (false, "Clé API invalide".into()),
|
|
404 => (false, "Localisation introuvable".into()),
|
|
code => (false, format!("Erreur API: {code} {}", status_text(&response))),
|
|
}
|
|
}
|
|
|
|
/// valeur.length > 0
|
|
fn has_items(value: &Value) -> bool {
|
|
let length = match value {
|
|
Value::Array(items) => json!(items.len()),
|
|
Value::String(text) => json!(js::length(text)),
|
|
Value::Object(fields) => match fields.get("length") {
|
|
Some(length) => length.clone(),
|
|
None => return false,
|
|
},
|
|
_ => return false,
|
|
};
|
|
js::to_number(&length) > 0.0
|
|
}
|
|
|
|
/// getCityFromCoordinates : ville, pays et « ville, pays » (Nominatim)
|
|
async fn city_from_coordinates(latitude: f64, longitude: f64) -> Option<(Value, Value, String)> {
|
|
let url = format!(
|
|
"{NOMINATIM_URL}?format=json&lat={}&lon={}&accept-language=fr",
|
|
js::number_string(latitude),
|
|
js::number_string(longitude)
|
|
);
|
|
let response = CLIENT
|
|
.get(url)
|
|
.header(reqwest::header::USER_AGENT, NOMINATIM_USER_AGENT)
|
|
.send()
|
|
.await
|
|
.map_err(|error| tracing::error!(%error, "Erreur lors de la géolocalisation"))
|
|
.ok()?;
|
|
if !response.status().is_success() {
|
|
tracing::error!(status = response.status().as_u16(), "Nominatim API error");
|
|
return None;
|
|
}
|
|
let data = response.json::<Value>().await.ok()?;
|
|
let address = data.get("address").filter(|address| truthy(address))?;
|
|
let pick = |keys: &[&str], fallback: &str| {
|
|
keys.iter()
|
|
.filter_map(|key| address.get(*key))
|
|
.find(|value| truthy(value))
|
|
.cloned()
|
|
.unwrap_or_else(|| json!(fallback))
|
|
};
|
|
let city = pick(
|
|
&["city", "town", "village", "municipality", "hamlet"],
|
|
"Ville inconnue",
|
|
);
|
|
let country = pick(&["country"], "Pays inconnu");
|
|
let full_location = format!("{}, {}", string_of(&city), string_of(&country));
|
|
Some((city, country, full_location))
|
|
}
|
|
|
|
/// translateConditionToFrench : premier mot-clé anglais reconnu, sinon tel quel
|
|
fn translate_condition(condition: &str) -> String {
|
|
let lower = condition.to_lowercase();
|
|
let has = |word: &str| lower.contains(word);
|
|
let french = if has("clear") {
|
|
"Dégagé"
|
|
} else if has("sunny") {
|
|
"Ensoleillé"
|
|
} else if has("fair") {
|
|
"Beau temps"
|
|
} else if has("partly cloudy") {
|
|
"Partiellement nuageux"
|
|
} else if has("cloudy") {
|
|
"Nuageux"
|
|
} else if has("overcast") {
|
|
"Couvert"
|
|
} else if has("rain") {
|
|
if has("heavy") {
|
|
"Pluie forte"
|
|
} else if has("light") {
|
|
"Pluie légère"
|
|
} else {
|
|
"Pluie"
|
|
}
|
|
} else if has("shower") {
|
|
"Averses"
|
|
} else if has("drizzle") {
|
|
"Bruine"
|
|
} else if has("thunderstorm") || has("thunder") {
|
|
"Orage"
|
|
} else if has("snow") {
|
|
if has("heavy") {
|
|
"Neige forte"
|
|
} else if has("light") {
|
|
"Neige légère"
|
|
} else {
|
|
"Neige"
|
|
}
|
|
} else if has("fog") {
|
|
"Brouillard"
|
|
} else if has("mist") {
|
|
"Brume"
|
|
} else if has("wind") {
|
|
"Venteux"
|
|
} else {
|
|
return condition.to_string();
|
|
};
|
|
french.to_string()
|
|
}
|
|
|
|
/// Méthodes héritées d'Object.prototype : iconMap[nom] est une fonction,
|
|
/// que JSON.stringify omet
|
|
const OBJECT_PROTOTYPE_METHODS: [&str; 11] = [
|
|
"constructor",
|
|
"__defineGetter__",
|
|
"__defineSetter__",
|
|
"hasOwnProperty",
|
|
"__lookupGetter__",
|
|
"__lookupSetter__",
|
|
"isPrototypeOf",
|
|
"propertyIsEnumerable",
|
|
"toString",
|
|
"valueOf",
|
|
"toLocaleString",
|
|
];
|
|
|
|
/// getWeatherIcon : émoji de l'icône Visual Crossing (None : clé omise du JSON)
|
|
fn weather_icon(icon: Option<&Value>) -> Option<Value> {
|
|
let key = icon.map(string_of).unwrap_or_else(|| "undefined".into());
|
|
let emoji = match key.as_str() {
|
|
"clear-day" => "☀️",
|
|
"clear-night" => "🌙",
|
|
"partly-cloudy-day" => "⛅",
|
|
"partly-cloudy-night" | "cloudy" => "☁️",
|
|
"rain" => "🌧️",
|
|
"snow" => "❄️",
|
|
"wind" => "💨",
|
|
"fog" => "🌫️",
|
|
"thunder" => "⛈️",
|
|
"hail" => "🌨️",
|
|
"__proto__" => return Some(json!({})),
|
|
method if OBJECT_PROTOTYPE_METHODS.contains(&method) => return None,
|
|
_ => "🌤️",
|
|
};
|
|
Some(json!(emoji))
|
|
}
|
|
|
|
/// getPreviousYearDate : même jour de la semaine, même semaine du mois, l'an
|
|
/// dernier (calculé sur la date du jour à Paris)
|
|
fn previous_year_date(date: NaiveDate) -> NaiveDate {
|
|
let (year, month) = (date.year() - 1, date.month());
|
|
let weekday = date.weekday().num_days_from_sunday() as i64;
|
|
let first_of_month = NaiveDate::from_ymd_opt(year, month, 1).expect("premier du mois");
|
|
let first_weekday = first_of_month.weekday().num_days_from_sunday() as i64;
|
|
let days_to_add = (weekday - first_weekday + 7) % 7;
|
|
let week_of_month = i64::from(date.day().div_ceil(7));
|
|
// new Date(année, mois, jour) déborde sur le mois suivant si besoin
|
|
let target = first_of_month + Days::days(days_to_add + (week_of_month - 1) * 7);
|
|
if target.month() != month {
|
|
target - Days::days(7)
|
|
} else {
|
|
target
|
|
}
|
|
}
|
|
|
|
/// Météo d'un jour : ligne en cache, ou réponse de l'API (InsertWeatherData)
|
|
/// quand elle n'a pas pu être enregistrée ; None = valeur undefined (clé omise)
|
|
type WeatherRecord = Vec<(String, Option<Value>)>;
|
|
|
|
fn record_of(row: WeatherDataRow) -> WeatherRecord {
|
|
match serde_json::to_value(row) {
|
|
Ok(Value::Object(fields)) => fields
|
|
.into_iter()
|
|
.map(|(key, value)| (key, Some(value)))
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// TypeError de convertApiDataToWeatherData sur une réponse inattendue
|
|
#[derive(Debug, PartialEq)]
|
|
struct TypeError;
|
|
|
|
/// valeur[clé] en JavaScript (undefined sur une valeur qui n'est pas un objet)
|
|
fn property<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
|
value.as_object().and_then(|fields| fields.get(key))
|
|
}
|
|
|
|
/// convertApiDataToWeatherData : premier jour de la réponse, conditions traduites
|
|
fn convert_api_data(
|
|
api: &Value,
|
|
location: &str,
|
|
is_current_year: bool,
|
|
) -> Result<Option<WeatherRecord>, TypeError> {
|
|
let Some(days) = property(api, "days").filter(|days| truthy(days)) else {
|
|
return Ok(None);
|
|
};
|
|
// days.length === 0 ; puis days[0]
|
|
let today = match days {
|
|
Value::Array(items) if items.is_empty() => return Ok(None),
|
|
Value::Array(items) => items.first(),
|
|
Value::Object(fields) => {
|
|
if fields.get("length").and_then(Value::as_f64) == Some(0.0) {
|
|
return Ok(None);
|
|
}
|
|
fields.get("0")
|
|
}
|
|
// Chaîne : days[0] est un caractère, sans propriété tempmax
|
|
_ => None,
|
|
};
|
|
let Some(today @ Value::Object(_)) = today else {
|
|
return Err(TypeError);
|
|
};
|
|
let text = |key: &str| match property(today, key) {
|
|
None | Some(Value::Null) => Err(TypeError),
|
|
Some(value) => Ok(string_of(value)),
|
|
};
|
|
let temp_max = text("tempmax")?;
|
|
let temp_min = text("tempmin")?;
|
|
let Some(Value::String(conditions)) = property(today, "conditions") else {
|
|
return Err(TypeError);
|
|
};
|
|
Ok(Some(vec![
|
|
("date".into(), property(today, "datetime").cloned()),
|
|
("location".into(), Some(json!(location))),
|
|
("tempMax".into(), Some(json!(temp_max))),
|
|
("tempMin".into(), Some(json!(temp_min))),
|
|
("icon".into(), property(today, "icon").cloned()),
|
|
("conditions".into(), Some(json!(translate_condition(conditions)))),
|
|
("isCurrentYear".into(), Some(json!(is_current_year))),
|
|
]))
|
|
}
|
|
|
|
/// Forme renvoyée : la météo du jour, l'icône en émoji, les températures en nombres
|
|
fn displayed(record: &WeatherRecord) -> Value {
|
|
let field = |key: &str| {
|
|
record
|
|
.iter()
|
|
.find(|(name, _)| name == key)
|
|
.and_then(|(_, value)| value.as_ref())
|
|
};
|
|
let temperature = |key: &str| {
|
|
let text = field(key).map(string_of).unwrap_or_else(|| "undefined".into());
|
|
js::number_value(js::parse_float(&text))
|
|
};
|
|
let mut out = Map::new();
|
|
for (key, value) in record {
|
|
let value = if key == "icon" {
|
|
weather_icon(value.as_ref())
|
|
} else {
|
|
value.clone()
|
|
};
|
|
if let Some(value) = value {
|
|
out.insert(key.clone(), value);
|
|
}
|
|
}
|
|
out.insert("maxTemperature".into(), temperature("tempMax"));
|
|
out.insert("minTemperature".into(), temperature("tempMin"));
|
|
if let Some(conditions) = field("conditions") {
|
|
out.insert("condition".into(), conditions.clone());
|
|
}
|
|
Value::Object(out)
|
|
}
|
|
|
|
/// storage.getWeatherData : ligne du jour en cache (sans tri, comme Node)
|
|
async fn cached(
|
|
db: &PgPool,
|
|
date: &str,
|
|
is_current_year: bool,
|
|
) -> Result<Option<WeatherRecord>, sqlx::Error> {
|
|
let row: Option<WeatherDataRow> = sqlx::query_as(&WeatherDataRow::select(
|
|
"WHERE (date = $1::date AND is_current_year = $2) LIMIT 1",
|
|
))
|
|
.bind(date)
|
|
.bind(is_current_year)
|
|
.persistent(false)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
Ok(row.map(record_of))
|
|
}
|
|
|
|
/// storage.getNearestWeatherData : ligne en cache la plus proche, à 7 jours près
|
|
async fn nearest(
|
|
db: &PgPool,
|
|
date: &str,
|
|
is_current_year: bool,
|
|
) -> Result<Option<WeatherRecord>, sqlx::Error> {
|
|
let row: Option<WeatherDataRow> = sqlx::query_as(&WeatherDataRow::select(
|
|
"WHERE (is_current_year = $1 AND ABS(date - $2::date) <= $3)
|
|
ORDER BY ABS(date - $4::date) LIMIT 1",
|
|
))
|
|
.bind(is_current_year)
|
|
.bind(date)
|
|
.bind(7_i32)
|
|
.bind(date)
|
|
.persistent(false)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
Ok(row.map(record_of))
|
|
}
|
|
|
|
/// storage.createWeatherData : valeurs envoyées comme node-pg, undefined → défaut
|
|
async fn save(db: &PgPool, record: &WeatherRecord) -> Result<WeatherRecord, sqlx::Error> {
|
|
let mut arguments = PgArguments::default();
|
|
let mut columns = Vec::new();
|
|
let mut placeholders = Vec::new();
|
|
for (key, value) in record {
|
|
let (Some(value), Some(col)) = (value, WeatherDataRow::field(key)) else {
|
|
continue;
|
|
};
|
|
arguments.add(node_pg_text(value)).map_err(sqlx::Error::Encode)?;
|
|
columns.push(format!("\"{}\"", col.column));
|
|
placeholders.push(format!("${}::{}", columns.len(), col.sql_type));
|
|
}
|
|
let sql = format!(
|
|
"INSERT INTO weather_data ({}) VALUES ({}) RETURNING {}",
|
|
columns.join(", "),
|
|
placeholders.join(", "),
|
|
WeatherDataRow::COLUMNS
|
|
);
|
|
let row: WeatherDataRow = sqlx::query_as_with(&sql, arguments).fetch_one(db).await?;
|
|
Ok(record_of(row))
|
|
}
|
|
|
|
/// Météo absente du cache : appel à l'API, puis enregistrement ; si
|
|
/// l'enregistrement échoue, la ligne en cache, à défaut la réponse de l'API
|
|
async fn fetch_and_save(
|
|
db: &PgPool,
|
|
url: &str,
|
|
location: &str,
|
|
date: &str,
|
|
is_current_year: bool,
|
|
) -> Result<Option<WeatherRecord>, AppError> {
|
|
const FAILED: &str = "Failed to fetch weather data";
|
|
let Some(api) = fetch_timeline(url).await.filter(truthy) else {
|
|
return Ok(None);
|
|
};
|
|
let Some(record) =
|
|
convert_api_data(&api, location, is_current_year).map_err(|_| AppError::internal(FAILED))?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
match save(db, &record).await {
|
|
Ok(saved) => Ok(Some(saved)),
|
|
Err(error) => {
|
|
tracing::warn!(%error, "météo : enregistrement impossible");
|
|
let existing = cached(db, date, is_current_year).await.or_500(FAILED)?;
|
|
Ok(Some(existing.unwrap_or(record)))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn current(State(state): State<AppState>, _user: CurrentUser) -> ApiResult {
|
|
const FAILED: &str = "Failed to fetch weather data";
|
|
let db = &state.db;
|
|
let settings = weather_settings(db)
|
|
.await
|
|
.or_500(FAILED)?
|
|
.filter(|settings| settings.is_active)
|
|
.ok_or_else(|| AppError::not_found("Weather service not configured or disabled"))?;
|
|
|
|
let today = crate::clock::today();
|
|
let today_text = today.format("%Y-%m-%d").to_string();
|
|
let previous_text = previous_year_date(today).format("%Y-%m-%d").to_string();
|
|
// Lectures du cache en parallèle (Promise.all)
|
|
let (current_year, previous_year) =
|
|
tokio::join!(cached(db, &today_text, true), cached(db, &previous_text, false));
|
|
let (mut current_year, mut previous_year) = (current_year.or_500(FAILED)?, previous_year.or_500(FAILED)?);
|
|
|
|
// Appels à l'API l'un après l'autre (limite de requêtes simultanées de Visual Crossing)
|
|
if current_year.is_none() {
|
|
let url = timeline_url(&settings.location, None, &settings.api_key, true);
|
|
current_year = fetch_and_save(db, &url, &settings.location, &today_text, true).await?;
|
|
}
|
|
if previous_year.is_none() {
|
|
let url = timeline_url(&settings.location, Some(&previous_text), &settings.api_key, true);
|
|
previous_year = fetch_and_save(db, &url, &settings.location, &previous_text, false).await?;
|
|
}
|
|
// Replis : ligne de l'an dernier la plus proche, puis météo relevée l'an dernier
|
|
if previous_year.is_none() {
|
|
previous_year = nearest(db, &previous_text, false).await.or_500(FAILED)?;
|
|
}
|
|
if previous_year.is_none() {
|
|
previous_year = nearest(db, &previous_text, true).await.or_500(FAILED)?;
|
|
}
|
|
|
|
Ok(Json(json!({
|
|
"currentYear": current_year.as_ref().map(displayed),
|
|
"previousYear": previous_year.as_ref().map(displayed),
|
|
"location": settings.location,
|
|
}))
|
|
.into_response())
|
|
}
|
|
|
|
async fn geolocation(
|
|
State(state): State<AppState>,
|
|
_user: CurrentUser,
|
|
JsonBody(body): JsonBody,
|
|
) -> ApiResult {
|
|
const FAILED: &str = "Erreur lors de la géolocalisation";
|
|
let given = |key: &str| body.get(key).filter(|value| truthy(value));
|
|
let (Some(latitude), Some(longitude)) = (given("latitude"), given("longitude")) else {
|
|
return Err(AppError::bad_request("Latitude et longitude requises"));
|
|
};
|
|
let settings = weather_settings(&state.db)
|
|
.await
|
|
.or_500(FAILED)?
|
|
.filter(|settings| !settings.api_key.is_empty())
|
|
.ok_or_else(|| AppError::internal("Configuration météo manquante"))?;
|
|
let latitude = js::parse_float(&string_of(latitude));
|
|
let longitude = js::parse_float(&string_of(longitude));
|
|
let (city, country, full_location) = city_from_coordinates(latitude, longitude)
|
|
.await
|
|
.ok_or_else(|| AppError::internal("Impossible de déterminer la ville à partir des coordonnées"))?;
|
|
|
|
let mut data = Map::new();
|
|
data.insert("location".into(), json!(full_location));
|
|
db::update_by_id::<WeatherSettingsRow>(&state.db, settings.id, &data, true)
|
|
.await
|
|
.or_500(FAILED)?;
|
|
// La ville a changé : le cache météo est vidé
|
|
sqlx::query("DELETE FROM weather_data")
|
|
.execute(&state.db)
|
|
.await
|
|
.or_500(FAILED)?;
|
|
tracing::info!(from = %settings.location, to = %full_location, "localisation météo mise à jour");
|
|
Ok(Json(json!({
|
|
"success": true,
|
|
"location": { "city": city, "country": country, "fullLocation": full_location },
|
|
"message": format!("Localisation mise à jour vers {full_location}"),
|
|
}))
|
|
.into_response())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn day(text: &str) -> NaiveDate {
|
|
NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn date_de_l_an_dernier_comme_node() {
|
|
// weatherService.getPreviousYearDate(new Date(`${date}T12:00:00Z`)), TZ=UTC
|
|
for (date, expected) in [
|
|
("2026-10-15", "2025-10-16"),
|
|
("2026-10-01", "2025-10-02"),
|
|
("2026-10-31", "2025-10-25"),
|
|
("2026-03-29", "2025-03-30"),
|
|
("2026-03-31", "2025-03-25"),
|
|
("2026-02-28", "2025-02-22"),
|
|
("2026-01-01", "2025-01-02"),
|
|
("2026-12-31", "2025-12-25"),
|
|
("2025-03-30", "2024-03-31"),
|
|
("2024-02-29", "2023-02-23"),
|
|
("2028-02-29", "2027-02-23"),
|
|
("2026-07-07", "2025-07-01"),
|
|
("2026-05-31", "2025-05-25"),
|
|
("2026-08-29", "2025-08-30"),
|
|
("2026-11-30", "2025-11-24"),
|
|
] {
|
|
assert_eq!(previous_year_date(day(date)), day(expected), "{date}");
|
|
}
|
|
}
|
|
|
|
fn as_json(record: Option<WeatherRecord>) -> Value {
|
|
match record {
|
|
None => Value::Null,
|
|
Some(record) => Value::Object(
|
|
record
|
|
.into_iter()
|
|
.filter_map(|(key, value)| value.map(|value| (key, value)))
|
|
.collect(),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn conversion_de_la_reponse_comme_node() {
|
|
// JSON.stringify(weatherService.convertApiDataToWeatherData(…, "Nancy, France", true))
|
|
let convert = |api: Value| as_json(convert_api_data(&api, "Nancy, France", true).unwrap());
|
|
assert_eq!(
|
|
convert(
|
|
json!({ "days": [{ "datetime": "2026-10-15", "tempmax": 18.3, "tempmin": 7, "icon": "partly-cloudy-day", "conditions": "Partially cloudy" }] })
|
|
),
|
|
json!({ "date": "2026-10-15", "location": "Nancy, France", "tempMax": "18.3", "tempMin": "7", "icon": "partly-cloudy-day", "conditions": "Nuageux", "isCurrentYear": true })
|
|
);
|
|
assert_eq!(
|
|
convert(
|
|
json!({ "days": [{ "datetime": "2025-10-16", "tempmax": -2.5, "tempmin": -10.25, "icon": "snow", "conditions": "Snow, Heavy" }] })
|
|
),
|
|
json!({ "date": "2025-10-16", "location": "Nancy, France", "tempMax": "-2.5", "tempMin": "-10.25", "icon": "snow", "conditions": "Neige forte", "isCurrentYear": true })
|
|
);
|
|
assert_eq!(
|
|
convert(
|
|
json!({ "days": [{ "datetime": "2026-01-02", "tempmax": "12", "tempmin": true, "icon": "rain", "conditions": "Rain, Overcast" }] })
|
|
),
|
|
json!({ "date": "2026-01-02", "location": "Nancy, France", "tempMax": "12", "tempMin": "true", "icon": "rain", "conditions": "Couvert", "isCurrentYear": true })
|
|
);
|
|
assert_eq!(
|
|
convert(
|
|
json!({ "days": [{ "datetime": "2026-01-03", "tempmax": [1, 2], "tempmin": { "a": 1 }, "conditions": "Pluie modérée" }] })
|
|
),
|
|
json!({ "date": "2026-01-03", "location": "Nancy, France", "tempMax": "1,2", "tempMin": "[object Object]", "conditions": "Pluie modérée", "isCurrentYear": true })
|
|
);
|
|
assert_eq!(
|
|
convert(
|
|
json!({ "days": [{ "tempmax": 0.1, "tempmin": 1e-7, "icon": 5, "conditions": "Light Drizzle" }] })
|
|
),
|
|
json!({ "location": "Nancy, France", "tempMax": "0.1", "tempMin": "1e-7", "icon": 5, "conditions": "Bruine", "isCurrentYear": true })
|
|
);
|
|
for empty in [
|
|
json!({ "days": [] }),
|
|
json!({ "days": { "length": 0 } }),
|
|
json!({ "nodays": 1 }),
|
|
json!("texte"),
|
|
] {
|
|
assert_eq!(convert(empty), Value::Null);
|
|
}
|
|
for invalid in [
|
|
json!({ "days": [{ "tempmax": null, "tempmin": 1, "conditions": "x" }] }),
|
|
json!({ "days": [{ "tempmax": 1, "tempmin": 1, "conditions": 3 }] }),
|
|
json!({ "days": [5] }),
|
|
json!({ "days": 5 }),
|
|
json!({ "days": "abc" }),
|
|
] {
|
|
assert_eq!(convert_api_data(&invalid, "L", false), Err(TypeError));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn conditions_traduites_comme_node() {
|
|
let conditions = [
|
|
"Clear",
|
|
"Sunny",
|
|
"Fair",
|
|
"Partially cloudy",
|
|
"Cloudy",
|
|
"Overcast",
|
|
"Rain, Heavy",
|
|
"Light Rain",
|
|
"Rain",
|
|
"Showers",
|
|
"Drizzle",
|
|
"Thunderstorm",
|
|
"Snow, Light",
|
|
"Snow",
|
|
"Fog",
|
|
"Mist",
|
|
"Windy",
|
|
"Pluie modérée",
|
|
"Rain, Partially cloudy",
|
|
"Clear, Snow",
|
|
];
|
|
let expected = [
|
|
"Dégagé",
|
|
"Ensoleillé",
|
|
"Beau temps",
|
|
"Nuageux",
|
|
"Nuageux",
|
|
"Couvert",
|
|
"Pluie forte",
|
|
"Pluie légère",
|
|
"Pluie",
|
|
"Averses",
|
|
"Bruine",
|
|
"Orage",
|
|
"Neige légère",
|
|
"Neige",
|
|
"Brouillard",
|
|
"Brume",
|
|
"Venteux",
|
|
"Pluie modérée",
|
|
"Nuageux",
|
|
"Dégagé",
|
|
];
|
|
for (condition, french) in conditions.iter().zip(expected) {
|
|
assert_eq!(translate_condition(condition), french, "{condition}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn icones_comme_node() {
|
|
for (icon, expected) in [
|
|
("clear-day", json!("☀️")),
|
|
("clear-night", json!("🌙")),
|
|
("partly-cloudy-day", json!("⛅")),
|
|
("partly-cloudy-night", json!("☁️")),
|
|
("cloudy", json!("☁️")),
|
|
("rain", json!("🌧️")),
|
|
("snow", json!("❄️")),
|
|
("wind", json!("💨")),
|
|
("fog", json!("🌫️")),
|
|
("thunder", json!("⛈️")),
|
|
("hail", json!("🌨️")),
|
|
("showers-day", json!("🌤️")),
|
|
("", json!("🌤️")),
|
|
("__proto__", json!({})),
|
|
] {
|
|
assert_eq!(weather_icon(Some(&json!(icon))), Some(expected), "{icon}");
|
|
}
|
|
assert_eq!(weather_icon(Some(&json!("constructor"))), None);
|
|
assert_eq!(weather_icon(Some(&json!("toString"))), None);
|
|
assert_eq!(weather_icon(None), Some(json!("🌤️")));
|
|
assert_eq!(weather_icon(Some(&Value::Null)), Some(json!("🌤️")));
|
|
}
|
|
|
|
#[test]
|
|
fn affichage_d_une_reponse_non_enregistree() {
|
|
// Réponse de l'API sans date ni icône : clés omises, icône par défaut à sa place
|
|
let record = convert_api_data(
|
|
&json!({ "days": [{ "tempmax": "21.5°", "tempmin": "n/a", "conditions": "Clear" }] }),
|
|
"Metz",
|
|
false,
|
|
)
|
|
.unwrap()
|
|
.unwrap();
|
|
let shown = displayed(&record);
|
|
assert_eq!(
|
|
serde_json::to_string(&shown).unwrap(),
|
|
r#"{"location":"Metz","tempMax":"21.5°","tempMin":"n/a","icon":"🌤️","conditions":"Dégagé","isCurrentYear":false,"maxTemperature":21.5,"minTemperature":null,"condition":"Dégagé"}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn adresses_des_services() {
|
|
assert_eq!(encode_uri_component("Nancy, France"), "Nancy%2C%20France");
|
|
assert_eq!(encode_uri_component("Épinal (88)!*'~"), "%C3%89pinal%20(88)!*'~");
|
|
assert_eq!(
|
|
timeline_url("Nancy, France", Some("2025-10-16"), "CLE", true),
|
|
"https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/Nancy%2C%20France/2025-10-16?unitGroup=metric&include=days&key=CLE&contentType=json&lang=fr"
|
|
);
|
|
assert!(has_items(&json!([1])) && !has_items(&json!([])) && !has_items(&json!(3)));
|
|
}
|
|
}
|