summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml1
-rw-r--r--README.md49
-rw-r--r--lefthook.yml7
-rw-r--r--src/auth.rs33
-rw-r--r--src/health.rs4
-rw-r--r--src/lib.rs540
-rw-r--r--src/locations.rs40
-rw-r--r--src/notifications.rs220
-rw-r--r--src/users.rs35
-rw-r--r--src/weather_poller.rs177
-rw-r--r--src/weather_thresholds.rs153
12 files changed, 1134 insertions, 126 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 26fa8b5..8146c54 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2074,6 +2074,7 @@ name = "silmataivas"
version = "0.2.0"
dependencies = [
"anyhow",
+ "async-trait",
"axum",
"chrono",
"dotenv",
diff --git a/Cargo.toml b/Cargo.toml
index b7471d9..04c5cb8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index 8d47613..3910fc4 100644
--- a/README.md
+++ b/README.md
@@ -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
+}
diff --git a/src/lib.rs b/src/lib.rs
index f51483b..900b4dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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
+}