learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use crate::middleware::auth::UserContext;
2use crate::repository::review::ReviewRepoError;
3use crate::state::SharedState;
4
5use axum::{
6 Json,
7 extract::{Extension, Query, State},
8 http::StatusCode,
9 response::IntoResponse,
10};
11use malfestio_core::srs::Grade;
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14
15#[derive(Deserialize)]
16pub struct DueCardsQuery {
17 deck_id: Option<String>,
18 #[serde(default = "default_limit")]
19 limit: i64,
20}
21
22fn default_limit() -> i64 {
23 20
24}
25
26#[derive(Deserialize)]
27pub struct SubmitReviewRequest {
28 card_id: String,
29 grade: u8,
30}
31
32#[derive(Serialize)]
33pub struct SubmitReviewResponse {
34 ease_factor: f32,
35 interval_days: i32,
36 repetitions: i32,
37 due_at: String,
38}
39
40/// GET /api/review/due - Get cards due for review
41pub async fn get_due_cards(
42 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<DueCardsQuery>,
43) -> impl IntoResponse {
44 let user = match ctx {
45 Some(Extension(user)) => user,
46 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
47 };
48
49 let result = state
50 .review_repo
51 .get_due_cards(&user.did, query.deck_id.as_deref(), query.limit)
52 .await;
53
54 match result {
55 Ok(cards) => Json(cards).into_response(),
56 Err(ReviewRepoError::InvalidArgument(msg)) => {
57 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
58 }
59 Err(e) => {
60 tracing::error!("Failed to get due cards: {:?}", e);
61 (
62 StatusCode::INTERNAL_SERVER_ERROR,
63 Json(json!({"error": "Failed to get due cards"})),
64 )
65 .into_response()
66 }
67 }
68}
69
70/// POST /api/review/submit - Submit a review grade
71pub async fn submit_review(
72 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<SubmitReviewRequest>,
73) -> impl IntoResponse {
74 let user = match ctx {
75 Some(Extension(user)) => user,
76 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
77 };
78
79 let grade = match Grade::new(payload.grade) {
80 Some(g) => g,
81 None => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Grade must be 0-5"}))).into_response(),
82 };
83
84 let result = state
85 .review_repo
86 .submit_review(&user.did, &payload.card_id, grade)
87 .await;
88
89 match result {
90 Ok(new_state) => Json(SubmitReviewResponse {
91 ease_factor: new_state.ease_factor,
92 interval_days: new_state.interval_days,
93 repetitions: new_state.repetitions,
94 due_at: new_state.due_at.to_rfc3339(),
95 })
96 .into_response(),
97 Err(ReviewRepoError::InvalidArgument(msg)) => {
98 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
99 }
100 Err(e) => {
101 tracing::error!("Failed to submit review: {:?}", e);
102 (
103 StatusCode::INTERNAL_SERVER_ERROR,
104 Json(json!({"error": "Failed to submit review"})),
105 )
106 .into_response()
107 }
108 }
109}
110
111/// GET /api/review/stats - Get user study statistics
112pub async fn get_stats(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse {
113 let user = match ctx {
114 Some(Extension(user)) => user,
115 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
116 };
117
118 let result = state.review_repo.get_stats(&user.did).await;
119
120 match result {
121 Ok(stats) => Json(stats).into_response(),
122 Err(e) => {
123 tracing::error!("Failed to get stats: {:?}", e);
124 (
125 StatusCode::INTERNAL_SERVER_ERROR,
126 Json(json!({"error": "Failed to get stats"})),
127 )
128 .into_response()
129 }
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use crate::repository::card::mock::MockCardRepository;
137 use crate::repository::note::mock::MockNoteRepository;
138 use crate::repository::oauth::mock::MockOAuthRepository;
139 use crate::repository::review::mock::MockReviewRepository;
140 use crate::repository::review::{ReviewCard, ReviewRepository};
141 use crate::state::AppState;
142 use chrono::Utc;
143 use std::sync::Arc;
144
145 fn create_test_state_with_review(review_repo: Arc<dyn ReviewRepository>) -> SharedState {
146 let pool = crate::db::create_mock_pool();
147 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
148 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
149 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
150 let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new())
151 as Arc<dyn crate::repository::preferences::PreferencesRepository>;
152 let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new())
153 as Arc<dyn crate::repository::social::SocialRepository>;
154
155 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new())
156 as Arc<dyn crate::repository::deck::DeckRepository>;
157 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() };
158
159 let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new())
160 as Arc<dyn crate::repository::search::SearchRepository>;
161
162 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new())
163 as Arc<dyn crate::repository::sync::SyncRepository>;
164
165 let repos = crate::state::Repositories {
166 card: card_repo,
167 note: note_repo,
168 oauth: oauth_repo,
169 prefs: preferences_repo,
170 review: review_repo,
171 social: social_repo,
172 deck: deck_repo,
173 search: search_repo,
174 sync: sync_repo,
175 };
176
177 AppState::new(pool, repos, config)
178 }
179
180 #[tokio::test]
181 async fn test_get_due_cards_unauthorized() {
182 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
183 let state = create_test_state_with_review(review_repo);
184
185 let response = get_due_cards(State(state), None, Query(DueCardsQuery { deck_id: None, limit: 20 }))
186 .await
187 .into_response();
188
189 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
190 }
191
192 #[tokio::test]
193 async fn test_get_due_cards_success() {
194 let cards = vec![ReviewCard {
195 review_id: "review-1".to_string(),
196 card_id: "card-1".to_string(),
197 deck_id: "deck-1".to_string(),
198 deck_title: "Test Deck".to_string(),
199 front: "What is 2+2?".to_string(),
200 back: "4".to_string(),
201 media_url: None,
202 hints: vec![],
203 due_at: Utc::now(),
204 }];
205 let review_repo = Arc::new(MockReviewRepository::with_cards(cards)) as Arc<dyn ReviewRepository>;
206 let state = create_test_state_with_review(review_repo);
207
208 let user = UserContext {
209 did: "did:plc:test".to_string(),
210 handle: "test.handle".to_string(),
211 access_token: "test_token".to_string(),
212 pds_url: "https://bsky.social".to_string(),
213 has_dpop: false,
214 };
215 let response = get_due_cards(
216 State(state),
217 Some(Extension(user)),
218 Query(DueCardsQuery { deck_id: None, limit: 20 }),
219 )
220 .await
221 .into_response();
222
223 assert_eq!(response.status(), StatusCode::OK);
224 }
225
226 #[tokio::test]
227 async fn test_submit_review_success() {
228 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
229 let state = create_test_state_with_review(review_repo);
230
231 let user = UserContext {
232 did: "did:plc:test".to_string(),
233 handle: "test.handle".to_string(),
234 access_token: "test_token".to_string(),
235 pds_url: "https://bsky.social".to_string(),
236 has_dpop: false,
237 };
238 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 3 };
239
240 let response = submit_review(State(state), Some(Extension(user)), Json(payload))
241 .await
242 .into_response();
243
244 assert_eq!(response.status(), StatusCode::OK);
245 }
246
247 #[tokio::test]
248 async fn test_submit_review_invalid_grade() {
249 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
250 let state = create_test_state_with_review(review_repo);
251
252 let user = UserContext {
253 did: "did:plc:test".to_string(),
254 handle: "test.handle".to_string(),
255 access_token: "test_token".to_string(),
256 pds_url: "https://bsky.social".to_string(),
257 has_dpop: false,
258 };
259 let payload = SubmitReviewRequest { card_id: "card-1".to_string(), grade: 10 };
260
261 let response = submit_review(State(state), Some(Extension(user)), Json(payload))
262 .await
263 .into_response();
264
265 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
266 }
267
268 #[tokio::test]
269 async fn test_get_stats_success() {
270 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
271 let state = create_test_state_with_review(review_repo);
272
273 let user = UserContext {
274 did: "did:plc:test".to_string(),
275 handle: "test.handle".to_string(),
276 access_token: "test_token".to_string(),
277 pds_url: "https://bsky.social".to_string(),
278 has_dpop: false,
279 };
280 let response = get_stats(State(state), Some(Extension(user))).await.into_response();
281
282 assert_eq!(response.status(), StatusCode::OK);
283 }
284}