diff options
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | README.md | 49 | ||||
| -rw-r--r-- | lefthook.yml | 7 | ||||
| -rw-r--r-- | src/auth.rs | 33 | ||||
| -rw-r--r-- | src/health.rs | 4 | ||||
| -rw-r--r-- | src/lib.rs | 540 | ||||
| -rw-r--r-- | src/locations.rs | 40 | ||||
| -rw-r--r-- | src/notifications.rs | 220 | ||||
| -rw-r--r-- | src/users.rs | 35 | ||||
| -rw-r--r-- | src/weather_poller.rs | 177 | ||||
| -rw-r--r-- | src/weather_thresholds.rs | 153 |
12 files changed, 1134 insertions, 126 deletions
@@ -2074,6 +2074,7 @@ name = "silmataivas" version = "0.2.0" dependencies = [ "anyhow", + "async-trait", "axum", "chrono", "dotenv", @@ -4,6 +4,7 @@ version = "0.2.0" edition = "2024" [dependencies] +async-trait = "0.1" axum = "0.8.4" chrono = "0.4.41" hyper = "1.6.0" @@ -1,4 +1,51 @@ -# Silmataivas +# Silmataivas (Rust Rewrite) + +Silmataivas is a weather monitoring service that sends personalized alerts based on user-defined thresholds and notification preferences. This is the Rust rewrite, providing a RESTful API for managing users, locations, weather thresholds, and notification settings. + +## Features +- Weather monitoring using OpenWeatherMap API +- Custom weather thresholds per user +- Flexible notifications: NTFY (push) and SMTP (email) +- User-specific configuration +- RESTful API for all resources +- Automatic OpenAPI documentation at `/docs` + +## API Usage +All API endpoints (except `/health` and `/docs`) require authentication using a Bearer token: + +``` +Authorization: Bearer <user_id> +``` + +### Main Endpoints +- `GET /health` — Health check +- `GET /api/users` — List users +- `POST /api/users` — Create user +- `GET /api/users/:id` — Get user +- `PUT /api/users/:id` — Update user +- `DELETE /api/users/:id` — Delete user +- `GET /api/locations` — List locations +- `POST /api/locations` — Create location +- `GET /api/locations/:id` — Get location +- `PUT /api/locations/:id` — Update location +- `DELETE /api/locations/:id` — Delete location +- `GET /api/weather-thresholds?user_id=...` — List thresholds for user +- `POST /api/weather-thresholds` — Create threshold +- `GET /api/weather-thresholds/:id/:user_id` — Get threshold +- `PUT /api/weather-thresholds/:id/:user_id` — Update threshold +- `DELETE /api/weather-thresholds/:id/:user_id` — Delete threshold +- `GET /api/ntfy-settings/:user_id` — Get NTFY settings +- `POST /api/ntfy-settings` — Create NTFY settings +- `PUT /api/ntfy-settings/:id` — Update NTFY settings +- `DELETE /api/ntfy-settings/:id` — Delete NTFY settings +- `GET /api/smtp-settings/:user_id` — Get SMTP settings +- `POST /api/smtp-settings` — Create SMTP settings +- `PUT /api/smtp-settings/:id` — Update SMTP settings +- `DELETE /api/smtp-settings/:id` — Delete SMTP settings + +For full details and request/response schemas, see the interactive OpenAPI docs at [`/docs`](http://localhost:4000/docs). + +--- To start your Phoenix server: diff --git a/lefthook.yml b/lefthook.yml index b855ccb..8899132 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,3 +10,10 @@ pre-commit: yamllint: glob: "**/*.{yml,yaml}" run: yamllint {staged_files} + fmt: + run: cargo fmt --all -- --check + stage_fixed: true + # clippy: + # run: cargo clippy --all-targets --all-features -- -D warnings + test: + run: cargo test --all
\ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..864efe0 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,33 @@ +use crate::users::{User, UserRepository}; +use axum::extract::FromRef; +use axum::extract::FromRequestParts; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use sqlx::SqlitePool; +use std::sync::Arc; + +pub struct AuthUser(pub User); + +impl<S> FromRequestParts<S> for AuthUser +where + Arc<SqlitePool>: axum::extract::FromRef<S>, + S: Send + Sync, +{ + type Rejection = Response; + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { + let pool = Arc::<SqlitePool>::from_ref(state); + let auth_header = parts + .headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Bearer ")); + if let Some(user_id) = auth_header { + let repo = UserRepository { db: &pool }; + if let Ok(Some(user)) = repo.get_user_by_user_id(user_id).await { + return Ok(AuthUser(user)); + } + } + Err((StatusCode::UNAUTHORIZED, "Unauthorized").into_response()) + } +} diff --git a/src/health.rs b/src/health.rs index daa2f43..ddf949a 100644 --- a/src/health.rs +++ b/src/health.rs @@ -1,6 +1,6 @@ -use axum::{response::IntoResponse, Json}; +use axum::{Json, response::IntoResponse}; use serde_json::json; pub async fn health_handler() -> impl IntoResponse { Json(json!({"status": "ok"})) -}
\ No newline at end of file +} @@ -1,29 +1,555 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::{post, put}; use axum::{Router, routing::get}; +use serde_json::json; +use sqlx::SqlitePool; -pub fn app() -> Router { +// Derive OpenApi for models + +mod auth; + +fn error_response(status: StatusCode, message: &str) -> axum::response::Response { + (status, Json(json!({"error": message}))).into_response() +} + +async fn not_found() -> axum::response::Response { + error_response(StatusCode::NOT_FOUND, "Not Found") +} + +mod users_api { + use super::*; + use crate::auth::AuthUser; + use crate::users::{User, UserRepository, UserRole}; + use axum::{ + Json, + extract::{Path, State}, + }; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Deserialize)] + pub struct CreateUser { + pub role: Option<UserRole>, + } + + #[derive(Deserialize)] + pub struct UpdateUser { + pub role: UserRole, + } + + pub async fn list_users( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<Vec<User>>, String> { + let repo = UserRepository { db: &pool }; + repo.list_users().await.map(Json).map_err(|e| e.to_string()) + } + + pub async fn get_user( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<User>, String> { + let repo = UserRepository { db: &pool }; + repo.get_user_by_id(id) + .await + .map_err(|e| e.to_string())? + .map(Json) + .ok_or_else(|| "User not found".to_string()) + } + + pub async fn create_user( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<CreateUser>, + ) -> Result<Json<User>, String> { + let repo = UserRepository { db: &pool }; + repo.create_user(None, payload.role) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn update_user( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<UpdateUser>, + ) -> Result<Json<User>, String> { + let repo = UserRepository { db: &pool }; + repo.update_user(id, payload.role) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn delete_user( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<(), String> { + let repo = UserRepository { db: &pool }; + repo.delete_user(id).await.map_err(|e| e.to_string()) + } +} + +mod locations_api { + use super::*; + use crate::auth::AuthUser; + use crate::locations::{Location, LocationRepository}; + use axum::{ + Json, + extract::{Path, State}, + }; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Deserialize)] + pub struct CreateLocation { + pub latitude: f64, + pub longitude: f64, + pub user_id: i64, + } + + #[derive(Deserialize)] + pub struct UpdateLocation { + pub latitude: f64, + pub longitude: f64, + } + + pub async fn list_locations( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<Vec<Location>>, String> { + let repo = LocationRepository { db: &pool }; + repo.list_locations() + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn get_location( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<Location>, String> { + let repo = LocationRepository { db: &pool }; + repo.get_location(id) + .await + .map_err(|e| e.to_string())? + .map(Json) + .ok_or_else(|| "Location not found".to_string()) + } + + pub async fn create_location( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<CreateLocation>, + ) -> Result<Json<Location>, String> { + let repo = LocationRepository { db: &pool }; + repo.create_location(payload.latitude, payload.longitude, payload.user_id) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn update_location( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<UpdateLocation>, + ) -> Result<Json<Location>, String> { + let repo = LocationRepository { db: &pool }; + // user_id is not updated + repo.update_location(id, payload.latitude, payload.longitude) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn delete_location( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<(), String> { + let repo = LocationRepository { db: &pool }; + repo.delete_location(id).await.map_err(|e| e.to_string()) + } +} + +mod thresholds_api { + use super::*; + use crate::auth::AuthUser; + use crate::weather_thresholds::{WeatherThreshold, WeatherThresholdRepository}; + use axum::{ + Json, + extract::{Path, Query, State}, + }; + use serde::Deserialize; + use std::sync::Arc; + + #[derive(Deserialize)] + pub struct CreateThreshold { + pub user_id: i64, + pub condition_type: String, + pub threshold_value: f64, + pub operator: String, + pub enabled: bool, + pub description: Option<String>, + } + + #[derive(Deserialize)] + pub struct UpdateThreshold { + pub user_id: i64, + pub condition_type: String, + pub threshold_value: f64, + pub operator: String, + pub enabled: bool, + pub description: Option<String>, + } + + pub async fn list_thresholds( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Query(query): Query<std::collections::HashMap<String, String>>, + ) -> impl axum::response::IntoResponse { + let repo = WeatherThresholdRepository { db: &pool }; + let user_id = query + .get("user_id") + .and_then(|s| s.parse().ok()) + .ok_or_else(|| "user_id required as query param".to_string())?; + repo.list_thresholds(user_id) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn get_threshold( + Path((id, user_id)): Path<(i64, i64)>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<WeatherThreshold>, String> { + let repo = WeatherThresholdRepository { db: &pool }; + repo.get_threshold(id, user_id) + .await + .map_err(|e| e.to_string())? + .map(Json) + .ok_or_else(|| "Threshold not found".to_string()) + } + + pub async fn create_threshold( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<CreateThreshold>, + ) -> Result<Json<WeatherThreshold>, String> { + let repo = WeatherThresholdRepository { db: &pool }; + repo.create_threshold( + payload.user_id, + payload.condition_type, + payload.threshold_value, + payload.operator, + payload.enabled, + payload.description, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn update_threshold( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<UpdateThreshold>, + ) -> Result<Json<WeatherThreshold>, String> { + let repo = WeatherThresholdRepository { db: &pool }; + repo.update_threshold( + id, + payload.user_id, + payload.condition_type, + payload.threshold_value, + payload.operator, + payload.enabled, + payload.description, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + + pub async fn delete_threshold( + Path((id, user_id)): Path<(i64, i64)>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<(), String> { + let repo = WeatherThresholdRepository { db: &pool }; + repo.delete_threshold(id, user_id) + .await + .map_err(|e| e.to_string()) + } +} + +mod notifications_api { + use super::*; + use crate::auth::AuthUser; + use crate::notifications::{ + NtfySettings, NtfySettingsRepository, SmtpSettings, SmtpSettingsRepository, + }; + use axum::{ + Json, + extract::{Path, State}, + }; + use serde::Deserialize; + use std::sync::Arc; + + // NTFY + #[derive(Deserialize)] + pub struct CreateNtfy { + pub user_id: i64, + pub enabled: bool, + pub topic: String, + pub server_url: String, + pub priority: i32, + pub title_template: Option<String>, + pub message_template: Option<String>, + } + #[derive(Deserialize)] + pub struct UpdateNtfy { + pub enabled: bool, + pub topic: String, + pub server_url: String, + pub priority: i32, + pub title_template: Option<String>, + pub message_template: Option<String>, + } + pub async fn get_ntfy_settings( + Path(user_id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<NtfySettings>, String> { + let repo = NtfySettingsRepository { db: &pool }; + repo.get_by_user(user_id) + .await + .map_err(|e| e.to_string())? + .map(Json) + .ok_or_else(|| "NTFY settings not found".to_string()) + } + pub async fn create_ntfy_settings( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<CreateNtfy>, + ) -> Result<Json<NtfySettings>, String> { + let repo = NtfySettingsRepository { db: &pool }; + repo.create( + payload.user_id, + payload.enabled, + payload.topic, + payload.server_url, + payload.priority, + payload.title_template, + payload.message_template, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + pub async fn update_ntfy_settings( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<UpdateNtfy>, + ) -> Result<Json<NtfySettings>, String> { + let repo = NtfySettingsRepository { db: &pool }; + repo.update( + id, + payload.enabled, + payload.topic, + payload.server_url, + payload.priority, + payload.title_template, + payload.message_template, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + pub async fn delete_ntfy_settings( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<(), String> { + let repo = NtfySettingsRepository { db: &pool }; + repo.delete(id).await.map_err(|e| e.to_string()) + } + + // SMTP + #[derive(Deserialize)] + pub struct CreateSmtp { + pub user_id: i64, + pub enabled: bool, + pub email: String, + pub smtp_server: String, + pub smtp_port: i32, + pub username: Option<String>, + pub password: Option<String>, + pub use_tls: bool, + pub from_email: Option<String>, + pub from_name: Option<String>, + pub subject_template: Option<String>, + pub body_template: Option<String>, + } + #[derive(Deserialize)] + pub struct UpdateSmtp { + pub enabled: bool, + pub email: String, + pub smtp_server: String, + pub smtp_port: i32, + pub username: Option<String>, + pub password: Option<String>, + pub use_tls: bool, + pub from_email: Option<String>, + pub from_name: Option<String>, + pub subject_template: Option<String>, + pub body_template: Option<String>, + } + pub async fn get_smtp_settings( + Path(user_id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<Json<SmtpSettings>, String> { + let repo = SmtpSettingsRepository { db: &pool }; + repo.get_by_user(user_id) + .await + .map_err(|e| e.to_string())? + .map(Json) + .ok_or_else(|| "SMTP settings not found".to_string()) + } + pub async fn create_smtp_settings( + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<CreateSmtp>, + ) -> Result<Json<SmtpSettings>, String> { + let repo = SmtpSettingsRepository { db: &pool }; + repo.create( + payload.user_id, + payload.enabled, + payload.email, + payload.smtp_server, + payload.smtp_port, + payload.username, + payload.password, + payload.use_tls, + payload.from_email, + payload.from_name, + payload.subject_template, + payload.body_template, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + pub async fn update_smtp_settings( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + Json(payload): Json<UpdateSmtp>, + ) -> Result<Json<SmtpSettings>, String> { + let repo = SmtpSettingsRepository { db: &pool }; + repo.update( + id, + payload.enabled, + payload.email, + payload.smtp_server, + payload.smtp_port, + payload.username, + payload.password, + payload.use_tls, + payload.from_email, + payload.from_name, + payload.subject_template, + payload.body_template, + ) + .await + .map(Json) + .map_err(|e| e.to_string()) + } + pub async fn delete_smtp_settings( + Path(id): Path<i64>, + AuthUser(_): AuthUser, + State(pool): State<Arc<SqlitePool>>, + ) -> Result<(), String> { + let repo = SmtpSettingsRepository { db: &pool }; + repo.delete(id).await.map_err(|e| e.to_string()) + } +} + +pub fn app_with_state(pool: std::sync::Arc<SqlitePool>) -> Router { Router::new() .route("/health", get(crate::health::health_handler)) + .nest("/api/users", + Router::new() + .route("/", get(users_api::list_users).post(users_api::create_user)) + .route("/{id}", get(users_api::get_user).put(users_api::update_user).delete(users_api::delete_user)) + ) + .nest("/api/locations", + Router::new() + .route("/", get(locations_api::list_locations).post(locations_api::create_location)) + .route("/{id}", get(locations_api::get_location).put(locations_api::update_location).delete(locations_api::delete_location)) + ) + .nest("/api/weather-thresholds", + Router::new() + .route("/", get(|auth_user, state, query: axum::extract::Query<std::collections::HashMap<String, String>>| async move { + thresholds_api::list_thresholds(auth_user, state, query).await + }).post(thresholds_api::create_threshold)) + .route("/{id}/{user_id}", get(thresholds_api::get_threshold).put(thresholds_api::update_threshold).delete(thresholds_api::delete_threshold)) + ) + .nest("/api/ntfy-settings", + Router::new() + .route("/user/{user_id}", get(notifications_api::get_ntfy_settings)) + .route("/", post(notifications_api::create_ntfy_settings)) + .route("/{id}", put(notifications_api::update_ntfy_settings).delete(notifications_api::delete_ntfy_settings)) + ) + .nest("/api/smtp-settings", + Router::new() + .route("/user/{user_id}", get(notifications_api::get_smtp_settings)) + .route("/", post(notifications_api::create_smtp_settings)) + .route("/{id}", put(notifications_api::update_smtp_settings).delete(notifications_api::delete_smtp_settings)) + ) + .fallback(not_found) + .with_state(pool) } -pub mod users; +pub mod health; pub mod locations; -pub mod weather_thresholds; pub mod notifications; +pub mod users; pub mod weather_poller; -pub mod health; +pub mod weather_thresholds; #[cfg(test)] mod tests { use super::*; use axum::body::Body; + use axum::body::to_bytes; use axum::http::{Request, StatusCode}; use tower::ServiceExt; // for `oneshot` - use axum::body::to_bytes; #[tokio::test] async fn test_health_endpoint() { - let app = app(); - let response = app.oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()).await.unwrap(); + let app = app_with_state(std::sync::Arc::new( + sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(), + )); + let response = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), 1024).await.unwrap(); assert_eq!(&body[..], b"{\"status\":\"ok\"}"); diff --git a/src/locations.rs b/src/locations.rs index e5f881d..2f8c3f8 100644 --- a/src/locations.rs +++ b/src/locations.rs @@ -21,13 +21,20 @@ impl<'a> LocationRepository<'a> { } pub async fn get_location(&self, id: i64) -> Result<Option<Location>, sqlx::Error> { - sqlx::query_as::<_, Location>("SELECT id, latitude, longitude, user_id FROM locations WHERE id = ?") - .bind(id) - .fetch_optional(self.db) - .await + sqlx::query_as::<_, Location>( + "SELECT id, latitude, longitude, user_id FROM locations WHERE id = ?", + ) + .bind(id) + .fetch_optional(self.db) + .await } - pub async fn create_location(&self, latitude: f64, longitude: f64, user_id: i64) -> Result<Location, sqlx::Error> { + pub async fn create_location( + &self, + latitude: f64, + longitude: f64, + user_id: i64, + ) -> Result<Location, sqlx::Error> { sqlx::query_as::<_, Location>( "INSERT INTO locations (latitude, longitude, user_id) VALUES (?, ?, ?) RETURNING id, latitude, longitude, user_id" ) @@ -38,7 +45,12 @@ impl<'a> LocationRepository<'a> { .await } - pub async fn update_location(&self, id: i64, latitude: f64, longitude: f64) -> Result<Location, sqlx::Error> { + pub async fn update_location( + &self, + id: i64, + latitude: f64, + longitude: f64, + ) -> Result<Location, sqlx::Error> { sqlx::query_as::<_, Location>( "UPDATE locations SET latitude = ?, longitude = ? WHERE id = ? RETURNING id, latitude, longitude, user_id" ) @@ -62,7 +74,7 @@ impl<'a> LocationRepository<'a> { mod tests { use super::*; use crate::users::{UserRepository, UserRole}; - use sqlx::{SqlitePool, Executor}; + use sqlx::{Executor, SqlitePool}; use tokio; async fn setup_db() -> SqlitePool { @@ -72,8 +84,10 @@ mod tests { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user' - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool.execute( "CREATE TABLE locations ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -81,8 +95,10 @@ mod tests { longitude REAL NOT NULL, user_id INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE NO ACTION - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool } @@ -136,4 +152,4 @@ mod tests { let locations = repo.list_locations().await.unwrap(); assert_eq!(locations.len(), 2); } -}
\ No newline at end of file +} diff --git a/src/notifications.rs b/src/notifications.rs index fb49e97..92d24d2 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -36,15 +36,22 @@ pub struct NtfySettingsRepository<'a> { impl<'a> NtfySettingsRepository<'a> { pub async fn get_by_user(&self, user_id: i64) -> Result<Option<NtfySettings>, sqlx::Error> { - sqlx::query_as::<_, NtfySettings>( - "SELECT * FROM user_ntfy_settings WHERE user_id = ?" - ) - .bind(user_id) - .fetch_optional(self.db) - .await + sqlx::query_as::<_, NtfySettings>("SELECT * FROM user_ntfy_settings WHERE user_id = ?") + .bind(user_id) + .fetch_optional(self.db) + .await } - pub async fn create(&self, user_id: i64, enabled: bool, topic: String, server_url: String, priority: i32, title_template: Option<String>, message_template: Option<String>) -> Result<NtfySettings, sqlx::Error> { + pub async fn create( + &self, + user_id: i64, + enabled: bool, + topic: String, + server_url: String, + priority: i32, + title_template: Option<String>, + message_template: Option<String>, + ) -> Result<NtfySettings, sqlx::Error> { sqlx::query_as::<_, NtfySettings>( "INSERT INTO user_ntfy_settings (user_id, enabled, topic, server_url, priority, title_template, message_template) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *" ) @@ -59,7 +66,16 @@ impl<'a> NtfySettingsRepository<'a> { .await } - pub async fn update(&self, id: i64, enabled: bool, topic: String, server_url: String, priority: i32, title_template: Option<String>, message_template: Option<String>) -> Result<NtfySettings, sqlx::Error> { + pub async fn update( + &self, + id: i64, + enabled: bool, + topic: String, + server_url: String, + priority: i32, + title_template: Option<String>, + message_template: Option<String>, + ) -> Result<NtfySettings, sqlx::Error> { sqlx::query_as::<_, NtfySettings>( "UPDATE user_ntfy_settings SET enabled = ?, topic = ?, server_url = ?, priority = ?, title_template = ?, message_template = ? WHERE id = ? RETURNING *" ) @@ -89,15 +105,27 @@ pub struct SmtpSettingsRepository<'a> { impl<'a> SmtpSettingsRepository<'a> { pub async fn get_by_user(&self, user_id: i64) -> Result<Option<SmtpSettings>, sqlx::Error> { - sqlx::query_as::<_, SmtpSettings>( - "SELECT * FROM user_smtp_settings WHERE user_id = ?" - ) - .bind(user_id) - .fetch_optional(self.db) - .await + sqlx::query_as::<_, SmtpSettings>("SELECT * FROM user_smtp_settings WHERE user_id = ?") + .bind(user_id) + .fetch_optional(self.db) + .await } - pub async fn create(&self, user_id: i64, enabled: bool, email: String, smtp_server: String, smtp_port: i32, username: Option<String>, password: Option<String>, use_tls: bool, from_email: Option<String>, from_name: Option<String>, subject_template: Option<String>, body_template: Option<String>) -> Result<SmtpSettings, sqlx::Error> { + pub async fn create( + &self, + user_id: i64, + enabled: bool, + email: String, + smtp_server: String, + smtp_port: i32, + username: Option<String>, + password: Option<String>, + use_tls: bool, + from_email: Option<String>, + from_name: Option<String>, + subject_template: Option<String>, + body_template: Option<String>, + ) -> Result<SmtpSettings, sqlx::Error> { sqlx::query_as::<_, SmtpSettings>( "INSERT INTO user_smtp_settings (user_id, enabled, email, smtp_server, smtp_port, username, password, use_tls, from_email, from_name, subject_template, body_template) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *" ) @@ -117,7 +145,21 @@ impl<'a> SmtpSettingsRepository<'a> { .await } - pub async fn update(&self, id: i64, enabled: bool, email: String, smtp_server: String, smtp_port: i32, username: Option<String>, password: Option<String>, use_tls: bool, from_email: Option<String>, from_name: Option<String>, subject_template: Option<String>, body_template: Option<String>) -> Result<SmtpSettings, sqlx::Error> { + pub async fn update( + &self, + id: i64, + enabled: bool, + email: String, + smtp_server: String, + smtp_port: i32, + username: Option<String>, + password: Option<String>, + use_tls: bool, + from_email: Option<String>, + from_name: Option<String>, + subject_template: Option<String>, + body_template: Option<String>, + ) -> Result<SmtpSettings, sqlx::Error> { sqlx::query_as::<_, SmtpSettings>( "UPDATE user_smtp_settings SET enabled = ?, email = ?, smtp_server = ?, smtp_port = ?, username = ?, password = ?, use_tls = ?, from_email = ?, from_name = ?, subject_template = ?, body_template = ? WHERE id = ? RETURNING *" ) @@ -150,7 +192,7 @@ impl<'a> SmtpSettingsRepository<'a> { mod tests { use super::*; use crate::users::{UserRepository, UserRole}; - use sqlx::{SqlitePool, Executor}; + use sqlx::{Executor, SqlitePool}; use tokio; async fn setup_db() -> SqlitePool { @@ -160,8 +202,10 @@ mod tests { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user' - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool.execute( "CREATE TABLE user_ntfy_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -173,8 +217,10 @@ mod tests { title_template TEXT, message_template TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool.execute( "CREATE TABLE user_smtp_settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -191,8 +237,10 @@ mod tests { subject_template TEXT, body_template TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool } @@ -207,7 +255,18 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = NtfySettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "topic1".to_string(), "https://ntfy.sh".to_string(), 3, Some("title".to_string()), Some("msg".to_string())).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "topic1".to_string(), + "https://ntfy.sh".to_string(), + 3, + Some("title".to_string()), + Some("msg".to_string()), + ) + .await + .unwrap(); let fetched = repo.get_by_user(user_id).await.unwrap().unwrap(); assert_eq!(fetched.topic, "topic1"); assert_eq!(fetched.server_url, "https://ntfy.sh"); @@ -221,8 +280,30 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = NtfySettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "topic1".to_string(), "https://ntfy.sh".to_string(), 3, None, None).await.unwrap(); - let updated = repo.update(settings.id, false, "topic2".to_string(), "https://ntfy2.sh".to_string(), 4, Some("t2".to_string()), Some("m2".to_string())).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "topic1".to_string(), + "https://ntfy.sh".to_string(), + 3, + None, + None, + ) + .await + .unwrap(); + let updated = repo + .update( + settings.id, + false, + "topic2".to_string(), + "https://ntfy2.sh".to_string(), + 4, + Some("t2".to_string()), + Some("m2".to_string()), + ) + .await + .unwrap(); assert_eq!(updated.enabled, false); assert_eq!(updated.topic, "topic2"); assert_eq!(updated.server_url, "https://ntfy2.sh"); @@ -236,7 +317,18 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = NtfySettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "topic1".to_string(), "https://ntfy.sh".to_string(), 3, None, None).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "topic1".to_string(), + "https://ntfy.sh".to_string(), + 3, + None, + None, + ) + .await + .unwrap(); repo.delete(settings.id).await.unwrap(); let fetched = repo.get_by_user(user_id).await.unwrap(); assert!(fetched.is_none()); @@ -247,7 +339,23 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = SmtpSettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "test@example.com".to_string(), "smtp.example.com".to_string(), 587, Some("user".to_string()), Some("pass".to_string()), true, Some("from@example.com".to_string()), Some("Alerts".to_string()), Some("subj".to_string()), Some("body".to_string())).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "test@example.com".to_string(), + "smtp.example.com".to_string(), + 587, + Some("user".to_string()), + Some("pass".to_string()), + true, + Some("from@example.com".to_string()), + Some("Alerts".to_string()), + Some("subj".to_string()), + Some("body".to_string()), + ) + .await + .unwrap(); let fetched = repo.get_by_user(user_id).await.unwrap().unwrap(); assert_eq!(fetched.email, "test@example.com"); assert_eq!(fetched.smtp_server, "smtp.example.com"); @@ -266,8 +374,40 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = SmtpSettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "test@example.com".to_string(), "smtp.example.com".to_string(), 587, None, None, true, None, None, None, None).await.unwrap(); - let updated = repo.update(settings.id, false, "other@example.com".to_string(), "smtp2.example.com".to_string(), 465, Some("u2".to_string()), Some("p2".to_string()), false, Some("f2@example.com".to_string()), Some("N2".to_string()), Some("s2".to_string()), Some("b2".to_string())).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "test@example.com".to_string(), + "smtp.example.com".to_string(), + 587, + None, + None, + true, + None, + None, + None, + None, + ) + .await + .unwrap(); + let updated = repo + .update( + settings.id, + false, + "other@example.com".to_string(), + "smtp2.example.com".to_string(), + 465, + Some("u2".to_string()), + Some("p2".to_string()), + false, + Some("f2@example.com".to_string()), + Some("N2".to_string()), + Some("s2".to_string()), + Some("b2".to_string()), + ) + .await + .unwrap(); assert_eq!(updated.enabled, false); assert_eq!(updated.email, "other@example.com"); assert_eq!(updated.smtp_server, "smtp2.example.com"); @@ -286,9 +426,25 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = SmtpSettingsRepository { db: &db }; - let settings = repo.create(user_id, true, "test@example.com".to_string(), "smtp.example.com".to_string(), 587, None, None, true, None, None, None, None).await.unwrap(); + let settings = repo + .create( + user_id, + true, + "test@example.com".to_string(), + "smtp.example.com".to_string(), + 587, + None, + None, + true, + None, + None, + None, + None, + ) + .await + .unwrap(); repo.delete(settings.id).await.unwrap(); let fetched = repo.get_by_user(user_id).await.unwrap(); assert!(fetched.is_none()); } -}
\ No newline at end of file +} diff --git a/src/users.rs b/src/users.rs index baca6dd..0cfa440 100644 --- a/src/users.rs +++ b/src/users.rs @@ -9,21 +9,16 @@ pub struct User { pub role: UserRole, } -#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, sqlx::Type, Clone, PartialEq, Eq, Default)] #[sqlx(type_name = "TEXT")] pub enum UserRole { #[serde(rename = "user")] + #[default] User, #[serde(rename = "admin")] Admin, } -impl Default for UserRole { - fn default() -> Self { - UserRole::User - } -} - pub struct UserRepository<'a> { pub db: &'a sqlx::SqlitePool, } @@ -49,11 +44,15 @@ impl<'a> UserRepository<'a> { .await } - pub async fn create_user(&self, user_id: Option<String>, role: Option<UserRole>) -> Result<User, sqlx::Error> { + pub async fn create_user( + &self, + user_id: Option<String>, + role: Option<UserRole>, + ) -> Result<User, sqlx::Error> { let user_id = user_id.unwrap_or_else(|| Uuid::new_v4().to_string()); let role = role.unwrap_or_default(); sqlx::query_as::<_, User>( - "INSERT INTO users (user_id, role) VALUES (?, ?) RETURNING id, user_id, role" + "INSERT INTO users (user_id, role) VALUES (?, ?) RETURNING id, user_id, role", ) .bind(user_id) .bind(role) @@ -63,7 +62,7 @@ impl<'a> UserRepository<'a> { pub async fn update_user(&self, id: i64, role: UserRole) -> Result<User, sqlx::Error> { sqlx::query_as::<_, User>( - "UPDATE users SET role = ? WHERE id = ? RETURNING id, user_id, role" + "UPDATE users SET role = ? WHERE id = ? RETURNING id, user_id, role", ) .bind(role) .bind(id) @@ -83,7 +82,7 @@ impl<'a> UserRepository<'a> { #[cfg(test)] mod tests { use super::*; - use sqlx::{SqlitePool, Executor}; + use sqlx::{Executor, SqlitePool}; use tokio; async fn setup_db() -> SqlitePool { @@ -93,8 +92,10 @@ mod tests { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user' - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool } @@ -104,7 +105,11 @@ mod tests { let repo = UserRepository { db: &db }; let user = repo.create_user(None, Some(UserRole::Admin)).await.unwrap(); assert_eq!(user.role, UserRole::Admin); - let fetched = repo.get_user_by_user_id(&user.user_id).await.unwrap().unwrap(); + let fetched = repo + .get_user_by_user_id(&user.user_id) + .await + .unwrap() + .unwrap(); assert_eq!(fetched.user_id, user.user_id); } @@ -136,4 +141,4 @@ mod tests { let users = repo.list_users().await.unwrap(); assert_eq!(users.len(), 2); } -}
\ No newline at end of file +} diff --git a/src/weather_poller.rs b/src/weather_poller.rs index 056cef8..4fd418d 100644 --- a/src/weather_poller.rs +++ b/src/weather_poller.rs @@ -1,13 +1,13 @@ -use crate::users::UserRepository; use crate::locations::LocationRepository; -use crate::weather_thresholds::{WeatherThresholdRepository, WeatherThreshold}; -use crate::notifications::{NtfySettingsRepository, SmtpSettingsRepository, NtfySettings, SmtpSettings}; -use serde_json::Value; -use tera::{Tera, Context}; +use crate::notifications::{ + NtfySettings, NtfySettingsRepository, SmtpSettings, SmtpSettingsRepository, +}; +use crate::users::UserRepository; +use crate::weather_thresholds::{WeatherThreshold, WeatherThresholdRepository}; use reqwest::Client; +use serde_json::Value; use std::sync::{Arc, Mutex}; -use tokio_task_scheduler::{Scheduler, Task}; -use tokio::time::Duration; +use tera::{Context, Tera}; const OWM_API_URL: &str = "https://api.openweathermap.org/data/2.5/forecast"; const ALERT_WINDOW_HOURS: i64 = 24; @@ -24,8 +24,15 @@ impl WeatherPoller { let loc_repo = LocationRepository { db: &self.db }; let users = user_repo.list_users().await.unwrap_or_default(); for user in users { - if let Some(location) = loc_repo.list_locations().await.unwrap_or_default().into_iter().find(|l| l.user_id == user.id) { - self.check_user_weather(user.id, location.latitude, location.longitude).await; + if let Some(location) = loc_repo + .list_locations() + .await + .unwrap_or_default() + .into_iter() + .find(|l| l.user_id == user.id) + { + self.check_user_weather(user.id, location.latitude, location.longitude) + .await; } } } @@ -33,16 +40,27 @@ impl WeatherPoller { pub async fn check_user_weather(&self, user_id: i64, lat: f64, lon: f64) { if let Ok(Some(forecast)) = self.fetch_forecast(lat, lon).await { let threshold_repo = WeatherThresholdRepository { db: &self.db }; - let thresholds = threshold_repo.list_thresholds(user_id).await.unwrap_or_default().into_iter().filter(|t| t.enabled).collect::<Vec<_>>(); + let thresholds = threshold_repo + .list_thresholds(user_id) + .await + .unwrap_or_default() + .into_iter() + .filter(|t| t.enabled) + .collect::<Vec<_>>(); if let Some(entry) = find_first_alert_entry(&forecast, &thresholds) { self.send_notifications(user_id, &entry).await; } } } - pub async fn fetch_forecast(&self, lat: f64, lon: f64) -> Result<Option<Vec<Value>>, reqwest::Error> { + pub async fn fetch_forecast( + &self, + lat: f64, + lon: f64, + ) -> Result<Option<Vec<Value>>, reqwest::Error> { let client = Client::new(); - let resp = client.get(OWM_API_URL) + let resp = client + .get(OWM_API_URL) .query(&[ ("lat", lat.to_string()), ("lon", lon.to_string()), @@ -73,7 +91,7 @@ impl WeatherPoller { } fn find_first_alert_entry(forecast: &[Value], thresholds: &[WeatherThreshold]) -> Option<Value> { - use chrono::{Utc, TimeZone}; + use chrono::{TimeZone, Utc}; let now = Utc::now(); for entry in forecast { if let Some(ts) = entry["dt"].as_i64() { @@ -91,9 +109,21 @@ fn find_first_alert_entry(forecast: &[Value], thresholds: &[WeatherThreshold]) - fn threshold_triggered(threshold: &WeatherThreshold, entry: &Value) -> bool { let value = match threshold.condition_type.as_str() { - "wind_speed" => entry.pointer("/wind/speed").and_then(|v| v.as_f64()).unwrap_or(0.0) * 3.6, - "rain" => entry.pointer("/rain/3h").and_then(|v| v.as_f64()).unwrap_or(0.0), - "temp_min" | "temp_max" => entry.pointer("/main/temp").and_then(|v| v.as_f64()).unwrap_or(0.0), + "wind_speed" => { + entry + .pointer("/wind/speed") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + * 3.6 + } + "rain" => entry + .pointer("/rain/3h") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + "temp_min" | "temp_max" => entry + .pointer("/main/temp") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), _ => return false, }; compare(value, &threshold.operator, threshold.threshold_value) @@ -105,28 +135,35 @@ fn compare(value: f64, op: &str, threshold: f64) -> bool { ">=" => value >= threshold, "<" => value < threshold, "<=" => value <= threshold, - "==" => (value - threshold).abs() < std::f64::EPSILON, + "==" => (value - threshold).abs() < f64::EPSILON, _ => false, } } -async fn send_ntfy_notification(ntfy: &NtfySettings, weather_entry: &Value, tera: Arc<Mutex<Tera>>) { +async fn send_ntfy_notification( + ntfy: &NtfySettings, + weather_entry: &Value, + tera: Arc<Mutex<Tera>>, +) { let mut ctx = Context::new(); add_weather_context(&mut ctx, weather_entry); let title = if let Some(tpl) = &ntfy.title_template { let mut tera = tera.lock().unwrap(); - tera.render_str(tpl, &ctx).unwrap_or_else(|_| "🚨 Weather Alert".to_string()) + tera.render_str(tpl, &ctx) + .unwrap_or_else(|_| "🚨 Weather Alert".to_string()) } else { "🚨 Weather Alert".to_string() }; let message = if let Some(tpl) = &ntfy.message_template { let mut tera = tera.lock().unwrap(); - tera.render_str(tpl, &ctx).unwrap_or_else(|_| "🚨 Weather alert for your location".to_string()) + tera.render_str(tpl, &ctx) + .unwrap_or_else(|_| "🚨 Weather alert for your location".to_string()) } else { default_weather_message(weather_entry) }; let client = Client::new(); - let _ = client.post(&format!("{}/{}", ntfy.server_url, ntfy.topic)) + let _ = client + .post(&format!("{}/{}", ntfy.server_url, ntfy.topic)) .header("Priority", ntfy.priority.to_string()) .header("Title", title) .body(message) @@ -134,38 +171,56 @@ async fn send_ntfy_notification(ntfy: &NtfySettings, weather_entry: &Value, tera .await; } -async fn send_smtp_notification(smtp: &SmtpSettings, weather_entry: &Value, tera: Arc<Mutex<Tera>>) { +async fn send_smtp_notification( + smtp: &SmtpSettings, + weather_entry: &Value, + tera: Arc<Mutex<Tera>>, +) { use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; let mut ctx = Context::new(); add_weather_context(&mut ctx, weather_entry); let subject = if let Some(tpl) = &smtp.subject_template { let mut tera = tera.lock().unwrap(); - tera.render_str(tpl, &ctx).unwrap_or_else(|_| "⚠️ Weather Alert for Your Location".to_string()) + tera.render_str(tpl, &ctx) + .unwrap_or_else(|_| "⚠️ Weather Alert for Your Location".to_string()) } else { "⚠️ Weather Alert for Your Location".to_string() }; let body = if let Some(tpl) = &smtp.body_template { let mut tera = tera.lock().unwrap(); - tera.render_str(tpl, &ctx).unwrap_or_else(|_| default_weather_message(weather_entry)) + tera.render_str(tpl, &ctx) + .unwrap_or_else(|_| default_weather_message(weather_entry)) } else { default_weather_message(weather_entry) }; - let from = smtp.from_email.clone().unwrap_or_else(|| smtp.email.clone()); - let from_name = smtp.from_name.clone().unwrap_or_else(|| "Silmätaivas Alerts".to_string()); + let from = smtp + .from_email + .clone() + .unwrap_or_else(|| smtp.email.clone()); + let from_name = smtp + .from_name + .clone() + .unwrap_or_else(|| "Silmätaivas Alerts".to_string()); let email = Message::builder() .from(format!("{} <{}>", from_name, from).parse().unwrap()) .to(smtp.email.parse().unwrap()) .subject(subject) .body(body) .unwrap(); - let creds = smtp.username.as_ref().and_then(|u| smtp.password.as_ref().map(|p| Credentials::new(u.clone(), p.clone()))); + let creds = smtp.username.as_ref().and_then(|u| { + smtp.password + .as_ref() + .map(|p| Credentials::new(u.clone(), p.clone())) + }); let mailer = if let Some(creds) = creds { - SmtpTransport::relay(&smtp.smtp_server).unwrap() + SmtpTransport::relay(&smtp.smtp_server) + .unwrap() .port(smtp.smtp_port as u16) .credentials(creds) .build() } else { - SmtpTransport::relay(&smtp.smtp_server).unwrap() + SmtpTransport::relay(&smtp.smtp_server) + .unwrap() .port(smtp.smtp_port as u16) .build() }; @@ -173,20 +228,64 @@ async fn send_smtp_notification(smtp: &SmtpSettings, weather_entry: &Value, tera } fn add_weather_context(ctx: &mut Context, entry: &Value) { - ctx.insert("temp", &entry.pointer("/main/temp").and_then(|v| v.as_f64()).unwrap_or(0.0)); - ctx.insert("wind_speed", &(entry.pointer("/wind/speed").and_then(|v| v.as_f64()).unwrap_or(0.0) * 3.6)); - ctx.insert("rain", &entry.pointer("/rain/3h").and_then(|v| v.as_f64()).unwrap_or(0.0)); + ctx.insert( + "temp", + &entry + .pointer("/main/temp") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + ); + ctx.insert( + "wind_speed", + &(entry + .pointer("/wind/speed") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + * 3.6), + ); + ctx.insert( + "rain", + &entry + .pointer("/rain/3h") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + ); ctx.insert("time", &entry["dt_txt"].as_str().unwrap_or("N/A")); - ctx.insert("humidity", &entry.pointer("/main/humidity").and_then(|v| v.as_f64()).unwrap_or(0.0)); - ctx.insert("pressure", &entry.pointer("/main/pressure").and_then(|v| v.as_f64()).unwrap_or(0.0)); + ctx.insert( + "humidity", + &entry + .pointer("/main/humidity") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + ); + ctx.insert( + "pressure", + &entry + .pointer("/main/pressure") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0), + ); } fn default_weather_message(entry: &Value) -> String { - let temp = entry.pointer("/main/temp").and_then(|v| v.as_f64()).unwrap_or(0.0); - let wind = entry.pointer("/wind/speed").and_then(|v| v.as_f64()).unwrap_or(0.0) * 3.6; - let rain = entry.pointer("/rain/3h").and_then(|v| v.as_f64()).unwrap_or(0.0); + let temp = entry + .pointer("/main/temp") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let wind = entry + .pointer("/wind/speed") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0) + * 3.6; + let rain = entry + .pointer("/rain/3h") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); let time = entry["dt_txt"].as_str().unwrap_or("N/A"); - format!("🚨 Weather alert for your location ({}):\n\n🌬️ Wind: {:.1} km/h\n🌧️ Rain: {:.1} mm\n🌡️ Temperature: {:.1} °C\n\nStay safe,\n— Silmätaivas", time, wind, rain, temp) + format!( + "🚨 Weather alert for your location ({}):\n\n🌬️ Wind: {:.1} km/h\n🌧️ Rain: {:.1} mm\n🌡️ Temperature: {:.1} °C\n\nStay safe,\n— Silmätaivas", + time, wind, rain, temp + ) } -// Unit tests for threshold logic and template rendering can be added here.
\ No newline at end of file +// Unit tests for threshold logic and template rendering can be added here. diff --git a/src/weather_thresholds.rs b/src/weather_thresholds.rs index bfb9cdf..dadfeda 100644 --- a/src/weather_thresholds.rs +++ b/src/weather_thresholds.rs @@ -1,5 +1,12 @@ +use crate::auth::AuthUser; +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; use serde::{Deserialize, Serialize}; use sqlx::FromRow; +use std::collections::HashMap; +use std::sync::Arc; #[derive(Debug, Serialize, Deserialize, FromRow, Clone, PartialEq)] pub struct WeatherThreshold { @@ -17,7 +24,10 @@ pub struct WeatherThresholdRepository<'a> { } impl<'a> WeatherThresholdRepository<'a> { - pub async fn list_thresholds(&self, user_id: i64) -> Result<Vec<WeatherThreshold>, sqlx::Error> { + pub async fn list_thresholds( + &self, + user_id: i64, + ) -> Result<Vec<WeatherThreshold>, sqlx::Error> { sqlx::query_as::<_, WeatherThreshold>( "SELECT id, user_id, condition_type, threshold_value, operator, enabled, description FROM weather_thresholds WHERE user_id = ?" ) @@ -26,7 +36,11 @@ impl<'a> WeatherThresholdRepository<'a> { .await } - pub async fn get_threshold(&self, id: i64, user_id: i64) -> Result<Option<WeatherThreshold>, sqlx::Error> { + pub async fn get_threshold( + &self, + id: i64, + user_id: i64, + ) -> Result<Option<WeatherThreshold>, sqlx::Error> { sqlx::query_as::<_, WeatherThreshold>( "SELECT id, user_id, condition_type, threshold_value, operator, enabled, description FROM weather_thresholds WHERE id = ? AND user_id = ?" ) @@ -36,7 +50,15 @@ impl<'a> WeatherThresholdRepository<'a> { .await } - pub async fn create_threshold(&self, user_id: i64, condition_type: String, threshold_value: f64, operator: String, enabled: bool, description: Option<String>) -> Result<WeatherThreshold, sqlx::Error> { + pub async fn create_threshold( + &self, + user_id: i64, + condition_type: String, + threshold_value: f64, + operator: String, + enabled: bool, + description: Option<String>, + ) -> Result<WeatherThreshold, sqlx::Error> { sqlx::query_as::<_, WeatherThreshold>( "INSERT INTO weather_thresholds (user_id, condition_type, threshold_value, operator, enabled, description) VALUES (?, ?, ?, ?, ?, ?) RETURNING id, user_id, condition_type, threshold_value, operator, enabled, description" ) @@ -50,7 +72,16 @@ impl<'a> WeatherThresholdRepository<'a> { .await } - pub async fn update_threshold(&self, id: i64, user_id: i64, condition_type: String, threshold_value: f64, operator: String, enabled: bool, description: Option<String>) -> Result<WeatherThreshold, sqlx::Error> { + pub async fn update_threshold( + &self, + id: i64, + user_id: i64, + condition_type: String, + threshold_value: f64, + operator: String, + enabled: bool, + description: Option<String>, + ) -> Result<WeatherThreshold, sqlx::Error> { sqlx::query_as::<_, WeatherThreshold>( "UPDATE weather_thresholds SET condition_type = ?, threshold_value = ?, operator = ?, enabled = ?, description = ? WHERE id = ? AND user_id = ? RETURNING id, user_id, condition_type, threshold_value, operator, enabled, description" ) @@ -75,11 +106,35 @@ impl<'a> WeatherThresholdRepository<'a> { } } +pub async fn list_thresholds( + AuthUser(_): AuthUser, + State(pool): State<Arc<sqlx::SqlitePool>>, + Query(query): Query<HashMap<String, String>>, +) -> impl IntoResponse { + let repo = WeatherThresholdRepository { db: &pool }; + let user_id = match query.get("user_id").and_then(|s| s.parse().ok()) { + Some(uid) => uid, + None => { + return crate::error_response( + StatusCode::BAD_REQUEST, + "user_id required as query param", + ); + } + }; + match repo.list_thresholds(user_id).await { + Ok(thresholds) => Json(thresholds).into_response(), + Err(e) => crate::error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + #[cfg(test)] mod tests { use super::*; use crate::users::{UserRepository, UserRole}; - use sqlx::{SqlitePool, Executor}; + use axum::extract::{Json, State}; + use hyper::StatusCode; + use sqlx::{Executor, SqlitePool}; + use std::sync::Arc; use tokio; async fn setup_db() -> SqlitePool { @@ -89,8 +144,10 @@ mod tests { id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user' - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool.execute( "CREATE TABLE weather_thresholds ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -101,8 +158,10 @@ mod tests { enabled BOOLEAN NOT NULL DEFAULT 1, description TEXT, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE - );" - ).await.unwrap(); + );", + ) + .await + .unwrap(); pool } @@ -117,12 +176,21 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = WeatherThresholdRepository { db: &db }; - let th = repo.create_threshold(user_id, "wind_speed".to_string(), 10.0, ">".to_string(), true, Some("desc".to_string())).await.unwrap(); + let th = repo + .create_threshold( + user_id, + "wind_speed".to_string(), + 10.0, + ">".to_string(), + true, + Some("desc".to_string()), + ) + .await + .unwrap(); let fetched = repo.get_threshold(th.id, user_id).await.unwrap().unwrap(); assert_eq!(fetched.condition_type, "wind_speed"); assert_eq!(fetched.threshold_value, 10.0); - assert_eq!(fetched.operator, ">" - ); + assert_eq!(fetched.operator, ">"); assert_eq!(fetched.enabled, true); assert_eq!(fetched.description, Some("desc".to_string())); } @@ -132,8 +200,29 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = WeatherThresholdRepository { db: &db }; - let th = repo.create_threshold(user_id, "wind_speed".to_string(), 10.0, ">".to_string(), true, None).await.unwrap(); - let updated = repo.update_threshold(th.id, user_id, "rain".to_string(), 5.0, "<".to_string(), false, Some("rain desc".to_string())).await.unwrap(); + let th = repo + .create_threshold( + user_id, + "wind_speed".to_string(), + 10.0, + ">".to_string(), + true, + None, + ) + .await + .unwrap(); + let updated = repo + .update_threshold( + th.id, + user_id, + "rain".to_string(), + 5.0, + "<".to_string(), + false, + Some("rain desc".to_string()), + ) + .await + .unwrap(); assert_eq!(updated.condition_type, "rain"); assert_eq!(updated.threshold_value, 5.0); assert_eq!(updated.operator, "<"); @@ -146,7 +235,17 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = WeatherThresholdRepository { db: &db }; - let th = repo.create_threshold(user_id, "wind_speed".to_string(), 10.0, ">".to_string(), true, None).await.unwrap(); + let th = repo + .create_threshold( + user_id, + "wind_speed".to_string(), + 10.0, + ">".to_string(), + true, + None, + ) + .await + .unwrap(); repo.delete_threshold(th.id, user_id).await.unwrap(); let fetched = repo.get_threshold(th.id, user_id).await.unwrap(); assert!(fetched.is_none()); @@ -157,9 +256,27 @@ mod tests { let db = setup_db().await; let user_id = create_user(&db).await; let repo = WeatherThresholdRepository { db: &db }; - repo.create_threshold(user_id, "wind_speed".to_string(), 10.0, ">".to_string(), true, None).await.unwrap(); - repo.create_threshold(user_id, "rain".to_string(), 5.0, "<".to_string(), false, None).await.unwrap(); + repo.create_threshold( + user_id, + "wind_speed".to_string(), + 10.0, + ">".to_string(), + true, + None, + ) + .await + .unwrap(); + repo.create_threshold( + user_id, + "rain".to_string(), + 5.0, + "<".to_string(), + false, + None, + ) + .await + .unwrap(); let thresholds = repo.list_thresholds(user_id).await.unwrap(); assert_eq!(thresholds.len(), 2); } -}
\ No newline at end of file +} |
