learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add hints to cards

* refactor deck API to use repository pattern

+786 -584
+1 -1
crates/core/src/error.rs
··· 8 8 #[error("Serialization error: {0}")] 9 9 Serialization(#[from] serde_json::Error), 10 10 11 - /// TODO: Replace with database error type 11 + /// Wraps external database errors (e.g. Postgres, SQLx, Tokio-Postgres) 12 12 #[error("Database error: {0}")] 13 13 Database(String), 14 14
+5 -3
crates/core/src/srs.rs
··· 75 75 pub fn schedule(&self, grade: Grade, config: &Sm2Config) -> Self { 76 76 let q = grade.0 as f32; 77 77 78 - // TODO: move to separate fn 79 - // EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) 80 - let new_ease = self.ease_factor + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02)); 78 + let new_ease = calculate_next_ease(self.ease_factor, q); 81 79 let new_ease = new_ease.max(config.min_ease); 82 80 83 81 if grade.is_passing() { ··· 100 98 Self { ease_factor: new_ease, interval_days: 0, repetitions: 0, due_at: Utc::now() + Duration::minutes(10) } 101 99 } 102 100 } 101 + } 102 + 103 + fn calculate_next_ease(current_ease: f32, quality: f32) -> f32 { 104 + current_ease + (0.1 - (5.0 - quality) * (0.08 + (5.0 - quality) * 0.02)) 103 105 } 104 106 105 107 #[cfg(test)]
+4 -3
crates/server/src/api/auth.rs
··· 1 - use axum::{Json, http::StatusCode, response::IntoResponse}; 1 + use crate::state::SharedState; 2 + use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 2 3 3 4 use serde::{Deserialize, Serialize}; 4 5 use serde_json::json; ··· 18 19 } 19 20 20 21 /// TODO: Make PDS URL configurable (bluesky users can use their own PDS) 21 - pub async fn login(Json(payload): Json<LoginRequest>) -> impl IntoResponse { 22 + pub async fn login(State(state): State<SharedState>, Json(payload): Json<LoginRequest>) -> impl IntoResponse { 22 23 let client = reqwest::Client::new(); 23 - let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 24 + let pds_url = &state.config.pds_url; 24 25 25 26 let resp = client 26 27 .post(format!("{}/xrpc/com.atproto.server.createSession", pds_url))
+92 -474
crates/server/src/api/deck.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 2 use crate::state::SharedState; 3 3 4 + use crate::repository::deck::{CreateDeckParams, DeckRepoError, UpdateDeckParams}; 4 5 use axum::{ 5 6 Json, 6 7 extract::{Extension, Path, State}, 7 8 http::StatusCode, 8 9 response::IntoResponse, 9 10 }; 10 - use malfestio_core::model::{Deck, Visibility}; 11 + use malfestio_core::model::Visibility; 11 12 use serde::Deserialize; 12 13 use serde_json::json; 13 14 ··· 24 25 pub published: bool, 25 26 } 26 27 28 + // TODO: add tests 27 29 pub async fn create_deck( 28 30 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Json(payload): Json<CreateDeckRequest>, 29 31 ) -> impl IntoResponse { ··· 32 34 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 33 35 }; 34 36 35 - let pool = &state.pool; 36 - let client = match pool.get().await { 37 - Ok(client) => client, 38 - Err(e) => { 39 - tracing::error!("Failed to get database connection: {}", e); 40 - return ( 41 - StatusCode::INTERNAL_SERVER_ERROR, 42 - Json(json!({"error": "Database connection failed"})), 43 - ) 44 - .into_response(); 45 - } 37 + let params = CreateDeckParams { 38 + owner_did: user.did, 39 + title: payload.title, 40 + description: payload.description, 41 + tags: payload.tags, 42 + visibility: payload.visibility, 46 43 }; 47 44 48 - let deck_id = uuid::Uuid::new_v4(); 49 - let visibility_json = match serde_json::to_value(&payload.visibility) { 50 - Ok(v) => v, 45 + match state.deck_repo.create(params).await { 46 + Ok(deck) => (StatusCode::CREATED, Json(deck)).into_response(), 51 47 Err(e) => { 52 - tracing::error!("Failed to serialize visibility: {}", e); 53 - return ( 48 + tracing::error!("Failed to create deck: {:?}", e); 49 + ( 54 50 StatusCode::INTERNAL_SERVER_ERROR, 55 - Json(json!({"error": "Failed to serialize visibility"})), 51 + Json(json!({"error": "Failed to create deck"})), 56 52 ) 57 - .into_response(); 53 + .into_response() 58 54 } 59 - }; 60 - 61 - let result = client 62 - .execute( 63 - "INSERT INTO decks (id, owner_did, title, description, tags, visibility) 64 - VALUES ($1, $2, $3, $4, $5, $6)", 65 - &[ 66 - &deck_id, 67 - &user.did, 68 - &payload.title, 69 - &payload.description, 70 - &payload.tags, 71 - &visibility_json, 72 - ], 73 - ) 74 - .await; 75 - 76 - if let Err(e) = result { 77 - tracing::error!("Failed to insert deck: {}", e); 78 - return ( 79 - StatusCode::INTERNAL_SERVER_ERROR, 80 - Json(json!({"error": "Failed to create deck"})), 81 - ) 82 - .into_response(); 83 55 } 84 - 85 - let new_deck = Deck { 86 - id: deck_id.to_string(), 87 - owner_did: user.did, 88 - title: payload.title, 89 - description: payload.description, 90 - tags: payload.tags, 91 - visibility: payload.visibility, 92 - published_at: None, 93 - fork_of: None, 94 - }; 95 - 96 - (StatusCode::CREATED, Json(new_deck)).into_response() 97 56 } 98 57 99 58 pub async fn list_decks( ··· 101 60 ) -> impl IntoResponse { 102 61 let user_did = ctx.map(|Extension(u)| u.did); 103 62 104 - let pool = &state.pool; 105 - let client = match pool.get().await { 106 - Ok(client) => client, 63 + match state.deck_repo.list_visible(user_did.as_deref()).await { 64 + Ok(decks) => Json(decks).into_response(), 107 65 Err(e) => { 108 - tracing::error!("Failed to get database connection: {}", e); 109 - return ( 66 + tracing::error!("Failed to list decks: {:?}", e); 67 + ( 110 68 StatusCode::INTERNAL_SERVER_ERROR, 111 - Json(json!({"error": "Database connection failed"})), 69 + Json(json!({"error": "Failed to list decks"})), 112 70 ) 113 - .into_response(); 71 + .into_response() 114 72 } 115 - }; 116 - 117 - let query = if let Some(ref _did) = user_did { 118 - "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 119 - FROM decks 120 - WHERE owner_did = $1 121 - OR visibility->>'type' = 'Public' 122 - OR visibility->>'type' = 'Unlisted' 123 - OR (visibility->>'type' = 'SharedWith' AND visibility->'content' ? $1) 124 - ORDER BY created_at DESC" 125 - } else { 126 - "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 127 - FROM decks 128 - WHERE visibility->>'type' IN ('Public', 'Unlisted') 129 - ORDER BY created_at DESC" 130 - }; 131 - 132 - let rows = if let Some(ref did) = user_did { 133 - client.query(query, &[did]).await 134 - } else { 135 - client.query(query, &[]).await 136 - }; 137 - 138 - let rows = match rows { 139 - Ok(rows) => rows, 140 - Err(e) => { 141 - tracing::error!("Failed to query decks: {}", e); 142 - return ( 143 - StatusCode::INTERNAL_SERVER_ERROR, 144 - Json(json!({"error": "Failed to retrieve decks"})), 145 - ) 146 - .into_response(); 147 - } 148 - }; 149 - 150 - let mut decks = Vec::new(); 151 - for row in rows { 152 - let visibility_json: serde_json::Value = row.get("visibility"); 153 - let visibility: Visibility = match serde_json::from_value(visibility_json) { 154 - Ok(v) => v, 155 - Err(e) => { 156 - tracing::error!("Failed to deserialize visibility: {}", e); 157 - continue; 158 - } 159 - }; 160 - 161 - let id: uuid::Uuid = row.get("id"); 162 - let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 163 - 164 - decks.push(Deck { 165 - id: id.to_string(), 166 - owner_did: row.get("owner_did"), 167 - title: row.get("title"), 168 - description: row.get("description"), 169 - tags: row.get("tags"), 170 - visibility, 171 - published_at: row 172 - .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 173 - .map(|dt| dt.to_rfc3339()), 174 - fork_of: fork_of.map(|u| u.to_string()), 175 - }); 176 73 } 177 - 178 - Json(decks).into_response() 179 74 } 180 75 181 76 pub async fn get_deck( 182 77 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 183 78 ) -> impl IntoResponse { 184 79 let user_did = ctx.map(|Extension(u)| u.did); 185 - let pool = &state.pool; 186 - let client = match pool.get().await { 187 - Ok(client) => client, 188 - Err(e) => { 189 - tracing::error!("Failed to get database connection: {}", e); 190 - return ( 191 - StatusCode::INTERNAL_SERVER_ERROR, 192 - Json(json!({"error": "Database connection failed"})), 193 - ) 194 - .into_response(); 195 - } 196 - }; 197 80 198 - let deck_id = match uuid::Uuid::parse_str(&id) { 199 - Ok(uuid) => uuid, 200 - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), 201 - }; 81 + match state.deck_repo.get(&id).await { 82 + Ok(deck) => { 83 + // Access control check 84 + let is_owner = user_did.as_ref() == Some(&deck.owner_did); 85 + let has_access = match &deck.visibility { 86 + Visibility::Public | Visibility::Unlisted => true, 87 + Visibility::Private => is_owner, 88 + Visibility::SharedWith(dids) => { 89 + is_owner || user_did.as_ref().map(|did| dids.contains(did)).unwrap_or(false) 90 + } 91 + }; 92 + 93 + if !has_access { 94 + return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response(); 95 + } 202 96 203 - let row = match client 204 - .query_opt( 205 - "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 206 - FROM decks WHERE id = $1", 207 - &[&deck_id], 208 - ) 209 - .await 210 - { 211 - Ok(Some(row)) => row, 212 - Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 97 + Json(deck).into_response() 98 + } 99 + Err(crate::repository::deck::DeckRepoError::NotFound(_)) => { 100 + (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 101 + } 213 102 Err(e) => { 214 - tracing::error!("Failed to query deck: {}", e); 215 - return ( 103 + tracing::error!("Failed to get deck: {:?}", e); 104 + ( 216 105 StatusCode::INTERNAL_SERVER_ERROR, 217 106 Json(json!({"error": "Failed to retrieve deck"})), 218 107 ) 219 - .into_response(); 108 + .into_response() 220 109 } 221 - }; 222 - 223 - let visibility_json: serde_json::Value = row.get("visibility"); 224 - let visibility: Visibility = match serde_json::from_value(visibility_json) { 225 - Ok(v) => v, 226 - Err(e) => { 227 - tracing::error!("Failed to deserialize visibility: {}", e); 228 - return ( 229 - StatusCode::INTERNAL_SERVER_ERROR, 230 - Json(json!({"error": "Failed to parse deck visibility"})), 231 - ) 232 - .into_response(); 233 - } 234 - }; 235 - 236 - let owner_did: String = row.get("owner_did"); 237 - let is_owner = user_did.as_ref() == Some(&owner_did); 238 - 239 - let has_access = match &visibility { 240 - Visibility::Public | Visibility::Unlisted => true, 241 - Visibility::Private => is_owner, 242 - Visibility::SharedWith(dids) => is_owner || user_did.as_ref().map(|did| dids.contains(did)).unwrap_or(false), 243 - }; 244 - 245 - if !has_access { 246 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response(); 247 110 } 248 - 249 - let uuid_id: uuid::Uuid = row.get("id"); 250 - let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 251 - 252 - let deck = Deck { 253 - id: uuid_id.to_string(), 254 - owner_did, 255 - title: row.get("title"), 256 - description: row.get("description"), 257 - tags: row.get("tags"), 258 - visibility, 259 - published_at: row 260 - .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 261 - .map(|dt| dt.to_rfc3339()), 262 - fork_of: fork_of.map(|u| u.to_string()), 263 - }; 264 - 265 - Json(deck).into_response() 266 111 } 267 112 268 113 pub async fn publish_deck( ··· 274 119 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 275 120 }; 276 121 277 - let pool = &state.pool; 278 - let client = match pool.get().await { 279 - Ok(client) => client, 280 - Err(e) => { 281 - tracing::error!("Failed to get database connection: {}", e); 282 - return ( 283 - StatusCode::INTERNAL_SERVER_ERROR, 284 - Json(json!({"error": "Database connection failed"})), 285 - ) 286 - .into_response(); 122 + let deck = match state.deck_repo.get(&id).await { 123 + Ok(d) => d, 124 + Err(DeckRepoError::NotFound(_)) => { 125 + return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(); 287 126 } 288 - }; 289 - 290 - let deck_id = match uuid::Uuid::parse_str(&id) { 291 - Ok(uuid) => uuid, 292 - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), 293 - }; 294 - 295 - let deck_row = match client 296 - .query_opt( 297 - "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of 298 - FROM decks WHERE id = $1", 299 - &[&deck_id], 300 - ) 301 - .await 302 - { 303 - Ok(Some(row)) => row, 304 - Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 305 127 Err(e) => { 306 - tracing::error!("Failed to query deck: {}", e); 128 + tracing::error!("Failed to get deck: {:?}", e); 307 129 return ( 308 130 StatusCode::INTERNAL_SERVER_ERROR, 309 - Json(json!({"error": "Database error"})), 131 + Json(json!({"error": "Failed to fetch deck"})), 310 132 ) 311 133 .into_response(); 312 134 } 313 135 }; 314 136 315 - let owner_did: String = deck_row.get("owner_did"); 316 - if owner_did != user.did { 137 + if deck.owner_did != user.did { 317 138 return (StatusCode::FORBIDDEN, Json(json!({"error": "Only owner can publish"}))).into_response(); 318 139 } 319 140 320 - let visibility_json: serde_json::Value = deck_row.get("visibility"); 321 - let visibility: Visibility = match serde_json::from_value(visibility_json) { 322 - Ok(v) => v, 323 - Err(e) => { 324 - tracing::error!("Failed to parse visibility: {}", e); 325 - return ( 326 - StatusCode::INTERNAL_SERVER_ERROR, 327 - Json(json!({"error": "Invalid deck data"})), 328 - ) 329 - .into_response(); 330 - } 331 - }; 332 - 333 - let fork_of: Option<uuid::Uuid> = deck_row.get("fork_of"); 334 - let mut deck = Deck { 335 - id: deck_id.to_string(), 336 - owner_did: owner_did.clone(), 337 - title: deck_row.get("title"), 338 - description: deck_row.get("description"), 339 - tags: deck_row.get("tags"), 340 - visibility: visibility.clone(), 341 - published_at: deck_row 342 - .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 343 - .map(|dt| dt.to_rfc3339()), 344 - fork_of: fork_of.map(|u| u.to_string()), 345 - }; 346 - 347 - let mut deck_at_uri: Option<String> = None; 141 + let mut updated_deck = deck.clone(); 142 + let mut deck_at_uri = None; 348 143 349 144 if payload.published { 350 - let card_rows = match client 351 - .query( 352 - "SELECT id, owner_did, deck_id, front, back, media_url 353 - FROM cards WHERE deck_id = $1 ORDER BY created_at ASC", 354 - &[&deck_id], 355 - ) 356 - .await 357 - { 358 - Ok(rows) => rows, 145 + let cards = match state.card_repo.list_by_deck(&id).await { 146 + Ok(c) => c, 359 147 Err(e) => { 360 - tracing::error!("Failed to fetch cards: {}", e); 148 + tracing::error!("Failed to fetch cards: {:?}", e); 361 149 return ( 362 150 StatusCode::INTERNAL_SERVER_ERROR, 363 151 Json(json!({"error": "Failed to fetch cards"})), ··· 366 154 } 367 155 }; 368 156 369 - let cards: Vec<malfestio_core::model::Card> = card_rows 370 - .iter() 371 - .map(|row| { 372 - let card_id: uuid::Uuid = row.get("id"); 373 - let card_deck_id: uuid::Uuid = row.get("deck_id"); 374 - malfestio_core::model::Card { 375 - id: card_id.to_string(), 376 - owner_did: row.get("owner_did"), 377 - deck_id: card_deck_id.to_string(), 378 - front: row.get("front"), 379 - back: row.get("back"), 380 - media_url: row.get("media_url"), 381 - card_type: malfestio_core::model::CardType::default(), 382 - hints: vec![], 383 - } 384 - }) 385 - .collect(); 386 - 387 157 match crate::pds::publish::publish_deck_to_pds(state.oauth_repo.clone(), &user.did, &deck, &cards).await { 388 158 Ok(result) => { 389 159 deck_at_uri = Some(result.deck_at_uri.clone()); 390 160 391 - if let Err(e) = client 392 - .execute( 393 - "UPDATE decks SET at_uri = $1, visibility = $2, published_at = $3 WHERE id = $4", 394 - &[ 395 - &result.deck_at_uri, 396 - &serde_json::to_value(&Visibility::Public).unwrap(), 397 - &Some(chrono::Utc::now()), 398 - &deck_id, 399 - ], 400 - ) 401 - .await 402 - { 403 - tracing::error!("Failed to store deck AT-URI: {}", e); 161 + let params = UpdateDeckParams { 162 + deck_id: id.clone(), 163 + visibility: Some(Visibility::Public), 164 + published_at: Some(chrono::Utc::now().to_rfc3339()), 165 + at_uri: Some(result.deck_at_uri.clone()), 166 + }; 167 + 168 + if let Err(e) = state.deck_repo.update(params).await { 169 + tracing::error!("Failed to update deck: {:?}", e); 404 170 } 405 171 172 + updated_deck.visibility = Visibility::Public; 173 + updated_deck.published_at = Some(chrono::Utc::now().to_rfc3339()); 174 + 406 175 for (i, at_uri) in result.card_at_uris.iter().enumerate() { 407 176 if i < cards.len() 408 - && let Ok(card_uuid) = uuid::Uuid::parse_str(&cards[i].id) 409 - && let Err(e) = client 410 - .execute("UPDATE cards SET at_uri = $1 WHERE id = $2", &[at_uri, &card_uuid]) 411 - .await 177 + && let Err(e) = state.card_repo.update_at_uri(&cards[i].id, at_uri).await 412 178 { 413 - tracing::warn!("Failed to store card AT-URI: {}", e); 179 + tracing::warn!("Failed to store card AT-URI: {:?}", e); 414 180 } 415 181 } 416 - 417 - deck.visibility = Visibility::Public; 418 - deck.published_at = Some(chrono::Utc::now().to_rfc3339()); 419 182 } 420 183 Err(e) => { 421 184 tracing::error!("Failed to publish to PDS: {}", e); ··· 427 190 } 428 191 } 429 192 } else { 430 - let (new_visibility, published_at) = ( 431 - serde_json::to_value(&Visibility::Private).unwrap(), 432 - None::<chrono::DateTime<chrono::Utc>>, 433 - ); 434 - if let Err(e) = client 435 - .execute( 436 - "UPDATE decks SET visibility = $1, published_at = $2 WHERE id = $3", 437 - &[&new_visibility, &published_at, &deck_id], 438 - ) 439 - .await 440 - { 441 - tracing::error!("Failed to update deck: {}", e); 193 + let params = UpdateDeckParams { 194 + deck_id: id.clone(), 195 + visibility: Some(Visibility::Private), 196 + published_at: None, 197 + at_uri: None, 198 + }; 199 + if let Err(e) = state.deck_repo.update(params).await { 200 + tracing::error!("Failed to update deck: {:?}", e); 442 201 return ( 443 202 StatusCode::INTERNAL_SERVER_ERROR, 444 203 Json(json!({"error": "Failed to update deck"})), 445 204 ) 446 205 .into_response(); 447 206 } 448 - deck.visibility = Visibility::Private; 449 - deck.published_at = None; 207 + updated_deck.visibility = Visibility::Private; 208 + updated_deck.published_at = None; 450 209 } 451 210 452 211 if let Some(at_uri) = deck_at_uri { 453 212 Json(json!({ 454 - "deck": deck, 213 + "deck": updated_deck, 455 214 "at_uri": at_uri 456 215 })) 457 216 .into_response() 458 217 } else { 459 - Json(deck).into_response() 218 + Json(updated_deck).into_response() 460 219 } 461 220 } 462 221 ··· 468 227 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 469 228 }; 470 229 471 - let pool = &state.pool; 472 - let mut client = match pool.get().await { 473 - Ok(client) => client, 474 - Err(e) => { 475 - tracing::error!("Failed to get database connection: {}", e); 476 - return ( 477 - StatusCode::INTERNAL_SERVER_ERROR, 478 - Json(json!({"error": "Database connection failed"})), 479 - ) 480 - .into_response(); 230 + match state.deck_repo.fork(&id, &user.did).await { 231 + Ok(deck) => (StatusCode::CREATED, Json(deck)).into_response(), 232 + Err(crate::repository::deck::DeckRepoError::NotFound(_)) => { 233 + (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 481 234 } 482 - }; 483 - 484 - let original_deck_uuid = match uuid::Uuid::parse_str(&id) { 485 - Ok(uuid) => uuid, 486 - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), 487 - }; 488 - 489 - let transaction = match client.transaction().await { 490 - Ok(tx) => tx, 491 - Err(e) => { 492 - tracing::error!("Failed to start transaction: {}", e); 493 - return ( 494 - StatusCode::INTERNAL_SERVER_ERROR, 495 - Json(json!({"error": "Database error"})), 496 - ) 497 - .into_response(); 498 - } 499 - }; 500 - 501 - let original_deck_row = match transaction 502 - .query_opt( 503 - "SELECT owner_did, title, description, tags, visibility FROM decks WHERE id = $1", 504 - &[&original_deck_uuid], 505 - ) 506 - .await 507 - { 508 - Ok(Some(row)) => row, 509 - Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 510 - Err(e) => { 511 - tracing::error!("Failed to query deck: {}", e); 512 - return ( 513 - StatusCode::INTERNAL_SERVER_ERROR, 514 - Json(json!({"error": "Failed to retrieve deck"})), 515 - ) 516 - .into_response(); 517 - } 518 - }; 519 - 520 - let visibility_json: serde_json::Value = original_deck_row.get("visibility"); 521 - let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private); 522 - 523 - let can_fork = match visibility { 524 - Visibility::Public | Visibility::Unlisted => true, 525 - Visibility::SharedWith(dids) => dids.contains(&user.did), 526 - Visibility::Private => { 527 - let owner: String = original_deck_row.get("owner_did"); 528 - owner == user.did 529 - } 530 - }; 531 - 532 - if !can_fork { 533 - return ( 235 + Err(crate::repository::deck::DeckRepoError::AccessDenied(_)) => ( 534 236 StatusCode::FORBIDDEN, 535 237 Json(json!({"error": "Cannot fork private deck"})), 536 238 ) 537 - .into_response(); 538 - } 539 - 540 - let new_deck_id = uuid::Uuid::new_v4(); 541 - let title: String = original_deck_row.get("title"); 542 - let description: String = original_deck_row.get("description"); 543 - let tags: Vec<String> = original_deck_row.get("tags"); 544 - 545 - if let Err(e) = transaction 546 - .execute( 547 - "INSERT INTO decks (id, owner_did, title, description, tags, visibility, fork_of) 548 - VALUES ($1, $2, $3, $4, $5, $6, $7)", 549 - &[ 550 - &new_deck_id, 551 - &user.did, 552 - &format!("Fork of {}", title), 553 - &description, 554 - &tags, 555 - &serde_json::to_value(&Visibility::Private).unwrap(), 556 - &original_deck_uuid, 557 - ], 558 - ) 559 - .await 560 - { 561 - tracing::error!("Failed to create forked deck: {}", e); 562 - return ( 563 - StatusCode::INTERNAL_SERVER_ERROR, 564 - Json(json!({"error": "Failed to create deck"})), 565 - ) 566 - .into_response(); 567 - } 568 - 569 - let original_cards = match transaction 570 - .query( 571 - "SELECT front, back, media_url FROM cards WHERE deck_id = $1", 572 - &[&original_deck_uuid], 573 - ) 574 - .await 575 - { 576 - Ok(rows) => rows, 239 + .into_response(), 577 240 Err(e) => { 578 - tracing::error!("Failed to fetch original cards: {}", e); 579 - return ( 580 - StatusCode::INTERNAL_SERVER_ERROR, 581 - Json(json!({"error": "Failed to fetch cards"})), 582 - ) 583 - .into_response(); 584 - } 585 - }; 586 - 587 - for row in original_cards { 588 - let card_id = uuid::Uuid::new_v4(); 589 - let front: String = row.get("front"); 590 - let back: String = row.get("back"); 591 - let media_url: Option<String> = row.get("media_url"); 592 - 593 - if let Err(e) = transaction 594 - .execute( 595 - "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) 596 - VALUES ($1, $2, $3, $4, $5, $6)", 597 - &[&card_id, &user.did, &new_deck_id, &front, &back, &media_url], 598 - ) 599 - .await 600 - { 601 - tracing::error!("Failed to fork card: {}", e); 602 - return ( 241 + tracing::error!("Failed to fork deck: {:?}", e); 242 + ( 603 243 StatusCode::INTERNAL_SERVER_ERROR, 604 - Json(json!({"error": "Failed to fork cards"})), 244 + Json(json!({"error": "Failed to fork deck"})), 605 245 ) 606 - .into_response(); 246 + .into_response() 607 247 } 608 248 } 609 - 610 - if let Err(e) = transaction.commit().await { 611 - tracing::error!("Failed to commit transaction: {}", e); 612 - return ( 613 - StatusCode::INTERNAL_SERVER_ERROR, 614 - Json(json!({"error": "Transaction failed"})), 615 - ) 616 - .into_response(); 617 - } 618 - 619 - let deck = Deck { 620 - id: new_deck_id.to_string(), 621 - owner_did: user.did, 622 - title: format!("Fork of {}", title), 623 - description, 624 - tags, 625 - visibility: Visibility::Private, 626 - published_at: None, 627 - fork_of: Some(id), 628 - }; 629 - 630 - (StatusCode::CREATED, Json(deck)).into_response() 631 249 }
+14 -1
crates/server/src/api/feed.rs
··· 62 62 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 63 63 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 64 64 65 - Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 65 + let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 66 + as Arc<dyn crate::repository::deck::DeckRepository>; 67 + let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 68 + 69 + let repos = crate::state::Repositories { 70 + card: card_repo, 71 + note: note_repo, 72 + oauth: oauth_repo, 73 + review: review_repo, 74 + social: social_repo, 75 + deck: deck_repo, 76 + }; 77 + 78 + AppState::new(pool, repos, config) 66 79 } 67 80 68 81 #[tokio::test]
+1
crates/server/src/api/importer.rs
··· 8 8 url: String, 9 9 } 10 10 11 + // TODO: add tests 11 12 pub async fn import_article(Json(payload): Json<ImportRequest>) -> impl IntoResponse { 12 13 if payload.url.trim().is_empty() { 13 14 return (StatusCode::BAD_REQUEST, Json(json!({"error": "URL is required"}))).into_response();
+14 -1
crates/server/src/api/review.rs
··· 150 150 let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) 151 151 as Arc<dyn crate::repository::social::SocialRepository>; 152 152 153 - Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 153 + let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 154 + as Arc<dyn crate::repository::deck::DeckRepository>; 155 + let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 156 + 157 + let repos = crate::state::Repositories { 158 + card: card_repo, 159 + note: note_repo, 160 + oauth: oauth_repo, 161 + review: review_repo, 162 + social: social_repo, 163 + deck: deck_repo, 164 + }; 165 + 166 + AppState::new(pool, repos, config) 154 167 } 155 168 156 169 #[tokio::test]
+22 -8
crates/server/src/api/social.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 - use crate::repository::social::SocialRepoError; 3 2 use crate::state::SharedState; 4 3 5 4 use axum::{ ··· 29 28 30 29 match result { 31 30 Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(), 32 - Err(SocialRepoError::DatabaseError(msg)) => { 31 + Err(malfestio_core::Error::Database(msg)) => { 33 32 tracing::error!("Database error: {}", msg); 34 33 ( 35 34 StatusCode::INTERNAL_SERVER_ERROR, ··· 57 56 58 57 match result { 59 58 Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(), 60 - Err(SocialRepoError::DatabaseError(msg)) => { 59 + Err(malfestio_core::Error::Database(msg)) => { 61 60 tracing::error!("Database error: {}", msg); 62 61 ( 63 62 StatusCode::INTERNAL_SERVER_ERROR, ··· 78 77 79 78 match result { 80 79 Ok(followers) => Json(followers).into_response(), 81 - Err(SocialRepoError::DatabaseError(msg)) => { 80 + Err(malfestio_core::Error::Database(msg)) => { 82 81 tracing::error!("Database error: {}", msg); 83 82 ( 84 83 StatusCode::INTERNAL_SERVER_ERROR, ··· 99 98 100 99 match result { 101 100 Ok(following) => Json(following).into_response(), 102 - Err(SocialRepoError::DatabaseError(msg)) => { 101 + Err(malfestio_core::Error::Database(msg)) => { 103 102 tracing::error!("Database error: {}", msg); 104 103 ( 105 104 StatusCode::INTERNAL_SERVER_ERROR, ··· 131 130 132 131 match result { 133 132 Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(), 134 - Err(SocialRepoError::DatabaseError(msg)) => { 133 + Err(malfestio_core::Error::Database(msg)) => { 135 134 tracing::error!("Database error: {}", msg); 136 135 ( 137 136 StatusCode::INTERNAL_SERVER_ERROR, ··· 152 151 153 152 match result { 154 153 Ok(comments) => Json(comments).into_response(), 155 - Err(SocialRepoError::DatabaseError(msg)) => { 154 + Err(malfestio_core::Error::Database(msg)) => { 156 155 tracing::error!("Database error: {}", msg); 157 156 ( 158 157 StatusCode::INTERNAL_SERVER_ERROR, ··· 188 187 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 189 188 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 190 189 191 - Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 190 + let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 191 + as Arc<dyn crate::repository::deck::DeckRepository>; 192 + let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 193 + let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 194 + 195 + Arc::new(AppState { 196 + pool, 197 + card_repo, 198 + note_repo, 199 + oauth_repo, 200 + review_repo, 201 + social_repo, 202 + deck_repo, 203 + config, 204 + auth_cache, 205 + }) 192 206 } 193 207 194 208 #[tokio::test]
+2 -4
crates/server/src/db.rs
··· 5 5 pub type DbPool = Pool; 6 6 7 7 /// Initialize database connection pool from environment 8 - pub fn create_pool() -> Result<DbPool, Box<dyn std::error::Error>> { 9 - let db_url = std::env::var("DB_URL").map_err(|_| "DB_URL environment variable not set")?; 10 - 11 - let config = db_url.parse::<tokio_postgres::Config>()?; 8 + pub fn create_pool(url: &str) -> Result<DbPool, Box<dyn std::error::Error>> { 9 + let config = url.parse::<tokio_postgres::Config>()?; 12 10 13 11 let mut pool_config = Config::new(); 14 12 pool_config.dbname = config.get_dbname().map(String::from);
+31 -4
crates/server/src/lib.rs
··· 34 34 35 35 tracing::info!("Starting Malfestio Server..."); 36 36 37 - let pool = db::create_pool().map_err(|e| { 37 + let database_url = std::env::var("DATABASE_URL") 38 + .unwrap_or_else(|_| std::env::var("DB_URL").expect("DATABASE_URL or DB_URL must be set")); 39 + let pool = db::create_pool(&database_url).map_err(|e| { 38 40 tracing::error!("Failed to create database pool: {}", e); 39 41 malfestio_core::Error::Database(format!("Failed to create database pool: {}", e)) 40 42 })?; 41 43 42 44 tracing::info!("Database connection pool created"); 43 45 44 - let state = state::AppState::new(pool); 46 + let oauth_repo = std::sync::Arc::new(repository::oauth::DbOAuthRepository::new(pool.clone())); 47 + let deck_repo = std::sync::Arc::new(repository::deck::DbDeckRepository::new(pool.clone())); 48 + let card_repo = std::sync::Arc::new(repository::card::DbCardRepository::new(pool.clone())); 49 + let note_repo = std::sync::Arc::new(repository::note::DbNoteRepository::new(pool.clone())); 50 + let review_repo = std::sync::Arc::new(repository::review::DbReviewRepository::new(pool.clone())); 51 + let social_repo = std::sync::Arc::new(repository::social::DbSocialRepository::new(pool.clone())); 52 + 53 + let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 54 + let config = state::AppConfig { pds_url }; 55 + 56 + let repos = state::Repositories { 57 + oauth: oauth_repo, 58 + deck: deck_repo, 59 + card: card_repo, 60 + note: note_repo, 61 + review: review_repo, 62 + social: social_repo, 63 + }; 64 + 65 + let state = state::AppState::new(pool, repos, config); 45 66 let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new()); 46 67 47 68 let auth_routes = Router::new() ··· 58 79 .route("/social/unfollow/{did}", post(api::social::unfollow)) 59 80 .route("/decks/{id}/comments", post(api::social::add_comment)) 60 81 .route("/feeds/follows", get(api::feed::get_feed_follows)) 61 - .layer(axum_middleware::from_fn(middleware::auth::auth_middleware)); 82 + .layer(axum_middleware::from_fn_with_state( 83 + state.clone(), 84 + middleware::auth::auth_middleware, 85 + )); 62 86 63 87 let optional_auth_routes = Router::new() 64 88 .route("/decks", get(api::deck::list_decks)) ··· 70 94 .route("/social/following/{did}", get(api::social::get_following)) 71 95 .route("/decks/{id}/comments", get(api::social::get_comments)) 72 96 .route("/feeds/trending", get(api::feed::get_feed_trending)) 73 - .layer(axum_middleware::from_fn(middleware::auth::optional_auth_middleware)); 97 + .layer(axum_middleware::from_fn_with_state( 98 + state.clone(), 99 + middleware::auth::optional_auth_middleware, 100 + )); 74 101 75 102 let oauth_routes = Router::new() 76 103 .route("/authorize", post(api::oauth::authorize))
+5 -2
crates/server/src/main.rs
··· 1 1 #[tokio::main] 2 2 async fn main() -> malfestio_core::Result<()> { 3 - // TODO: default to .env, pass arg/param into call 4 - dotenvy::from_filename(".env.local").ok(); 3 + dotenvy::dotenv().ok(); 4 + if let Ok(file) = std::env::var("ENV_FILE") { 5 + dotenvy::from_filename(file).ok(); 6 + } 7 + 5 8 malfestio_server::start().await 6 9 }
+31 -9
crates/server/src/middleware/auth.rs
··· 1 - use axum::response::IntoResponse; 1 + use crate::state::SharedState; 2 2 use axum::{ 3 - extract::Request, 4 - http::{self, StatusCode}, 3 + extract::{Request, State}, 4 + http::{self}, 5 5 middleware::Next, 6 - response::Response, 6 + response::{IntoResponse, Response}, 7 7 }; 8 8 use serde_json::json; 9 + use std::time::{Duration, Instant}; 9 10 10 11 #[derive(Clone, Debug)] 11 12 pub struct UserContext { ··· 13 14 pub handle: String, 14 15 } 15 16 17 + /// Cache expiry time 18 + const CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes 19 + 16 20 /// TODO: Cache or use signature verification for performance 17 - pub async fn auth_middleware(mut req: Request, next: Next) -> Response { 21 + pub async fn auth_middleware(State(state): State<SharedState>, mut req: Request, next: Next) -> Response { 18 22 let auth_header = req.headers().get(http::header::AUTHORIZATION); 19 23 20 24 let token = match auth_header.and_then(|h| h.to_str().ok()) { 21 25 Some(header_val) if header_val.starts_with("Bearer ") => &header_val[7..], 22 26 _ => { 23 27 return ( 24 - StatusCode::UNAUTHORIZED, 28 + axum::http::StatusCode::UNAUTHORIZED, 25 29 axum::Json(json!({ "error": "Missing or invalid Authorization header" })), 26 30 ) 27 31 .into_response(); 28 32 } 29 33 }; 30 34 35 + { 36 + let cache = state.auth_cache.read().await; 37 + if let Some((user_ctx, timestamp)) = cache.get(token) 38 + && timestamp.elapsed() < CACHE_TTL 39 + { 40 + req.extensions_mut().insert(user_ctx.clone()); 41 + return next.run(req).await; 42 + } 43 + } 44 + 31 45 let client = reqwest::Client::new(); 32 - let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string()); 46 + let pds_url = &state.config.pds_url; 33 47 34 48 let resp = client 35 49 .get(format!("{}/xrpc/com.atproto.server.getSession", pds_url)) ··· 43 57 let did = body["did"].as_str().unwrap_or("").to_string(); 44 58 let handle = body["handle"].as_str().unwrap_or("").to_string(); 45 59 46 - req.extensions_mut().insert(UserContext { did, handle }); 60 + let user_ctx = UserContext { did, handle }; 61 + 62 + // Update cache 63 + { 64 + let mut cache = state.auth_cache.write().await; 65 + cache.insert(token.to_string(), (user_ctx.clone(), Instant::now())); 66 + } 67 + 68 + req.extensions_mut().insert(user_ctx); 47 69 next.run(req).await 48 70 } 49 71 _ => ( 50 - StatusCode::UNAUTHORIZED, 72 + axum::http::StatusCode::UNAUTHORIZED, 51 73 axum::Json(json!({ "error": "Invalid session" })), 52 74 ) 53 75 .into_response(),
+51 -6
crates/server/src/repository/card.rs
··· 27 27 async fn list_by_deck(&self, deck_id: &str) -> Result<Vec<Card>, CardRepoError>; 28 28 29 29 async fn verify_deck_ownership(&self, deck_id: &str, owner_did: &str) -> Result<bool, CardRepoError>; 30 + 31 + async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError>; 30 32 } 31 33 32 34 pub struct DbCardRepository { ··· 67 69 let card_id = uuid::Uuid::new_v4(); 68 70 client 69 71 .execute( 70 - "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) 71 - VALUES ($1, $2, $3, $4, $5, $6)", 72 + "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints) 73 + VALUES ($1, $2, $3, $4, $5, $6, $7)", 72 74 &[ 73 75 &card_id, 74 76 &params.owner_did, ··· 76 78 &params.front, 77 79 &params.back, 78 80 &params.media_url, 81 + &params.hints, 79 82 ], 80 83 ) 81 84 .await ··· 115 118 116 119 let rows = client 117 120 .query( 118 - "SELECT id, owner_did, deck_id, front, back, media_url 121 + "SELECT id, owner_did, deck_id, front, back, media_url, hints 119 122 FROM cards 120 123 WHERE deck_id = $1 121 124 ORDER BY created_at ASC", ··· 137 140 back: row.get("back"), 138 141 media_url: row.get("media_url"), 139 142 card_type: CardType::default(), 140 - hints: vec![], 143 + hints: row.get("hints"), 141 144 }); 142 145 } 143 146 ··· 166 169 } 167 170 None => Ok(false), 168 171 } 172 + } 173 + 174 + async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError> { 175 + let client = self 176 + .pool 177 + .get() 178 + .await 179 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 180 + 181 + let card_uuid = uuid::Uuid::parse_str(card_id) 182 + .map_err(|_| CardRepoError::InvalidArgument("Invalid card ID".to_string()))?; 183 + 184 + client 185 + .execute("UPDATE cards SET at_uri = $1 WHERE id = $2", &[&at_uri, &card_uuid]) 186 + .await 187 + .map_err(|e| CardRepoError::DatabaseError(format!("Failed to update card AT-URI: {}", e)))?; 188 + 189 + Ok(()) 169 190 } 170 191 } 171 192 ··· 177 198 #[derive(Clone)] 178 199 pub struct MockCardRepository { 179 200 pub cards: Arc<Mutex<Vec<Card>>>, 201 + pub at_uris: Arc<Mutex<std::collections::HashMap<String, String>>>, 180 202 pub should_fail: Arc<Mutex<bool>>, 181 203 } 182 204 183 205 impl MockCardRepository { 184 206 pub fn new() -> Self { 185 - Self { cards: Arc::new(Mutex::new(Vec::new())), should_fail: Arc::new(Mutex::new(false)) } 207 + Self { 208 + cards: Arc::new(Mutex::new(Vec::new())), 209 + at_uris: Arc::new(Mutex::new(std::collections::HashMap::new())), 210 + should_fail: Arc::new(Mutex::new(false)), 211 + } 186 212 } 187 213 188 214 pub fn with_cards(cards: Vec<Card>) -> Self { 189 - Self { cards: Arc::new(Mutex::new(cards)), should_fail: Arc::new(Mutex::new(false)) } 215 + Self { 216 + cards: Arc::new(Mutex::new(cards)), 217 + at_uris: Arc::new(Mutex::new(std::collections::HashMap::new())), 218 + should_fail: Arc::new(Mutex::new(false)), 219 + } 190 220 } 191 221 192 222 pub fn set_should_fail(&self, should_fail: bool) { ··· 236 266 return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 237 267 } 238 268 Ok(true) 269 + } 270 + 271 + async fn update_at_uri(&self, card_id: &str, at_uri: &str) -> Result<(), CardRepoError> { 272 + if *self.should_fail.lock().unwrap() { 273 + return Err(CardRepoError::DatabaseError("Mock failure".to_string())); 274 + } 275 + 276 + let cards = self.cards.lock().unwrap(); 277 + if cards.iter().any(|c| c.id == card_id) { 278 + let mut at_uris = self.at_uris.lock().unwrap(); 279 + at_uris.insert(card_id.to_string(), at_uri.to_string()); 280 + Ok(()) 281 + } else { 282 + Err(CardRepoError::NotFound("Card not found".to_string())) 283 + } 239 284 } 240 285 } 241 286 }
+409
crates/server/src/repository/deck.rs
··· 1 + use async_trait::async_trait; 2 + use malfestio_core::model::{Deck, Visibility}; 3 + use uuid::Uuid; 4 + 5 + #[derive(Debug)] 6 + pub enum DeckRepoError { 7 + DatabaseError(String), 8 + NotFound(String), 9 + AccessDenied(String), 10 + InvalidArgument(String), 11 + } 12 + 13 + #[derive(Debug)] 14 + pub struct CreateDeckParams { 15 + pub owner_did: String, 16 + pub title: String, 17 + pub description: String, 18 + pub tags: Vec<String>, 19 + pub visibility: Visibility, 20 + } 21 + 22 + #[derive(Debug)] 23 + pub struct UpdateDeckParams { 24 + pub deck_id: String, 25 + pub visibility: Option<Visibility>, 26 + pub published_at: Option<String>, 27 + pub at_uri: Option<String>, 28 + } 29 + 30 + #[async_trait] 31 + pub trait DeckRepository: Send + Sync { 32 + async fn create(&self, params: CreateDeckParams) -> Result<Deck, DeckRepoError>; 33 + async fn get(&self, id: &str) -> Result<Deck, DeckRepoError>; 34 + async fn list_visible(&self, viewer_did: Option<&str>) -> Result<Vec<Deck>, DeckRepoError>; 35 + async fn update(&self, params: UpdateDeckParams) -> Result<Deck, DeckRepoError>; 36 + async fn fork(&self, original_deck_id: &str, user_did: &str) -> Result<Deck, DeckRepoError>; 37 + } 38 + 39 + pub struct DbDeckRepository { 40 + pool: crate::db::DbPool, 41 + } 42 + 43 + impl DbDeckRepository { 44 + pub fn new(pool: crate::db::DbPool) -> Self { 45 + Self { pool } 46 + } 47 + } 48 + 49 + #[async_trait] 50 + impl DeckRepository for DbDeckRepository { 51 + async fn create(&self, params: CreateDeckParams) -> Result<Deck, DeckRepoError> { 52 + let client = self 53 + .pool 54 + .get() 55 + .await 56 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 57 + 58 + let deck_id = Uuid::new_v4(); 59 + let visibility_json = serde_json::to_value(&params.visibility) 60 + .map_err(|e| DeckRepoError::InvalidArgument(format!("Failed to serialize visibility: {}", e)))?; 61 + 62 + client 63 + .execute( 64 + "INSERT INTO decks (id, owner_did, title, description, tags, visibility) 65 + VALUES ($1, $2, $3, $4, $5, $6)", 66 + &[ 67 + &deck_id, 68 + &params.owner_did, 69 + &params.title, 70 + &params.description, 71 + &params.tags, 72 + &visibility_json, 73 + ], 74 + ) 75 + .await 76 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to insert deck: {}", e)))?; 77 + 78 + Ok(Deck { 79 + id: deck_id.to_string(), 80 + owner_did: params.owner_did, 81 + title: params.title, 82 + description: params.description, 83 + tags: params.tags, 84 + visibility: params.visibility, 85 + published_at: None, 86 + fork_of: None, 87 + }) 88 + } 89 + 90 + async fn get(&self, id: &str) -> Result<Deck, DeckRepoError> { 91 + let client = self 92 + .pool 93 + .get() 94 + .await 95 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 96 + 97 + let deck_uuid = 98 + Uuid::parse_str(id).map_err(|_| DeckRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 99 + 100 + let row = client 101 + .query_opt( 102 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of 103 + FROM decks WHERE id = $1", 104 + &[&deck_uuid], 105 + ) 106 + .await 107 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query deck: {}", e)))? 108 + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string()))?; 109 + 110 + let visibility_json: serde_json::Value = row.get("visibility"); 111 + let visibility: Visibility = serde_json::from_value(visibility_json) 112 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to parse deck visibility: {}", e)))?; 113 + 114 + let fork_of: Option<Uuid> = row.get("fork_of"); 115 + 116 + Ok(Deck { 117 + id: row.get::<_, Uuid>("id").to_string(), 118 + owner_did: row.get("owner_did"), 119 + title: row.get("title"), 120 + description: row.get("description"), 121 + tags: row.get("tags"), 122 + visibility, 123 + published_at: row 124 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 125 + .map(|dt| dt.to_rfc3339()), 126 + fork_of: fork_of.map(|u| u.to_string()), 127 + }) 128 + } 129 + 130 + async fn list_visible(&self, viewer_did: Option<&str>) -> Result<Vec<Deck>, DeckRepoError> { 131 + let client = self 132 + .pool 133 + .get() 134 + .await 135 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 136 + 137 + let query = if viewer_did.is_some() { 138 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at 139 + FROM decks 140 + WHERE owner_did = $1 141 + OR visibility->>'type' = 'Public' 142 + OR visibility->>'type' = 'Unlisted' 143 + OR (visibility->>'type' = 'SharedWith' AND visibility->'content' ? $1) 144 + ORDER BY created_at DESC" 145 + } else { 146 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at 147 + FROM decks 148 + WHERE visibility->>'type' IN ('Public', 'Unlisted') 149 + ORDER BY created_at DESC" 150 + }; 151 + 152 + let rows = if let Some(did) = viewer_did { 153 + client.query(query, &[&did]).await 154 + } else { 155 + client.query(query, &[]).await 156 + } 157 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to retrieve decks: {}", e)))?; 158 + 159 + let mut decks = Vec::new(); 160 + for row in rows { 161 + let visibility_json: serde_json::Value = row.get("visibility"); 162 + let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private); 163 + let fork_of: Option<Uuid> = row.get("fork_of"); 164 + 165 + decks.push(Deck { 166 + id: row.get::<_, Uuid>("id").to_string(), 167 + owner_did: row.get("owner_did"), 168 + title: row.get("title"), 169 + description: row.get("description"), 170 + tags: row.get("tags"), 171 + visibility, 172 + published_at: row 173 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 174 + .map(|dt| dt.to_rfc3339()), 175 + fork_of: fork_of.map(|u| u.to_string()), 176 + }); 177 + } 178 + Ok(decks) 179 + } 180 + 181 + async fn update(&self, params: UpdateDeckParams) -> Result<Deck, DeckRepoError> { 182 + let client = self 183 + .pool 184 + .get() 185 + .await 186 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 187 + 188 + let deck_uuid = Uuid::parse_str(&params.deck_id) 189 + .map_err(|_| DeckRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 190 + 191 + // TODO: build the query dynamically. 192 + let current = self.get(&params.deck_id).await?; 193 + 194 + let new_visibility = params.visibility.unwrap_or(current.visibility); 195 + let vis_json = serde_json::to_value(&new_visibility).unwrap(); 196 + 197 + let new_published_at = if let Some(ts) = params.published_at { 198 + Some( 199 + chrono::DateTime::parse_from_rfc3339(&ts) 200 + .map_err(|_| DeckRepoError::InvalidArgument("Invalid timestamp".to_string()))? 201 + .with_timezone(&chrono::Utc), 202 + ) 203 + } else { 204 + None 205 + }; 206 + 207 + if let Some(at_uri) = params.at_uri { 208 + client 209 + .execute( 210 + "UPDATE decks SET visibility = $1, published_at = $2, at_uri = $3 WHERE id = $4", 211 + &[&vis_json, &new_published_at, &at_uri, &deck_uuid], 212 + ) 213 + .await 214 + } else { 215 + client 216 + .execute( 217 + "UPDATE decks SET visibility = $1, published_at = $2 WHERE id = $3", 218 + &[&vis_json, &new_published_at, &deck_uuid], 219 + ) 220 + .await 221 + } 222 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to update deck: {}", e)))?; 223 + 224 + // Return updated deck 225 + self.get(&params.deck_id).await 226 + } 227 + 228 + async fn fork(&self, original_deck_id: &str, user_did: &str) -> Result<Deck, DeckRepoError> { 229 + let mut client = self 230 + .pool 231 + .get() 232 + .await 233 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 234 + 235 + let original_uuid = Uuid::parse_str(original_deck_id) 236 + .map_err(|_| DeckRepoError::InvalidArgument("Invalid deck ID".to_string()))?; 237 + 238 + // TODO: execute multiple queries with a Transaction object that lives across boundaries or use a closure 239 + let tx = client 240 + .transaction() 241 + .await 242 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to start transaction: {}", e)))?; 243 + 244 + let original_deck_row = tx 245 + .query_opt( 246 + "SELECT title, description, tags FROM decks WHERE id = $1", 247 + &[&original_uuid], 248 + ) 249 + .await 250 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to query deck: {}", e)))? 251 + .ok_or_else(|| DeckRepoError::NotFound("Original deck not found".to_string()))?; 252 + 253 + let new_deck_id = Uuid::new_v4(); 254 + let title: String = original_deck_row.get("title"); 255 + let description: String = original_deck_row.get("description"); 256 + let tags: Vec<String> = original_deck_row.get("tags"); 257 + let new_title = format!("Fork of {}", title); 258 + let visibility = Visibility::Private; 259 + let vis_json = serde_json::to_value(&visibility).unwrap(); 260 + 261 + tx.execute( 262 + "INSERT INTO decks (id, owner_did, title, description, tags, visibility, fork_of) 263 + VALUES ($1, $2, $3, $4, $5, $6, $7)", 264 + &[ 265 + &new_deck_id, 266 + &user_did, 267 + &new_title, 268 + &description, 269 + &tags, 270 + &vis_json, 271 + &original_uuid, 272 + ], 273 + ) 274 + .await 275 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to create deck: {}", e)))?; 276 + 277 + let rows = tx 278 + .query( 279 + "SELECT front, back, media_url, hints FROM cards WHERE deck_id = $1", 280 + &[&original_uuid], 281 + ) 282 + .await 283 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to fetch cards: {}", e)))?; 284 + 285 + for row in rows { 286 + let card_id = Uuid::new_v4(); 287 + let front: String = row.get("front"); 288 + let back: String = row.get("back"); 289 + let media_url: Option<String> = row.get("media_url"); 290 + let hints: Vec<String> = row.get("hints"); // Added hints support here too 291 + 292 + tx.execute( 293 + "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url, hints) 294 + VALUES ($1, $2, $3, $4, $5, $6, $7)", 295 + &[&card_id, &user_did, &new_deck_id, &front, &back, &media_url, &hints], 296 + ) 297 + .await 298 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to copy card: {}", e)))?; 299 + } 300 + 301 + tx.commit() 302 + .await 303 + .map_err(|e| DeckRepoError::DatabaseError(format!("Failed to commit transaction: {}", e)))?; 304 + 305 + Ok(Deck { 306 + id: new_deck_id.to_string(), 307 + owner_did: user_did.to_string(), 308 + title: new_title, 309 + description, 310 + tags, 311 + visibility, 312 + published_at: None, 313 + fork_of: Some(original_deck_id.to_string()), 314 + }) 315 + } 316 + } 317 + 318 + #[cfg(test)] 319 + pub mod mock { 320 + use super::*; 321 + use std::sync::{Arc, Mutex}; 322 + 323 + #[derive(Clone)] 324 + pub struct MockDeckRepository { 325 + pub decks: Arc<Mutex<Vec<Deck>>>, 326 + } 327 + 328 + impl MockDeckRepository { 329 + pub fn new() -> Self { 330 + Self { decks: Arc::new(Mutex::new(Vec::new())) } 331 + } 332 + } 333 + 334 + impl Default for MockDeckRepository { 335 + fn default() -> Self { 336 + Self::new() 337 + } 338 + } 339 + 340 + #[async_trait] 341 + impl DeckRepository for MockDeckRepository { 342 + async fn create(&self, params: CreateDeckParams) -> Result<Deck, DeckRepoError> { 343 + let deck = Deck { 344 + id: Uuid::new_v4().to_string(), 345 + owner_did: params.owner_did, 346 + title: params.title, 347 + description: params.description, 348 + tags: params.tags, 349 + visibility: params.visibility, 350 + published_at: None, 351 + fork_of: None, 352 + }; 353 + self.decks.lock().unwrap().push(deck.clone()); 354 + Ok(deck) 355 + } 356 + 357 + async fn get(&self, id: &str) -> Result<Deck, DeckRepoError> { 358 + let decks = self.decks.lock().unwrap(); 359 + decks 360 + .iter() 361 + .find(|d| d.id == id) 362 + .cloned() 363 + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string())) 364 + } 365 + 366 + async fn list_visible(&self, _viewer_did: Option<&str>) -> Result<Vec<Deck>, DeckRepoError> { 367 + let decks = self.decks.lock().unwrap(); 368 + Ok(decks.clone()) 369 + } 370 + 371 + async fn update(&self, params: UpdateDeckParams) -> Result<Deck, DeckRepoError> { 372 + let mut decks = self.decks.lock().unwrap(); 373 + let deck = decks 374 + .iter_mut() 375 + .find(|d| d.id == params.deck_id) 376 + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string()))?; 377 + 378 + if let Some(v) = params.visibility { 379 + deck.visibility = v; 380 + } 381 + if let Some(p) = params.published_at { 382 + deck.published_at = Some(p); 383 + } 384 + Ok(deck.clone()) 385 + } 386 + 387 + async fn fork(&self, original_deck_id: &str, user_did: &str) -> Result<Deck, DeckRepoError> { 388 + let mut decks = self.decks.lock().unwrap(); 389 + let original = decks 390 + .iter() 391 + .find(|d| d.id == original_deck_id) 392 + .ok_or_else(|| DeckRepoError::NotFound("Deck not found".to_string()))? 393 + .clone(); 394 + 395 + let deck = Deck { 396 + id: Uuid::new_v4().to_string(), 397 + owner_did: user_did.to_string(), 398 + title: format!("Fork of {}", original.title), 399 + description: original.description, 400 + tags: original.tags, 401 + visibility: Visibility::Private, 402 + published_at: None, 403 + fork_of: Some(original_deck_id.to_string()), 404 + }; 405 + decks.push(deck.clone()); 406 + Ok(deck) 407 + } 408 + } 409 + }
+1
crates/server/src/repository/mod.rs
··· 1 1 pub mod card; 2 + pub mod deck; 2 3 pub mod note; 3 4 pub mod oauth; 4 5 pub mod review;
+3 -2
crates/server/src/repository/review.rs
··· 87 87 c.front, 88 88 c.back, 89 89 c.media_url, 90 + c.hints, 90 91 cr.due_at 91 92 FROM cards c 92 93 JOIN decks d ON c.deck_id = d.id ··· 111 112 c.front, 112 113 c.back, 113 114 c.media_url, 115 + c.hints, 114 116 cr.due_at 115 117 FROM cards c 116 118 JOIN decks d ON c.deck_id = d.id ··· 142 144 front: row.get("front"), 143 145 back: row.get("back"), 144 146 media_url: row.get("media_url"), 145 - // TODO: Load hints when stored in DB 146 - hints: vec![], 147 + hints: row.get("hints"), 147 148 due_at: due_at.unwrap_or_else(Utc::now), 148 149 }); 149 150 }
+44 -52
crates/server/src/repository/social.rs
··· 1 1 use async_trait::async_trait; 2 2 use chrono::Utc; 3 + use malfestio_core::error::Error; 3 4 use malfestio_core::model::{Comment, Deck, Visibility}; 4 5 5 6 use crate::db; 6 7 7 - #[derive(Debug)] 8 - /// TODO: merge with core error type 9 - pub enum SocialRepoError { 10 - DatabaseError(String), 11 - NotFound(String), 12 - } 13 - 14 8 #[async_trait] 15 9 pub trait SocialRepository: Send + Sync { 16 - async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError>; 17 - async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError>; 18 - async fn get_followers(&self, did: &str) -> Result<Vec<String>, SocialRepoError>; 19 - async fn get_following(&self, did: &str) -> Result<Vec<String>, SocialRepoError>; 10 + async fn follow(&self, follower: &str, subject: &str) -> Result<(), Error>; 11 + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), Error>; 12 + async fn get_followers(&self, did: &str) -> Result<Vec<String>, Error>; 13 + async fn get_following(&self, did: &str) -> Result<Vec<String>, Error>; 20 14 async fn add_comment( 21 15 &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, 22 - ) -> Result<Comment, SocialRepoError>; 23 - async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, SocialRepoError>; 24 - async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, SocialRepoError>; 25 - async fn get_feed_trending(&self) -> Result<Vec<Deck>, SocialRepoError>; 16 + ) -> Result<Comment, Error>; 17 + async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, Error>; 18 + async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, Error>; 19 + async fn get_feed_trending(&self) -> Result<Vec<Deck>, Error>; 26 20 } 27 21 28 22 pub struct DbSocialRepository { ··· 68 62 69 63 #[async_trait] 70 64 impl SocialRepository for DbSocialRepository { 71 - async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 65 + async fn follow(&self, follower: &str, subject: &str) -> Result<(), Error> { 72 66 let client = self 73 67 .pool 74 68 .get() 75 69 .await 76 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 70 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 77 71 78 72 client 79 73 .execute( ··· 81 75 &[&follower, &subject], 82 76 ) 83 77 .await 84 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to follow: {}", e)))?; 78 + .map_err(|e| Error::Database(format!("Failed to follow: {}", e)))?; 85 79 86 80 Ok(()) 87 81 } 88 82 89 - async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 83 + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), Error> { 90 84 let client = self 91 85 .pool 92 86 .get() 93 87 .await 94 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 88 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 95 89 96 90 client 97 91 .execute( ··· 99 93 &[&follower, &subject], 100 94 ) 101 95 .await 102 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to unfollow: {}", e)))?; 96 + .map_err(|e| Error::Database(format!("Failed to unfollow: {}", e)))?; 103 97 104 98 Ok(()) 105 99 } 106 100 107 - async fn get_followers(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 101 + async fn get_followers(&self, did: &str) -> Result<Vec<String>, Error> { 108 102 let client = self 109 103 .pool 110 104 .get() 111 105 .await 112 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 106 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 113 107 114 108 let rows = client 115 109 .query("SELECT follower_did FROM follows WHERE subject_did = $1", &[&did]) 116 110 .await 117 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get followers: {}", e)))?; 111 + .map_err(|e| Error::Database(format!("Failed to get followers: {}", e)))?; 118 112 119 113 Ok(rows.iter().map(|row| row.get("follower_did")).collect()) 120 114 } 121 115 122 - async fn get_following(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 116 + async fn get_following(&self, did: &str) -> Result<Vec<String>, Error> { 123 117 let client = self 124 118 .pool 125 119 .get() 126 120 .await 127 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 121 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 128 122 129 123 let rows = client 130 124 .query("SELECT subject_did FROM follows WHERE follower_did = $1", &[&did]) 131 125 .await 132 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get following: {}", e)))?; 126 + .map_err(|e| Error::Database(format!("Failed to get following: {}", e)))?; 133 127 134 128 Ok(rows.iter().map(|row| row.get("subject_did")).collect()) 135 129 } 136 130 137 131 async fn add_comment( 138 132 &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, 139 - ) -> Result<Comment, SocialRepoError> { 133 + ) -> Result<Comment, Error> { 140 134 let client = self 141 135 .pool 142 136 .get() 143 137 .await 144 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 138 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 145 139 146 - let deck_uuid = uuid::Uuid::parse_str(deck_id) 147 - .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; 140 + let deck_uuid = uuid::Uuid::parse_str(deck_id).map_err(|_| Error::Database("Invalid deck ID".to_string()))?; 148 141 149 142 let parent_uuid = parent_id 150 143 .map(uuid::Uuid::parse_str) 151 144 .transpose() 152 - .map_err(|_| SocialRepoError::DatabaseError("Invalid parent ID".to_string()))?; 145 + .map_err(|_| Error::Database("Invalid parent ID".to_string()))?; 153 146 154 147 let comment_id = uuid::Uuid::new_v4(); 155 148 let now = Utc::now(); ··· 161 154 &[&comment_id, &deck_uuid, &author_did, &content, &parent_uuid, &now], 162 155 ) 163 156 .await 164 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to add comment: {}", e)))?; 157 + .map_err(|e| Error::Database(format!("Failed to add comment: {}", e)))?; 165 158 166 159 Ok(Comment { 167 160 id: comment_id.to_string(), ··· 173 166 }) 174 167 } 175 168 176 - async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, SocialRepoError> { 169 + async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, Error> { 177 170 let client = self 178 171 .pool 179 172 .get() 180 173 .await 181 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 174 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 182 175 183 - let deck_uuid = uuid::Uuid::parse_str(deck_id) 184 - .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; 176 + let deck_uuid = uuid::Uuid::parse_str(deck_id).map_err(|_| Error::Database("Invalid deck ID".to_string()))?; 185 177 186 178 let rows = client 187 179 .query( ··· 192 184 &[&deck_uuid], 193 185 ) 194 186 .await 195 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get comments: {}", e)))?; 187 + .map_err(|e| Error::Database(format!("Failed to get comments: {}", e)))?; 196 188 197 189 let mut comments = Vec::new(); 198 190 for row in rows { ··· 214 206 Ok(comments) 215 207 } 216 208 217 - async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, SocialRepoError> { 209 + async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, Error> { 218 210 let client = self 219 211 .pool 220 212 .get() 221 213 .await 222 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 214 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 223 215 224 216 let query = " 225 217 SELECT d.id, d.owner_did, d.title, d.description, d.tags, d.visibility, d.published_at, d.fork_of ··· 235 227 let rows = client 236 228 .query(query, &[&user_did]) 237 229 .await 238 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get feed: {}", e)))?; 230 + .map_err(|e| Error::Database(format!("Failed to get feed: {}", e)))?; 239 231 240 232 Ok(Self::parse_deck_rows(rows)) 241 233 } 242 234 243 - async fn get_feed_trending(&self) -> Result<Vec<Deck>, SocialRepoError> { 235 + async fn get_feed_trending(&self) -> Result<Vec<Deck>, Error> { 244 236 let client = self 245 237 .pool 246 238 .get() 247 239 .await 248 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 240 + .map_err(|e| Error::Database(format!("Failed to get connection: {}", e)))?; 249 241 250 242 let query = " 251 243 SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of ··· 259 251 let rows = client 260 252 .query(query, &[]) 261 253 .await 262 - .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get trending: {}", e)))?; 254 + .map_err(|e| Error::Database(format!("Failed to get trending: {}", e)))?; 263 255 264 256 Ok(Self::parse_deck_rows(rows)) 265 257 } ··· 290 282 291 283 #[async_trait] 292 284 impl SocialRepository for MockSocialRepository { 293 - async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 285 + async fn follow(&self, follower: &str, subject: &str) -> Result<(), Error> { 294 286 let mut followers = self.followers.lock().unwrap(); 295 287 if !followers.contains(&(follower.to_string(), subject.to_string())) { 296 288 followers.push((follower.to_string(), subject.to_string())); ··· 298 290 Ok(()) 299 291 } 300 292 301 - async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 293 + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), Error> { 302 294 let mut followers = self.followers.lock().unwrap(); 303 295 followers.retain(|(f, s)| f != follower || s != subject); 304 296 Ok(()) 305 297 } 306 298 307 - async fn get_followers(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 299 + async fn get_followers(&self, did: &str) -> Result<Vec<String>, Error> { 308 300 let followers = self.followers.lock().unwrap(); 309 301 Ok(followers 310 302 .iter() ··· 313 305 .collect()) 314 306 } 315 307 316 - async fn get_following(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 308 + async fn get_following(&self, did: &str) -> Result<Vec<String>, Error> { 317 309 let followers = self.followers.lock().unwrap(); 318 310 Ok(followers 319 311 .iter() ··· 324 316 325 317 async fn add_comment( 326 318 &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, 327 - ) -> Result<Comment, SocialRepoError> { 319 + ) -> Result<Comment, Error> { 328 320 let comment = Comment { 329 321 id: uuid::Uuid::new_v4().to_string(), 330 322 deck_id: deck_id.to_string(), ··· 337 329 Ok(comment) 338 330 } 339 331 340 - async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, SocialRepoError> { 332 + async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, Error> { 341 333 let comments = self.comments.lock().unwrap(); 342 334 Ok(comments.iter().filter(|c| c.deck_id == deck_id).cloned().collect()) 343 335 } 344 336 345 337 /// Mock empty or predefined 346 - async fn get_feed_follows(&self, _user_did: &str) -> Result<Vec<Deck>, SocialRepoError> { 338 + async fn get_feed_follows(&self, _user_did: &str) -> Result<Vec<Deck>, Error> { 347 339 Ok(vec![]) 348 340 } 349 341 350 - async fn get_feed_trending(&self) -> Result<Vec<Deck>, SocialRepoError> { 342 + async fn get_feed_trending(&self) -> Result<Vec<Deck>, Error> { 351 343 Ok(vec![]) 352 344 } 353 345 }
+55 -14
crates/server/src/state.rs
··· 1 1 use crate::db::DbPool; 2 - use crate::repository::card::{CardRepository, DbCardRepository}; 3 - use crate::repository::note::{DbNoteRepository, NoteRepository}; 4 - use crate::repository::oauth::{DbOAuthRepository, OAuthRepository}; 5 - use crate::repository::review::{DbReviewRepository, ReviewRepository}; 6 - use crate::repository::social::{DbSocialRepository, SocialRepository}; 2 + use crate::middleware::auth::UserContext; 3 + use crate::repository::card::CardRepository; 4 + use crate::repository::deck::DeckRepository; 5 + use crate::repository::note::NoteRepository; 6 + use crate::repository::oauth::OAuthRepository; 7 + use crate::repository::review::ReviewRepository; 8 + use crate::repository::social::SocialRepository; 7 9 10 + use std::collections::HashMap; 8 11 use std::sync::Arc; 12 + use std::time::Instant; 13 + use tokio::sync::RwLock; 9 14 10 15 pub type SharedState = Arc<AppState>; 11 16 17 + #[derive(Clone)] 18 + pub struct AppConfig { 19 + pub pds_url: String, 20 + } 21 + 22 + pub type AuthCache = Arc<RwLock<HashMap<String, (UserContext, Instant)>>>; 23 + 24 + pub struct Repositories { 25 + pub oauth: Arc<dyn OAuthRepository>, 26 + pub deck: Arc<dyn DeckRepository>, 27 + pub card: Arc<dyn CardRepository>, 28 + pub note: Arc<dyn NoteRepository>, 29 + pub review: Arc<dyn ReviewRepository>, 30 + pub social: Arc<dyn SocialRepository>, 31 + } 32 + 12 33 pub struct AppState { 13 34 pub pool: DbPool, 14 35 pub card_repo: Arc<dyn CardRepository>, 36 + pub deck_repo: Arc<dyn DeckRepository>, 15 37 pub note_repo: Arc<dyn NoteRepository>, 16 38 pub oauth_repo: Arc<dyn OAuthRepository>, 17 39 pub review_repo: Arc<dyn ReviewRepository>, 18 40 pub social_repo: Arc<dyn SocialRepository>, 41 + pub config: AppConfig, 42 + pub auth_cache: AuthCache, 19 43 } 20 44 21 45 impl AppState { 22 - pub fn new(pool: DbPool) -> SharedState { 23 - let card_repo = Arc::new(DbCardRepository::new(pool.clone())) as Arc<dyn CardRepository>; 24 - let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>; 25 - let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>; 26 - let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc<dyn ReviewRepository>; 27 - let social_repo = Arc::new(DbSocialRepository::new(pool.clone())) as Arc<dyn SocialRepository>; 28 - 29 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 46 + pub fn new(pool: DbPool, repos: Repositories, config: AppConfig) -> SharedState { 47 + let auth_cache = Arc::new(RwLock::new(HashMap::new())); 48 + Arc::new(Self { 49 + pool, 50 + oauth_repo: repos.oauth, 51 + deck_repo: repos.deck, 52 + card_repo: repos.card, 53 + note_repo: repos.note, 54 + review_repo: repos.review, 55 + social_repo: repos.social, 56 + config, 57 + auth_cache, 58 + }) 30 59 } 31 60 32 61 #[cfg(test)] ··· 37 66 use crate::repository; 38 67 let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 39 68 let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc<dyn SocialRepository>; 40 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 69 + let deck_repo = Arc::new(repository::deck::mock::MockDeckRepository::new()) as Arc<dyn DeckRepository>; 70 + let config = AppConfig { pds_url: "https://bsky.social".to_string() }; 71 + 72 + let repos = Repositories { 73 + card: card_repo, 74 + note: note_repo, 75 + oauth: oauth_repo, 76 + review: review_repo, 77 + social: social_repo, 78 + deck: deck_repo, 79 + }; 80 + 81 + Self::new(pool, repos, config) 41 82 } 42 83 }
+1
migrations/006_2025_12_30_add_hints.sql
··· 1 + ALTER TABLE cards ADD COLUMN hints TEXT[] NOT NULL DEFAULT '{}';