this repo has no description
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(¤t_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}