mod auth; mod config; mod db; mod errors; mod models; mod routes; mod webpush; use axum::routing::{get, post, put}; use axum::Router; use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; #[tokio::main] async fn main() { // Load .env file (silently ignore if missing). let _ = dotenvy::dotenv(); let database_url = config::database_url(); let pool = db::init_pool(&database_url).await; db::run_migrations(&pool).await; let (vapid_pem, vapid_pub) = webpush::init_vapid_keys(&pool).await; let state = config::AppState::new(pool, config::jwt_secret(), vapid_pem, vapid_pub); let port = config::port(); // Spawn background notification scheduler tokio::spawn(notification_scheduler(state.clone())); let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([ axum::http::Method::GET, axum::http::Method::POST, axum::http::Method::PUT, axum::http::Method::DELETE, ]) .allow_headers([ axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE, ]); let mut app = Router::new() .route("/api/register", post(routes::auth::register)) .route("/api/login", post(routes::auth::login)) .route("/api/me", get(routes::auth::me)) .route( "/api/progress", get(routes::progress::get_progress).put(routes::progress::update_progress), ) .route( "/api/lesson-state", put(routes::lesson_state::save_lesson_state), ) .route( "/api/lesson-state/{topic_id}/{lesson_id}", get(routes::lesson_state::get_lesson_state) .delete(routes::lesson_state::delete_lesson_state), ) .route("/api/tts", get(routes::tts::synthesize)) .route("/api/push/vapid-key", get(routes::push::vapid_key)) .route( "/api/push/subscribe", post(routes::push::subscribe).delete(routes::push::unsubscribe), ) .route( "/api/push/preferences", get(routes::push::get_preferences).put(routes::push::update_preferences), ) .layer(cors) .with_state(state); // In production, serve the frontend build as static files with SPA fallback if let Some(static_dir) = config::static_dir() { let index = format!("{}/index.html", static_dir); app = app.fallback_service(ServeDir::new(&static_dir).fallback(ServeFile::new(index))); println!("Serving static files from {static_dir}"); } let addr = format!("0.0.0.0:{port}"); let listener = tokio::net::TcpListener::bind(&addr) .await .expect("Failed to bind"); println!("Ayos API listening on http://{addr}"); axum::serve(listener, app).await.expect("Server error"); } async fn notification_scheduler(state: config::AppState) { let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); loop { interval.tick().await; let now = chrono::Utc::now(); let current_time = now.format("%H:%M").to_string(); let today = now.format("%Y-%m-%d").to_string(); // Find users who should receive a reminder now (haven't been notified today) let users: Vec<(String,)> = match sqlx::query_as( "SELECT user_id FROM user_stats WHERE reminder_enabled = 1 AND reminder_time = ? AND (last_notified_date IS NULL OR last_notified_date != ?)", ) .bind(¤t_time) .bind(&today) .fetch_all(&state.db) .await { Ok(users) => users, Err(e) => { eprintln!("Notification scheduler error: {e}"); continue; } }; for (user_id,) in &users { let subs: Vec<(String, String, String)> = match sqlx::query_as( "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?", ) .bind(user_id) .fetch_all(&state.db) .await { Ok(s) => s, Err(_) => continue, }; let payload = serde_json::json!({ "title": "Time to practice Tagalog!", "body": "Keep your streak going!", "data": { "url": "/home" } }); let payload_bytes = payload.to_string().into_bytes(); for (endpoint, p256dh, auth) in &subs { match webpush::send_push( &state.vapid_private_key_pem, &state.vapid_public_key, endpoint, p256dh, auth, &payload_bytes, ) .await { Ok(()) => {} Err(errors::AppError::NotFound(_)) => { // Subscription expired, clean up let _ = sqlx::query("DELETE FROM push_subscriptions WHERE endpoint = ?") .bind(endpoint) .execute(&state.db) .await; } Err(e) => { eprintln!("Failed to send push to {user_id}: {e}"); } } } // Mark as notified today let _ = sqlx::query("UPDATE user_stats SET last_notified_date = ? WHERE user_id = ?") .bind(&today) .bind(user_id) .execute(&state.db) .await; } } }