this repo has no description
at main 169 lines 5.7 kB view raw
1mod auth; 2mod config; 3mod db; 4mod errors; 5mod models; 6mod routes; 7mod webpush; 8 9use axum::routing::{get, post, put}; 10use axum::Router; 11use tower_http::cors::{Any, CorsLayer}; 12use tower_http::services::{ServeDir, ServeFile}; 13 14#[tokio::main] 15async fn main() { 16 // Load .env file (silently ignore if missing). 17 let _ = dotenvy::dotenv(); 18 19 let database_url = config::database_url(); 20 let pool = db::init_pool(&database_url).await; 21 db::run_migrations(&pool).await; 22 23 let (vapid_pem, vapid_pub) = webpush::init_vapid_keys(&pool).await; 24 let state = config::AppState::new(pool, config::jwt_secret(), vapid_pem, vapid_pub); 25 let port = config::port(); 26 27 // Spawn background notification scheduler 28 tokio::spawn(notification_scheduler(state.clone())); 29 30 let cors = CorsLayer::new() 31 .allow_origin(Any) 32 .allow_methods([ 33 axum::http::Method::GET, 34 axum::http::Method::POST, 35 axum::http::Method::PUT, 36 axum::http::Method::DELETE, 37 ]) 38 .allow_headers([ 39 axum::http::header::AUTHORIZATION, 40 axum::http::header::CONTENT_TYPE, 41 ]); 42 43 let mut app = Router::new() 44 .route("/api/register", post(routes::auth::register)) 45 .route("/api/login", post(routes::auth::login)) 46 .route("/api/me", get(routes::auth::me)) 47 .route( 48 "/api/progress", 49 get(routes::progress::get_progress).put(routes::progress::update_progress), 50 ) 51 .route( 52 "/api/lesson-state", 53 put(routes::lesson_state::save_lesson_state), 54 ) 55 .route( 56 "/api/lesson-state/{topic_id}/{lesson_id}", 57 get(routes::lesson_state::get_lesson_state) 58 .delete(routes::lesson_state::delete_lesson_state), 59 ) 60 .route("/api/tts", get(routes::tts::synthesize)) 61 .route("/api/push/vapid-key", get(routes::push::vapid_key)) 62 .route( 63 "/api/push/subscribe", 64 post(routes::push::subscribe).delete(routes::push::unsubscribe), 65 ) 66 .route( 67 "/api/push/preferences", 68 get(routes::push::get_preferences).put(routes::push::update_preferences), 69 ) 70 .layer(cors) 71 .with_state(state); 72 73 // In production, serve the frontend build as static files with SPA fallback 74 if let Some(static_dir) = config::static_dir() { 75 let index = format!("{}/index.html", static_dir); 76 app = app.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index))); 77 println!("Serving static files from {static_dir}"); 78 } 79 80 let addr = format!("0.0.0.0:{port}"); 81 let listener = tokio::net::TcpListener::bind(&addr) 82 .await 83 .expect("Failed to bind"); 84 85 println!("Ayos API listening on http://{addr}"); 86 87 axum::serve(listener, app).await.expect("Server error"); 88} 89 90async fn notification_scheduler(state: config::AppState) { 91 let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); 92 loop { 93 interval.tick().await; 94 95 let now = chrono::Utc::now(); 96 let current_time = now.format("%H:%M").to_string(); 97 let today = now.format("%Y-%m-%d").to_string(); 98 99 // Find users who should receive a reminder now (haven't been notified today) 100 let users: Vec<(String,)> = match sqlx::query_as( 101 "SELECT user_id FROM user_stats 102 WHERE reminder_enabled = 1 AND reminder_time = ? 103 AND (last_notified_date IS NULL OR last_notified_date != ?)", 104 ) 105 .bind(&current_time) 106 .bind(&today) 107 .fetch_all(&state.db) 108 .await 109 { 110 Ok(users) => users, 111 Err(e) => { 112 eprintln!("Notification scheduler error: {e}"); 113 continue; 114 } 115 }; 116 117 for (user_id,) in &users { 118 let subs: Vec<(String, String, String)> = match sqlx::query_as( 119 "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?", 120 ) 121 .bind(user_id) 122 .fetch_all(&state.db) 123 .await 124 { 125 Ok(s) => s, 126 Err(_) => continue, 127 }; 128 129 let payload = serde_json::json!({ 130 "title": "Time to practice Tagalog!", 131 "body": "Keep your streak going!", 132 "data": { "url": "/home" } 133 }); 134 let payload_bytes = payload.to_string().into_bytes(); 135 136 for (endpoint, p256dh, auth) in &subs { 137 match webpush::send_push( 138 &state.vapid_private_key_pem, 139 &state.vapid_public_key, 140 endpoint, 141 p256dh, 142 auth, 143 &payload_bytes, 144 ) 145 .await 146 { 147 Ok(()) => {} 148 Err(errors::AppError::NotFound(_)) => { 149 // Subscription expired, clean up 150 let _ = sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?") 151 .bind(endpoint) 152 .execute(&state.db) 153 .await; 154 } 155 Err(e) => { 156 eprintln!("Failed to send push to {user_id}: {e}"); 157 } 158 } 159 } 160 161 // Mark as notified today 162 let _ = sqlx::query("UPDATE user_stats SET last_notified_date = ? WHERE user_id = ?") 163 .bind(&today) 164 .bind(user_id) 165 .execute(&state.db) 166 .await; 167 } 168 } 169}