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

feat: social layer with follows, comments, and feeds

+17
crates/core/src/model.rs
··· 55 55 pub published_at: Option<String>, 56 56 pub fork_of: Option<String>, 57 57 } 58 + 59 + #[derive(Debug, Clone, Serialize, Deserialize)] 60 + pub struct Comment { 61 + pub id: String, 62 + pub deck_id: String, 63 + pub author_did: String, 64 + pub content: String, 65 + pub parent_id: Option<String>, 66 + pub created_at: String, 67 + } 68 + 69 + #[derive(Debug, Clone, Serialize, Deserialize)] 70 + pub struct Follow { 71 + pub follower_did: String, 72 + pub subject_did: String, 73 + pub created_at: String, 74 + }
+170 -2
crates/server/src/api/deck.rs
··· 182 182 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 183 183 ) -> impl IntoResponse { 184 184 let user_did = ctx.map(|Extension(u)| u.did); 185 - 186 185 let pool = &state.pool; 187 186 let client = match pool.get().await { 188 187 Ok(client) => client, ··· 428 427 } 429 428 } 430 429 } else { 431 - // Unpublish - just update local visibility 432 430 let (new_visibility, published_at) = ( 433 431 serde_json::to_value(&Visibility::Private).unwrap(), 434 432 None::<chrono::DateTime<chrono::Utc>>, ··· 461 459 Json(deck).into_response() 462 460 } 463 461 } 462 + 463 + pub async fn fork_deck( 464 + State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 465 + ) -> impl IntoResponse { 466 + let user = match ctx { 467 + Some(axum::Extension(user)) => user, 468 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 469 + }; 470 + 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(); 481 + } 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 ( 534 + StatusCode::FORBIDDEN, 535 + Json(json!({"error": "Cannot fork private deck"})), 536 + ) 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, 577 + 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 ( 603 + StatusCode::INTERNAL_SERVER_ERROR, 604 + Json(json!({"error": "Failed to fork cards"})), 605 + ) 606 + .into_response(); 607 + } 608 + } 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 + }
+87
crates/server/src/api/feed.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::state::SharedState; 3 + 4 + use axum::{ 5 + Json, 6 + extract::{Extension, State}, 7 + http::StatusCode, 8 + response::IntoResponse, 9 + }; 10 + use serde_json::json; 11 + 12 + pub async fn get_feed_follows( 13 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, 14 + ) -> impl IntoResponse { 15 + let user = match ctx { 16 + Some(Extension(user)) => user, 17 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 18 + }; 19 + 20 + match state.social_repo.get_feed_follows(&user.did).await { 21 + Ok(decks) => Json(decks).into_response(), 22 + Err(e) => { 23 + tracing::error!("Failed to get feed: {:?}", e); 24 + ( 25 + StatusCode::INTERNAL_SERVER_ERROR, 26 + Json(json!({"error": "Failed to retrieve feed"})), 27 + ) 28 + .into_response() 29 + } 30 + } 31 + } 32 + 33 + pub async fn get_feed_trending(State(state): State<SharedState>) -> impl IntoResponse { 34 + match state.social_repo.get_feed_trending().await { 35 + Ok(decks) => Json(decks).into_response(), 36 + Err(e) => { 37 + tracing::error!("Failed to get trending: {:?}", e); 38 + ( 39 + StatusCode::INTERNAL_SERVER_ERROR, 40 + Json(json!({"error": "Failed to retrieve trending feed"})), 41 + ) 42 + .into_response() 43 + } 44 + } 45 + } 46 + 47 + #[cfg(test)] 48 + mod tests { 49 + use super::*; 50 + use crate::repository::card::mock::MockCardRepository; 51 + use crate::repository::note::mock::MockNoteRepository; 52 + use crate::repository::oauth::mock::MockOAuthRepository; 53 + use crate::repository::review::mock::MockReviewRepository; 54 + use crate::repository::social::{SocialRepository, mock::MockSocialRepository}; 55 + use crate::state::AppState; 56 + use std::sync::Arc; 57 + 58 + fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState { 59 + let pool = crate::db::create_mock_pool(); 60 + let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 61 + let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 62 + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 63 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 64 + 65 + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 66 + } 67 + 68 + #[tokio::test] 69 + async fn test_get_feed_follows_success() { 70 + let social_repo = Arc::new(MockSocialRepository::new()); 71 + let state = create_test_state_with_social(social_repo); 72 + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; 73 + let response = get_feed_follows(State(state), Some(Extension(user))) 74 + .await 75 + .into_response(); 76 + 77 + assert_eq!(response.status(), StatusCode::OK); 78 + } 79 + 80 + #[tokio::test] 81 + async fn test_get_feed_trending_success() { 82 + let social_repo = Arc::new(MockSocialRepository::new()); 83 + let state = create_test_state_with_social(social_repo); 84 + let response = get_feed_trending(State(state)).await.into_response(); 85 + assert_eq!(response.status(), StatusCode::OK); 86 + } 87 + }
+2
crates/server/src/api/mod.rs
··· 1 1 pub mod auth; 2 2 pub mod card; 3 3 pub mod deck; 4 + pub mod feed; 4 5 pub mod importer; 5 6 pub mod note; 6 7 pub mod oauth; 7 8 pub mod review; 9 + pub mod social;
+3 -1
crates/server/src/api/review.rs
··· 147 147 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 148 148 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 149 149 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 150 + let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) 151 + as Arc<dyn crate::repository::social::SocialRepository>; 150 152 151 - Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo }) 153 + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 152 154 } 153 155 154 156 #[tokio::test]
+267
crates/server/src/api/social.rs
··· 1 + use crate::middleware::auth::UserContext; 2 + use crate::repository::social::SocialRepoError; 3 + use crate::state::SharedState; 4 + 5 + use axum::{ 6 + Json, 7 + extract::{Extension, Path, State}, 8 + http::StatusCode, 9 + response::IntoResponse, 10 + }; 11 + use serde::Deserialize; 12 + use serde_json::json; 13 + 14 + #[derive(Deserialize)] 15 + pub struct AddCommentRequest { 16 + pub content: String, 17 + pub parent_id: Option<String>, 18 + } 19 + 20 + pub async fn follow( 21 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>, 22 + ) -> impl IntoResponse { 23 + let user = match ctx { 24 + Some(Extension(user)) => user, 25 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 26 + }; 27 + 28 + let result = state.social_repo.follow(&user.did, &subject_did).await; 29 + 30 + match result { 31 + Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(), 32 + Err(SocialRepoError::DatabaseError(msg)) => { 33 + tracing::error!("Database error: {}", msg); 34 + ( 35 + StatusCode::INTERNAL_SERVER_ERROR, 36 + Json(json!({"error": "Failed to follow"})), 37 + ) 38 + .into_response() 39 + } 40 + Err(e) => ( 41 + StatusCode::INTERNAL_SERVER_ERROR, 42 + Json(json!({"error": format!("{:?}", e)})), 43 + ) 44 + .into_response(), 45 + } 46 + } 47 + 48 + pub async fn unfollow( 49 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>, 50 + ) -> impl IntoResponse { 51 + let user = match ctx { 52 + Some(Extension(user)) => user, 53 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 54 + }; 55 + 56 + let result = state.social_repo.unfollow(&user.did, &subject_did).await; 57 + 58 + match result { 59 + Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(), 60 + Err(SocialRepoError::DatabaseError(msg)) => { 61 + tracing::error!("Database error: {}", msg); 62 + ( 63 + StatusCode::INTERNAL_SERVER_ERROR, 64 + Json(json!({"error": "Failed to unfollow"})), 65 + ) 66 + .into_response() 67 + } 68 + Err(e) => ( 69 + StatusCode::INTERNAL_SERVER_ERROR, 70 + Json(json!({"error": format!("{:?}", e)})), 71 + ) 72 + .into_response(), 73 + } 74 + } 75 + 76 + pub async fn get_followers(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse { 77 + let result = state.social_repo.get_followers(&did).await; 78 + 79 + match result { 80 + Ok(followers) => Json(followers).into_response(), 81 + Err(SocialRepoError::DatabaseError(msg)) => { 82 + tracing::error!("Database error: {}", msg); 83 + ( 84 + StatusCode::INTERNAL_SERVER_ERROR, 85 + Json(json!({"error": "Failed to get followers"})), 86 + ) 87 + .into_response() 88 + } 89 + Err(e) => ( 90 + StatusCode::INTERNAL_SERVER_ERROR, 91 + Json(json!({"error": format!("{:?}", e)})), 92 + ) 93 + .into_response(), 94 + } 95 + } 96 + 97 + pub async fn get_following(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse { 98 + let result = state.social_repo.get_following(&did).await; 99 + 100 + match result { 101 + Ok(following) => Json(following).into_response(), 102 + Err(SocialRepoError::DatabaseError(msg)) => { 103 + tracing::error!("Database error: {}", msg); 104 + ( 105 + StatusCode::INTERNAL_SERVER_ERROR, 106 + Json(json!({"error": "Failed to get following"})), 107 + ) 108 + .into_response() 109 + } 110 + Err(e) => ( 111 + StatusCode::INTERNAL_SERVER_ERROR, 112 + Json(json!({"error": format!("{:?}", e)})), 113 + ) 114 + .into_response(), 115 + } 116 + } 117 + 118 + pub async fn add_comment( 119 + State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(deck_id): Path<String>, 120 + Json(payload): Json<AddCommentRequest>, 121 + ) -> impl IntoResponse { 122 + let user = match ctx { 123 + Some(Extension(user)) => user, 124 + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 125 + }; 126 + 127 + let result = state 128 + .social_repo 129 + .add_comment(&deck_id, &user.did, &payload.content, payload.parent_id.as_deref()) 130 + .await; 131 + 132 + match result { 133 + Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(), 134 + Err(SocialRepoError::DatabaseError(msg)) => { 135 + tracing::error!("Database error: {}", msg); 136 + ( 137 + StatusCode::INTERNAL_SERVER_ERROR, 138 + Json(json!({"error": "Failed to add comment"})), 139 + ) 140 + .into_response() 141 + } 142 + Err(e) => ( 143 + StatusCode::INTERNAL_SERVER_ERROR, 144 + Json(json!({"error": format!("{:?}", e)})), 145 + ) 146 + .into_response(), 147 + } 148 + } 149 + 150 + pub async fn get_comments(State(state): State<SharedState>, Path(deck_id): Path<String>) -> impl IntoResponse { 151 + let result = state.social_repo.get_comments(&deck_id).await; 152 + 153 + match result { 154 + Ok(comments) => Json(comments).into_response(), 155 + Err(SocialRepoError::DatabaseError(msg)) => { 156 + tracing::error!("Database error: {}", msg); 157 + ( 158 + StatusCode::INTERNAL_SERVER_ERROR, 159 + Json(json!({"error": "Failed to get comments"})), 160 + ) 161 + .into_response() 162 + } 163 + Err(e) => ( 164 + StatusCode::INTERNAL_SERVER_ERROR, 165 + Json(json!({"error": format!("{:?}", e)})), 166 + ) 167 + .into_response(), 168 + } 169 + } 170 + 171 + #[cfg(test)] 172 + mod tests { 173 + use super::*; 174 + use crate::middleware::auth::UserContext; 175 + use crate::repository::card::mock::MockCardRepository; 176 + use crate::repository::note::mock::MockNoteRepository; 177 + use crate::repository::oauth::mock::MockOAuthRepository; 178 + use crate::repository::review::mock::MockReviewRepository; 179 + use crate::repository::social::{SocialRepository, mock::MockSocialRepository}; 180 + use crate::state::AppState; 181 + use axum::extract::Json; 182 + use std::sync::Arc; 183 + 184 + fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState { 185 + let pool = crate::db::create_mock_pool(); 186 + let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 187 + let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 188 + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 189 + let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 190 + 191 + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 192 + } 193 + 194 + #[tokio::test] 195 + async fn test_follow_success() { 196 + let social_repo = Arc::new(MockSocialRepository::new()); 197 + let state = create_test_state_with_social(social_repo.clone()); 198 + let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; 199 + 200 + let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 201 + .await 202 + .into_response(); 203 + 204 + assert_eq!(response.status(), StatusCode::OK); 205 + 206 + let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); 207 + assert!(followers.contains(&"did:plc:follower".to_string())); 208 + } 209 + 210 + #[tokio::test] 211 + async fn test_unfollow_success() { 212 + let social_repo = Arc::new(MockSocialRepository::new()); 213 + social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap(); 214 + 215 + let state = create_test_state_with_social(social_repo.clone()); 216 + let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; 217 + 218 + let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 219 + .await 220 + .into_response(); 221 + 222 + assert_eq!(response.status(), StatusCode::OK); 223 + 224 + let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); 225 + assert!(followers.is_empty()); 226 + } 227 + 228 + #[tokio::test] 229 + async fn test_get_followers() { 230 + let social_repo = Arc::new(MockSocialRepository::new()); 231 + social_repo.follow("did:plc:1", "did:plc:subject").await.unwrap(); 232 + social_repo.follow("did:plc:2", "did:plc:subject").await.unwrap(); 233 + 234 + let state = create_test_state_with_social(social_repo); 235 + 236 + let response = get_followers(State(state), Path("did:plc:subject".to_string())) 237 + .await 238 + .into_response(); 239 + 240 + assert_eq!(response.status(), StatusCode::OK); 241 + // TODO: parse body to verify content 242 + } 243 + 244 + #[tokio::test] 245 + async fn test_add_comment_success() { 246 + let social_repo = Arc::new(MockSocialRepository::new()); 247 + let state = create_test_state_with_social(social_repo.clone()); 248 + let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() }; 249 + 250 + let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None }; 251 + 252 + let response = add_comment( 253 + State(state), 254 + Some(Extension(user)), 255 + Path("deck-1".to_string()), 256 + Json(payload), 257 + ) 258 + .await 259 + .into_response(); 260 + 261 + assert_eq!(response.status(), StatusCode::CREATED); 262 + 263 + let comments = social_repo.get_comments("deck-1").await.unwrap(); 264 + assert_eq!(comments.len(), 1); 265 + assert_eq!(comments[0].content, "Great deck!"); 266 + } 267 + }
+9
crates/server/src/lib.rs
··· 48 48 .route("/me", get(api::auth::me)) 49 49 .route("/decks", post(api::deck::create_deck)) 50 50 .route("/decks/{id}/publish", post(api::deck::publish_deck)) 51 + .route("/decks/{id}/fork", post(api::deck::fork_deck)) 51 52 .route("/notes", post(api::note::create_note)) 52 53 .route("/cards", post(api::card::create_card)) 53 54 .route("/review/due", get(api::review::get_due_cards)) 54 55 .route("/review/submit", post(api::review::submit_review)) 55 56 .route("/review/stats", get(api::review::get_stats)) 57 + .route("/social/follow/{did}", post(api::social::follow)) 58 + .route("/social/unfollow/{did}", post(api::social::unfollow)) 59 + .route("/decks/{id}/comments", post(api::social::add_comment)) 60 + .route("/feeds/follows", get(api::feed::get_feed_follows)) 56 61 .layer(axum_middleware::from_fn(middleware::auth::auth_middleware)); 57 62 58 63 let optional_auth_routes = Router::new() ··· 61 66 .route("/decks/{id}/cards", get(api::card::list_cards)) 62 67 .route("/notes", get(api::note::list_notes)) 63 68 .route("/notes/{id}", get(api::note::get_note)) 69 + .route("/social/followers/{did}", get(api::social::get_followers)) 70 + .route("/social/following/{did}", get(api::social::get_following)) 71 + .route("/decks/{id}/comments", get(api::social::get_comments)) 72 + .route("/feeds/trending", get(api::feed::get_feed_trending)) 64 73 .layer(axum_middleware::from_fn(middleware::auth::optional_auth_middleware)); 65 74 66 75 let oauth_routes = Router::new()
+1
crates/server/src/repository/mod.rs
··· 2 2 pub mod note; 3 3 pub mod oauth; 4 4 pub mod review; 5 + pub mod social;
+354
crates/server/src/repository/social.rs
··· 1 + use async_trait::async_trait; 2 + use chrono::Utc; 3 + use malfestio_core::model::{Comment, Deck, Visibility}; 4 + 5 + use crate::db; 6 + 7 + #[derive(Debug)] 8 + /// TODO: merge with core error type 9 + pub enum SocialRepoError { 10 + DatabaseError(String), 11 + NotFound(String), 12 + } 13 + 14 + #[async_trait] 15 + 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>; 20 + async fn add_comment( 21 + &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>; 26 + } 27 + 28 + pub struct DbSocialRepository { 29 + pool: db::DbPool, 30 + } 31 + 32 + impl DbSocialRepository { 33 + pub fn new(pool: db::DbPool) -> Self { 34 + Self { pool } 35 + } 36 + 37 + fn parse_deck_rows(rows: Vec<tokio_postgres::Row>) -> Vec<Deck> { 38 + let mut decks = Vec::new(); 39 + for row in rows { 40 + let visibility_json: serde_json::Value = row.get("visibility"); 41 + let visibility: Visibility = match serde_json::from_value(visibility_json) { 42 + Ok(v) => v, 43 + Err(e) => { 44 + tracing::error!("Failed to deserialize visibility: {}", e); 45 + continue; 46 + } 47 + }; 48 + 49 + let id: uuid::Uuid = row.get("id"); 50 + let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 51 + 52 + decks.push(Deck { 53 + id: id.to_string(), 54 + owner_did: row.get("owner_did"), 55 + title: row.get("title"), 56 + description: row.get("description"), 57 + tags: row.get("tags"), 58 + visibility, 59 + published_at: row 60 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 61 + .map(|dt| dt.to_rfc3339()), 62 + fork_of: fork_of.map(|u| u.to_string()), 63 + }); 64 + } 65 + decks 66 + } 67 + } 68 + 69 + #[async_trait] 70 + impl SocialRepository for DbSocialRepository { 71 + async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 72 + let client = self 73 + .pool 74 + .get() 75 + .await 76 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 77 + 78 + client 79 + .execute( 80 + "INSERT INTO follows (follower_did, subject_did) VALUES ($1, $2) ON CONFLICT DO NOTHING", 81 + &[&follower, &subject], 82 + ) 83 + .await 84 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to follow: {}", e)))?; 85 + 86 + Ok(()) 87 + } 88 + 89 + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 90 + let client = self 91 + .pool 92 + .get() 93 + .await 94 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 95 + 96 + client 97 + .execute( 98 + "DELETE FROM follows WHERE follower_did = $1 AND subject_did = $2", 99 + &[&follower, &subject], 100 + ) 101 + .await 102 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to unfollow: {}", e)))?; 103 + 104 + Ok(()) 105 + } 106 + 107 + async fn get_followers(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 108 + let client = self 109 + .pool 110 + .get() 111 + .await 112 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 113 + 114 + let rows = client 115 + .query("SELECT follower_did FROM follows WHERE subject_did = $1", &[&did]) 116 + .await 117 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get followers: {}", e)))?; 118 + 119 + Ok(rows.iter().map(|row| row.get("follower_did")).collect()) 120 + } 121 + 122 + async fn get_following(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 123 + let client = self 124 + .pool 125 + .get() 126 + .await 127 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 128 + 129 + let rows = client 130 + .query("SELECT subject_did FROM follows WHERE follower_did = $1", &[&did]) 131 + .await 132 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get following: {}", e)))?; 133 + 134 + Ok(rows.iter().map(|row| row.get("subject_did")).collect()) 135 + } 136 + 137 + async fn add_comment( 138 + &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, 139 + ) -> Result<Comment, SocialRepoError> { 140 + let client = self 141 + .pool 142 + .get() 143 + .await 144 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 145 + 146 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 147 + .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; 148 + 149 + let parent_uuid = parent_id 150 + .map(uuid::Uuid::parse_str) 151 + .transpose() 152 + .map_err(|_| SocialRepoError::DatabaseError("Invalid parent ID".to_string()))?; 153 + 154 + let comment_id = uuid::Uuid::new_v4(); 155 + let now = Utc::now(); 156 + 157 + client 158 + .execute( 159 + "INSERT INTO comments (id, deck_id, author_did, content, parent_id, created_at) 160 + VALUES ($1, $2, $3, $4, $5, $6)", 161 + &[&comment_id, &deck_uuid, &author_did, &content, &parent_uuid, &now], 162 + ) 163 + .await 164 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to add comment: {}", e)))?; 165 + 166 + Ok(Comment { 167 + id: comment_id.to_string(), 168 + deck_id: deck_id.to_string(), 169 + author_did: author_did.to_string(), 170 + content: content.to_string(), 171 + parent_id: parent_id.map(|s| s.to_string()), 172 + created_at: now.to_rfc3339(), 173 + }) 174 + } 175 + 176 + async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, SocialRepoError> { 177 + let client = self 178 + .pool 179 + .get() 180 + .await 181 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 182 + 183 + let deck_uuid = uuid::Uuid::parse_str(deck_id) 184 + .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; 185 + 186 + let rows = client 187 + .query( 188 + "SELECT id, deck_id, author_did, content, parent_id, created_at 189 + FROM comments 190 + WHERE deck_id = $1 191 + ORDER BY created_at ASC", 192 + &[&deck_uuid], 193 + ) 194 + .await 195 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get comments: {}", e)))?; 196 + 197 + let mut comments = Vec::new(); 198 + for row in rows { 199 + let id: uuid::Uuid = row.get("id"); 200 + let row_deck_id: uuid::Uuid = row.get("deck_id"); 201 + let parent_id: Option<uuid::Uuid> = row.get("parent_id"); 202 + let created_at: chrono::DateTime<Utc> = row.get("created_at"); 203 + 204 + comments.push(Comment { 205 + id: id.to_string(), 206 + deck_id: row_deck_id.to_string(), 207 + author_did: row.get("author_did"), 208 + content: row.get("content"), 209 + parent_id: parent_id.map(|u| u.to_string()), 210 + created_at: created_at.to_rfc3339(), 211 + }); 212 + } 213 + 214 + Ok(comments) 215 + } 216 + 217 + async fn get_feed_follows(&self, user_did: &str) -> Result<Vec<Deck>, SocialRepoError> { 218 + let client = self 219 + .pool 220 + .get() 221 + .await 222 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 223 + 224 + let query = " 225 + SELECT d.id, d.owner_did, d.title, d.description, d.tags, d.visibility, d.published_at, d.fork_of 226 + FROM decks d 227 + JOIN follows f ON d.owner_did = f.subject_did 228 + WHERE f.follower_did = $1 229 + AND d.published_at IS NOT NULL 230 + AND d.visibility->>'type' != 'Private' 231 + ORDER BY d.published_at DESC 232 + LIMIT 50 233 + "; 234 + 235 + let rows = client 236 + .query(query, &[&user_did]) 237 + .await 238 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get feed: {}", e)))?; 239 + 240 + Ok(Self::parse_deck_rows(rows)) 241 + } 242 + 243 + async fn get_feed_trending(&self) -> Result<Vec<Deck>, SocialRepoError> { 244 + let client = self 245 + .pool 246 + .get() 247 + .await 248 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; 249 + 250 + let query = " 251 + SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of 252 + FROM decks 253 + WHERE published_at IS NOT NULL 254 + AND visibility->>'type' = 'Public' 255 + ORDER BY published_at DESC 256 + LIMIT 50 257 + "; 258 + 259 + let rows = client 260 + .query(query, &[]) 261 + .await 262 + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get trending: {}", e)))?; 263 + 264 + Ok(Self::parse_deck_rows(rows)) 265 + } 266 + } 267 + 268 + #[cfg(test)] 269 + pub mod mock { 270 + use super::*; 271 + use std::sync::{Arc, Mutex}; 272 + 273 + pub struct MockSocialRepository { 274 + /// (follower, subject) 275 + pub followers: Arc<Mutex<Vec<(String, String)>>>, 276 + pub comments: Arc<Mutex<Vec<Comment>>>, 277 + } 278 + 279 + impl MockSocialRepository { 280 + pub fn new() -> Self { 281 + Self { followers: Arc::new(Mutex::new(Vec::new())), comments: Arc::new(Mutex::new(Vec::new())) } 282 + } 283 + } 284 + 285 + impl Default for MockSocialRepository { 286 + fn default() -> Self { 287 + Self::new() 288 + } 289 + } 290 + 291 + #[async_trait] 292 + impl SocialRepository for MockSocialRepository { 293 + async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 294 + let mut followers = self.followers.lock().unwrap(); 295 + if !followers.contains(&(follower.to_string(), subject.to_string())) { 296 + followers.push((follower.to_string(), subject.to_string())); 297 + } 298 + Ok(()) 299 + } 300 + 301 + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { 302 + let mut followers = self.followers.lock().unwrap(); 303 + followers.retain(|(f, s)| f != follower || s != subject); 304 + Ok(()) 305 + } 306 + 307 + async fn get_followers(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 308 + let followers = self.followers.lock().unwrap(); 309 + Ok(followers 310 + .iter() 311 + .filter(|(_, s)| s == did) 312 + .map(|(f, _)| f.clone()) 313 + .collect()) 314 + } 315 + 316 + async fn get_following(&self, did: &str) -> Result<Vec<String>, SocialRepoError> { 317 + let followers = self.followers.lock().unwrap(); 318 + Ok(followers 319 + .iter() 320 + .filter(|(f, _)| f == did) 321 + .map(|(_, s)| s.clone()) 322 + .collect()) 323 + } 324 + 325 + async fn add_comment( 326 + &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, 327 + ) -> Result<Comment, SocialRepoError> { 328 + let comment = Comment { 329 + id: uuid::Uuid::new_v4().to_string(), 330 + deck_id: deck_id.to_string(), 331 + author_did: author_did.to_string(), 332 + content: content.to_string(), 333 + parent_id: parent_id.map(|s| s.to_string()), 334 + created_at: Utc::now().to_rfc3339(), 335 + }; 336 + self.comments.lock().unwrap().push(comment.clone()); 337 + Ok(comment) 338 + } 339 + 340 + async fn get_comments(&self, deck_id: &str) -> Result<Vec<Comment>, SocialRepoError> { 341 + let comments = self.comments.lock().unwrap(); 342 + Ok(comments.iter().filter(|c| c.deck_id == deck_id).cloned().collect()) 343 + } 344 + 345 + /// Mock empty or predefined 346 + async fn get_feed_follows(&self, _user_did: &str) -> Result<Vec<Deck>, SocialRepoError> { 347 + Ok(vec![]) 348 + } 349 + 350 + async fn get_feed_trending(&self) -> Result<Vec<Deck>, SocialRepoError> { 351 + Ok(vec![]) 352 + } 353 + } 354 + }
+9 -4
crates/server/src/state.rs
··· 3 3 use crate::repository::note::{DbNoteRepository, NoteRepository}; 4 4 use crate::repository::oauth::{DbOAuthRepository, OAuthRepository}; 5 5 use crate::repository::review::{DbReviewRepository, ReviewRepository}; 6 + use crate::repository::social::{DbSocialRepository, SocialRepository}; 7 + 6 8 use std::sync::Arc; 7 9 8 10 pub type SharedState = Arc<AppState>; ··· 13 15 pub note_repo: Arc<dyn NoteRepository>, 14 16 pub oauth_repo: Arc<dyn OAuthRepository>, 15 17 pub review_repo: Arc<dyn ReviewRepository>, 18 + pub social_repo: Arc<dyn SocialRepository>, 16 19 } 17 20 18 21 impl AppState { ··· 21 24 let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>; 22 25 let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>; 23 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>; 24 28 25 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) 29 + Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) 26 30 } 27 31 28 32 #[cfg(test)] ··· 30 34 pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>, 31 35 oauth_repo: Arc<dyn OAuthRepository>, 32 36 ) -> SharedState { 33 - let review_repo = 34 - Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 35 - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) 37 + use crate::repository; 38 + let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>; 39 + 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 }) 36 41 } 37 42 }
+111 -1
docs/core-user-journeys.md
··· 1 1 # Core User Journeys 2 2 3 - This document outlines the five core user journeys for the initial product version. 3 + This document outlines the core user journeys and detailed user flows for Malfestio. 4 4 5 5 ## 1. Import Source & Publish Deck 6 6 7 7 **Goal**: A creator builds a study deck from an external resource and shares it. 8 + 9 + ### High-Level Workflow 8 10 9 11 1. **Import**: User inputs a URL (Article) or pastes text. 10 12 2. **Generate**: System extracts metadata (and optionally snapshots content). ··· 16 18 5. **Publish**: User sets visibility (e.g., Public) and publishes the Deck. 17 19 6. **Result**: The Deck is now a shareable Artifact (ATProto record). 18 20 21 + ### Detailed Flows 22 + 23 + #### Content Import 24 + 25 + **Import Article**: 26 + 27 + 1. Header → "Import" 28 + 2. Enter article URL 29 + 3. Submit → article parsed, deck/note created 30 + 31 + **Import Lecture**: 32 + 33 + 1. Import page → "Lecture Import" tab 34 + 2. Enter lecture URL 35 + 3. Submit → lecture content extracted 36 + 37 + #### Note Management 38 + 39 + **Create Note**: 40 + 41 + 1. Header → "Notes" → "New Note" 42 + 2. Fill: title, body (markdown), tags 43 + 3. Add wikilinks with `[[Note Title]]` 44 + 4. Set visibility 45 + 5. Submit → note created 46 + 47 + **View Notes**: 48 + 49 + 1. Header → "Notes" 50 + 2. Browse notes with backlink navigation 51 + 52 + #### Deck Management 53 + 54 + **Create Deck**: 55 + 56 + 1. Library (`/`) → "Create Deck" 57 + 2. Fill: title, description, tags 58 + 3. Set visibility (Private/Unlisted/Public/SharedWith) 59 + 4. Add cards (front/back, optional hints, card type) 60 + 5. Submit → deck created, redirected to Library 61 + 62 + **View Deck**: 63 + 64 + 1. Library → click deck card 65 + 2. View title, description, tags, card list 66 + 3. Options: Edit, Study, Back to Library 67 + 19 68 ## 2. Daily Study Loop 20 69 21 70 **Goal**: A learner maintains their knowledge using Spaced Repetition (SRS). 71 + 72 + ### High-Level Workflow 22 73 23 74 1. **Session Start**: User opens the app/daily study mode. 24 75 2. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2). ··· 31 82 6. **Progress**: User sees feedback (cards done, streak incremented). 32 83 * *Note: All grading/progress data is strictly private.* 33 84 85 + ### Detailed Flows 86 + 87 + #### Daily Review 88 + 89 + 1. Navigate to `/review` or click "Review" in header 90 + 2. View study stats: due count, streak, reviewed today 91 + 3. Click "Start Study Session" 92 + 4. Card front shown → press **Space** to flip 93 + 5. View answer → grade with **1-5** keys 94 + 6. Repeat until all due cards complete 95 + 7. View completion message and updated stats 96 + 97 + #### Deck-Specific Review 98 + 99 + 1. Navigate to deck view (`/decks/:id`) 100 + 2. Click "Study Deck" 101 + 3. Review only cards from that deck 102 + 4. Same keyboard controls apply 103 + 104 + #### Progress Tracking 105 + 106 + * **Due count**: Cards needing review today 107 + 108 + * **Streak**: Consecutive days studied 109 + * **Reviewed today**: Cards completed this session 110 + * **Interval growth**: SM-2 algorithm increases intervals for mastered cards 111 + 112 + #### Keyboard Shortcuts 113 + 114 + | Key | Action | 115 + | ----- | -------------- | 116 + | Space | Flip card | 117 + | 1 | Grade: Again | 118 + | 2 | Grade: Hard | 119 + | 3 | Grade: Good | 120 + | 4 | Grade: Easy | 121 + | 5 | Grade: Perfect | 122 + | E | Quick edit | 123 + | Esc | Exit session | 124 + 34 125 ## 3. Social Collaboration (Follow/Fork) 35 126 36 127 **Goal**: A learner discovers content and improves it. 128 + 129 + ### High-Level Workflow 37 130 38 131 1. **Discovery**: 39 132 * User follows a Curator. ··· 50 143 51 144 **Goal**: Community interaction while maintaining safety. 52 145 146 + ### High-Level Workflow 147 + 53 148 1. **Context**: A User is viewing a public Card or Deck. 54 149 2. **Discuss**: User adds a **Comment** (threaded) asking for clarification. 55 150 3. **Report** (Unhappy Path): ··· 62 157 63 158 **Goal**: Deep study of long-form audio/video content. 64 159 160 + ### High-Level Workflow 161 + 65 162 1. **Import**: User provides a Lecture URL (e.g., YouTube/Video). 66 163 2. **Structure**: 67 164 * User creates an **Outline** of the lecture. ··· 69 166 3. **Link**: 70 167 * User creates Cards specific to timestamped segments. 71 168 * Clicking context on a Card jumps video to the specific timestamp. 169 + 170 + ## Authentication 171 + 172 + ### Login 173 + 174 + 1. Navigate to `/login` 175 + 2. Enter Bluesky handle and app password 176 + 3. Submit → redirected to Library 177 + 178 + ### Logout 179 + 180 + 1. Click avatar in header → "Logout" 181 + 2. → redirected to Landing page
+2 -30
docs/todo.md
··· 49 49 - OAuth 2.1 client flow (PKCE, DPoP, handle/DID resolution, token refresh). 50 50 - PDS client for `putRecord`, `deleteRecord`, `uploadBlob`. 51 51 - TID generation and AT-URI builder in core crate. 52 - - Database migration for token storage and AT-URI columns. 53 52 - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. 54 53 - **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder). 55 - 56 54 - **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX. 57 55 - SM-2 spaced repetition scheduler. 58 - - Review repository with due card queries and stats tracking. 59 - - API endpoints: `/review/due`, `/review/submit`, `/review/stats`. 60 - - StudySession component with keyboard-first review. 61 - - ReviewStats component for progress display. 62 - 63 - ### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments) 64 - 65 - #### Deliverables 66 - 67 - - Follow graph + notifications 68 - - Feeds: 69 - - "New decks from follows" 70 - - "Trending this week" (simple scoring) 71 - - Forking workflow: 72 - - fork deck -> edit -> republish 73 - - Threaded comments on decks/cards 74 - 75 - #### Acceptance 76 - 77 - - A user can follow a curator and see new published decks in a feed. 56 + - **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments. 78 57 79 58 ### Milestone I - Search + Discovery + Taxonomy 80 59 ··· 95 74 96 75 #### Deliverables 97 76 77 + - Look into [Ozone](https://github.com/bluesky-social/ozone) 98 78 - Reporting pipeline + review queue 99 79 - Rate limits + spam heuristics 100 80 - Takedown/visibility states (shadowed, removed, quarantined) ··· 132 112 - Backups + restore drills 133 113 - Load test targets (study session + feed + search) 134 114 - Beta program + feedback loop + roadmap iteration 135 - 136 - #### Acceptance 137 - 138 - - You can run this as a real product with confidence. 139 - 140 - ## Lexicon Definitions 141 - 142 - Authoritative Lexicon definitions are located in the [`lexicons/`](../lexicons) directory. 143 115 144 116 ## Open Questions (Parked Decisions) 145 117
-107
docs/user-flows.md
··· 1 - # User Flows 2 - 3 - User experience pathways for Malfestio. 4 - 5 - ## Authentication 6 - 7 - ### Login 8 - 9 - 1. Navigate to `/login` 10 - 2. Enter Bluesky handle and app password 11 - 3. Submit → redirected to Library 12 - 13 - ### Logout 14 - 15 - 1. Click avatar in header → "Logout" 16 - 2. → redirected to Landing page 17 - 18 - ## Deck Management 19 - 20 - ### Create Deck 21 - 22 - 1. Library (`/`) → "Create Deck" 23 - 2. Fill: title, description, tags 24 - 3. Set visibility (Private/Unlisted/Public/SharedWith) 25 - 4. Add cards (front/back, optional hints, card type) 26 - 5. Submit → deck created, redirected to Library 27 - 28 - ### View Deck 29 - 30 - 1. Library → click deck card 31 - 2. View title, description, tags, card list 32 - 3. Options: Edit, Study, Back to Library 33 - 34 - ### Study Deck 35 - 36 - 1. Deck View → "Study Deck" 37 - 2. Study session with keyboard controls 38 - 3. Grade cards (1-5), view progress 39 - 4. Session complete → return to deck 40 - 41 - ## Note Management 42 - 43 - ### Create Note 44 - 45 - 1. Header → "Notes" → "New Note" 46 - 2. Fill: title, body (markdown), tags 47 - 3. Add wikilinks with `[[Note Title]]` 48 - 4. Set visibility 49 - 5. Submit → note created 50 - 51 - ### View Notes 52 - 53 - 1. Header → "Notes" 54 - 2. Browse notes with backlink navigation 55 - 56 - ## Content Import 57 - 58 - ### Import Article 59 - 60 - 1. Header → "Import" 61 - 2. Enter article URL 62 - 3. Submit → article parsed, deck/note created 63 - 64 - ### Import Lecture 65 - 66 - 1. Import page → "Lecture Import" tab 67 - 2. Enter lecture URL 68 - 3. Submit → lecture content extracted 69 - 70 - ## Study Session 71 - 72 - ### Daily Review 73 - 74 - 1. Navigate to `/review` or click "Review" in header 75 - 2. View study stats: due count, streak, reviewed today 76 - 3. Click "Start Study Session" 77 - 4. Card front shown → press **Space** to flip 78 - 5. View answer → grade with **1-5** keys 79 - 6. Repeat until all due cards complete 80 - 7. View completion message and updated stats 81 - 82 - ### Deck-Specific Review 83 - 84 - 1. Navigate to deck view (`/decks/:id`) 85 - 2. Click "Study Deck" 86 - 3. Review only cards from that deck 87 - 4. Same keyboard controls apply 88 - 89 - ### Progress Tracking 90 - 91 - - **Due count**: Cards needing review today 92 - - **Streak**: Consecutive days studied 93 - - **Reviewed today**: Cards completed this session 94 - - **Interval growth**: SM-2 algorithm increases intervals for mastered cards 95 - 96 - ### Keyboard Shortcuts 97 - 98 - | Key | Action | 99 - | ----- | -------------- | 100 - | Space | Flip card | 101 - | 1 | Grade: Again | 102 - | 2 | Grade: Hard | 103 - | 3 | Grade: Good | 104 - | 4 | Grade: Easy | 105 - | 5 | Grade: Perfect | 106 - | E | Quick edit | 107 - | Esc | Exit session |
+32
migrations/005_2025_12_30_social_layer.sql
··· 1 + -- Social Layer: Follows and Comments 2 + -- Implements Milestone H requirements 3 + 4 + -- Follows table: User A follows User B 5 + CREATE TABLE follows ( 6 + follower_did TEXT NOT NULL, 7 + subject_did TEXT NOT NULL, 8 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 + PRIMARY KEY (follower_did, subject_did) 10 + ); 11 + 12 + CREATE INDEX idx_follows_follower ON follows(follower_did); 13 + CREATE INDEX idx_follows_subject_did ON follows(subject_did); 14 + 15 + -- Comments table: Threaded comments on Decks (and potentially Cards in future) 16 + CREATE TABLE comments ( 17 + id UUID PRIMARY KEY, 18 + deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE, 19 + author_did TEXT NOT NULL, 20 + content TEXT NOT NULL, 21 + parent_id UUID REFERENCES comments(id) ON DELETE CASCADE, -- For threading 22 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 23 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 24 + ); 25 + 26 + CREATE INDEX idx_comments_deck_id ON comments(deck_id); 27 + CREATE INDEX idx_comments_parent_id ON comments(parent_id); 28 + CREATE INDEX idx_comments_author_did ON comments(author_did); 29 + CREATE INDEX idx_comments_created_at ON comments(created_at); 30 + 31 + CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments 32 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+2
web/src/App.tsx
··· 2 2 import { authStore } from "$lib/store"; 3 3 import DeckNew from "$pages/DeckNew"; 4 4 import DeckView from "$pages/DeckView"; 5 + import Feed from "$pages/Feed"; 5 6 import Home from "$pages/Home"; 6 7 import Import from "$pages/Import"; 7 8 import Landing from "$pages/Landing"; ··· 37 38 <Route path="/import/lecture" component={() => <ProtectedRoute component={LectureImport} />} /> 38 39 <Route path="/review" component={() => <ProtectedRoute component={Review} />} /> 39 40 <Route path="/review/:deckId" component={() => <ProtectedRoute component={Review} />} /> 41 + <Route path="/feed" component={() => <ProtectedRoute component={Feed} />} /> 40 42 <Route path="*" component={() => <ProtectedRoute component={NotFound} />} /> 41 43 </Router> 42 44 );
+116
web/src/components/social/CommentSection.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { authStore } from "$lib/store"; 3 + import { Button } from "$ui/Button"; 4 + import { createResource, createSignal, For, Show } from "solid-js"; 5 + 6 + type Comment = { 7 + id: string; 8 + deck_id: string; 9 + author_did: string; 10 + content: string; 11 + parent_id: string | null; 12 + created_at: string; 13 + }; 14 + 15 + type CommentNode = { comment: Comment; children: CommentNode[] }; 16 + 17 + type CommentSectionProps = { deckId: string }; 18 + 19 + function buildTree(comments: Comment[]): CommentNode[] { 20 + const map = new Map<string, CommentNode>(); 21 + const roots: CommentNode[] = []; 22 + 23 + for (const c of comments) { 24 + map.set(c.id, { comment: c, children: [] }); 25 + } 26 + 27 + for (const c of comments) { 28 + if (c.parent_id && map.has(c.parent_id)) { 29 + map.get(c.parent_id)!.children.push(map.get(c.id)!); 30 + } else { 31 + roots.push(map.get(c.id)!); 32 + } 33 + } 34 + return roots; 35 + } 36 + 37 + export function CommentSection(props: CommentSectionProps) { 38 + const [comments, { refetch }] = createResource(async () => { 39 + const res = await api.getComments(props.deckId); 40 + if (res.ok) { 41 + return (await res.json()) as Comment[]; 42 + } 43 + return []; 44 + }); 45 + 46 + const [newComment, setNewComment] = createSignal(""); 47 + const [replyTo, setReplyTo] = createSignal<string | null>(null); 48 + 49 + const submitComment = async (parentId?: string) => { 50 + if (!newComment().trim()) return; 51 + await api.addComment(props.deckId, newComment(), parentId); 52 + setNewComment(""); 53 + setReplyTo(null); 54 + refetch(); 55 + }; 56 + 57 + const CommentItem = (node: { node: CommentNode }) => ( 58 + <div class="border-l-2 border-gray-200 pl-4 my-2"> 59 + <div class="text-sm font-bold text-gray-600">{node.node.comment.author_did}</div> 60 + <div class="my-1">{node.node.comment.content}</div> 61 + <div class="text-xs text-gray-500 flex gap-2"> 62 + <span>{new Date(node.node.comment.created_at).toLocaleString()}</span> 63 + <button class="text-blue-500 hover:underline" onClick={() => setReplyTo(node.node.comment.id)}>Reply</button> 64 + </div> 65 + 66 + <Show when={replyTo() === node.node.comment.id}> 67 + <div class="mt-2 flex gap-2"> 68 + <input 69 + type="text" 70 + class="border rounded p-1 flex-1 text-sm" 71 + value={newComment()} 72 + onInput={(e) => setNewComment(e.currentTarget.value)} 73 + placeholder="Write a reply..." /> 74 + <Button size="sm" onClick={() => submitComment(node.node.comment.id)}>Post</Button> 75 + <Button size="sm" variant="ghost" onClick={() => setReplyTo(null)}>Cancel</Button> 76 + </div> 77 + </Show> 78 + 79 + <For each={node.node.children}>{(child) => <CommentItem node={child} />}</For> 80 + </div> 81 + ); 82 + 83 + return ( 84 + <div class="mt-8"> 85 + <h3 class="text-xl font-bold mb-4">Comments</h3> 86 + 87 + <Show when={authStore.user}> 88 + <div class="flex gap-2 mb-6"> 89 + <textarea 90 + class="border rounded p-2 flex-1 w-full" 91 + rows={2} 92 + placeholder="Add a comment..." 93 + value={replyTo() ? "" : newComment()} // Clear if replying elsewhere, actually separate state might be better but simple for now 94 + onInput={(e) => { 95 + if (!replyTo()) setNewComment(e.currentTarget.value); 96 + }} /> 97 + <div class="flex flex-col justify-end"> 98 + <Button onClick={() => submitComment()} disabled={!!replyTo()}>Post</Button> 99 + </div> 100 + </div> 101 + </Show> 102 + 103 + <Show when={comments()} fallback={<div class="animate-pulse">Loading comments...</div>}> 104 + {(data) => { 105 + const list = data as unknown as Comment[]; 106 + return ( 107 + <div class="space-y-4"> 108 + <For each={buildTree(list)}>{(node) => <CommentItem node={node} />}</For> 109 + {list.length === 0 && <div class="text-gray-500 italic">No comments yet.</div>} 110 + </div> 111 + ); 112 + }} 113 + </Show> 114 + </div> 115 + ); 116 + }
+51
web/src/components/social/FollowButton.tsx
··· 1 + import { api } from "$lib/api"; 2 + import { authStore } from "$lib/store"; 3 + import { Button } from "$ui/Button"; 4 + import { createSignal, onMount } from "solid-js"; 5 + 6 + type FollowButtonProps = { did: string; initialIsFollowing?: boolean }; 7 + 8 + export function FollowButton(props: FollowButtonProps) { 9 + const [isFollowing, setIsFollowing] = createSignal(props.initialIsFollowing || false); 10 + const [loading, setLoading] = createSignal(false); 11 + 12 + onMount(async () => { 13 + if (props.initialIsFollowing === undefined) { 14 + const user = authStore.user(); 15 + if (user) { 16 + try { 17 + const res = await api.getFollowers(props.did); 18 + if (res.ok) { 19 + const followers: string[] = await res.json(); 20 + setIsFollowing(followers.includes(user.did)); 21 + } 22 + } catch (e) { 23 + console.error("Failed to check follow status", e); 24 + } 25 + } 26 + } 27 + }); 28 + 29 + const toggle = async () => { 30 + setLoading(true); 31 + try { 32 + if (isFollowing()) { 33 + await api.unfollow(props.did); 34 + setIsFollowing(false); 35 + } else { 36 + await api.follow(props.did); 37 + setIsFollowing(true); 38 + } 39 + } catch (e) { 40 + console.error("Failed to toggle follow", e); 41 + } finally { 42 + setLoading(false); 43 + } 44 + }; 45 + 46 + return ( 47 + <Button onClick={toggle} disabled={loading()} variant={isFollowing() ? "secondary" : undefined}> 48 + {isFollowing() ? "Unfollow" : "Follow"} 49 + </Button> 50 + ); 51 + }
+10
web/src/lib/api.ts
··· 35 35 submitReview: (cardId: string, grade: number) => 36 36 apiFetch("/review/submit", { method: "POST", body: JSON.stringify({ card_id: cardId, grade }) }), 37 37 getStats: () => apiFetch("/review/stats", { method: "GET" }), 38 + follow: (did: string) => apiFetch(`/social/follow/${did}`, { method: "POST" }), 39 + unfollow: (did: string) => apiFetch(`/social/unfollow/${did}`, { method: "POST" }), 40 + getFollowers: (did: string) => apiFetch(`/social/followers/${did}`, { method: "GET" }), 41 + getFollowing: (did: string) => apiFetch(`/social/following/${did}`, { method: "GET" }), 42 + addComment: (deckId: string, content: string, parentId?: string) => 43 + apiFetch(`/decks/${deckId}/comments`, { method: "POST", body: JSON.stringify({ content, parent_id: parentId }) }), 44 + getComments: (deckId: string) => apiFetch(`/decks/${deckId}/comments`, { method: "GET" }), 45 + getFeedFollows: () => apiFetch("/feeds/follows", { method: "GET" }), 46 + getFeedTrending: () => apiFetch("/feeds/trending", { method: "GET" }), 47 + forkDeck: (deckId: string) => apiFetch(`/decks/${deckId}/fork`, { method: "POST" }), 38 48 };
+45 -2
web/src/pages/DeckView.tsx
··· 1 + import { CommentSection } from "$components/social/CommentSection"; 2 + import { FollowButton } from "$components/social/FollowButton"; 3 + import { Button } from "$components/ui/Button"; 1 4 import { api } from "$lib/api"; 2 5 import type { Visibility } from "$lib/store"; 3 6 import { A, useParams } from "@solidjs/router"; ··· 15 18 16 19 type Card = { id: string; front: string; back?: string }; 17 20 21 + // TODO: use api.ts 18 22 const fetchDeck = async (id: string): Promise<Deck | null> => { 19 23 const res = await api.get(`/decks/${id}`); 20 24 if (!res.ok) return null; 21 25 return res.json(); 22 26 }; 23 27 28 + // TODO: use api.ts 24 29 const fetchCards = async (id: string): Promise<Card[]> => { 25 30 const res = await api.get(`/decks/${id}/cards`); 26 31 if (!res.ok) return []; ··· 29 34 30 35 const DeckView: Component = () => { 31 36 const params = useParams(); 32 - 33 37 const [deck] = createResource(() => params.id, fetchDeck); 34 38 const [cards] = createResource(() => params.id, fetchCards); 35 39 40 + const handleFork = async () => { 41 + if (!deck()) return; 42 + // TODO: use modal 43 + if (confirm(`Fork "${deck()?.title}"?`)) { 44 + try { 45 + const res = await api.forkDeck(deck()!.id); 46 + if (res.ok) { 47 + const newDeck = await res.json(); 48 + // TODO: use toast 49 + alert("Deck forked successfully!"); 50 + // TODO: useNavigate 51 + // navigate(`/decks/${newDeck.id}`); 52 + window.location.href = `/decks/${newDeck.id}`; 53 + } else { 54 + // TODO: use toast 55 + alert("Failed to fork deck."); 56 + } 57 + } catch (e) { 58 + console.error(e); 59 + // TODO: use toast 60 + alert("Error forking deck."); 61 + } 62 + } 63 + }; 64 + 36 65 return ( 37 66 <div class="max-w-4xl mx-auto px-6 py-12"> 38 67 <Show when={deck.loading}> ··· 54 83 {deck()?.visibility.type} 55 84 </span> 56 85 </Show> 86 + </div> 87 + 88 + <div class="flex items-center gap-4 mb-6"> 89 + <div class="text-[#C6C6C6] font-light">By {deck()?.owner_did}</div> 90 + <FollowButton did={deck()?.owner_did || ""} /> 57 91 </div> 58 92 59 93 <p class="text-[#C6C6C6] mb-6 font-light">{deck()?.description}</p> ··· 67 101 </div> 68 102 69 103 <div class="flex gap-4 border-t border-[#393939] pt-6"> 70 - {/* Placeholder for Study Action */} 71 104 <button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors"> 72 105 Study Deck (Coming Soon) 73 106 </button> 107 + <Button 108 + onClick={handleFork} 109 + variant="secondary" 110 + class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors"> 111 + Fork Deck 112 + </Button> 74 113 <A 75 114 href="/" 76 115 class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors"> ··· 115 154 </div> 116 155 </Show> 117 156 </div> 157 + </div> 158 + 159 + <div class="mt-12 pt-8 border-t border-[#393939]"> 160 + <CommentSection deckId={deck()!.id} /> 118 161 </div> 119 162 </Show> 120 163 </div>
+89
web/src/pages/Feed.tsx
··· 1 + import { FollowButton } from "$components/social/FollowButton"; 2 + import { Button } from "$components/ui/Button"; 3 + import { Card } from "$components/ui/Card"; 4 + import { Tabs } from "$components/ui/Tabs"; 5 + import { api } from "$lib/api"; 6 + import { A } from "@solidjs/router"; 7 + import { createResource, For, Match, Show, Switch } from "solid-js"; 8 + 9 + type Deck = { id: string; title: string; description: string; owner_did: string; published_at: string; tags: string[] }; 10 + 11 + export default function Feed() { 12 + const [followsFeed] = createResource(async () => { 13 + const res = await api.getFeedFollows(); 14 + return res.ok ? (await res.json() as Deck[]) : []; 15 + }); 16 + 17 + const [valuableFeed] = createResource(async () => { 18 + const res = await api.getFeedTrending(); 19 + return res.ok ? (await res.json() as Deck[]) : []; 20 + }); 21 + 22 + const DeckItem = (props: { deck: Deck }) => ( 23 + <Card class="mb-4"> 24 + <div class="flex justify-between items-start"> 25 + <div> 26 + <h3 class="text-xl font-bold mb-1">{props.deck.title}</h3> 27 + <p class="text-sm text-gray-400 mb-2"> 28 + By {props.deck.owner_did} • {new Date(props.deck.published_at).toLocaleDateString()} 29 + </p> 30 + <p class="mb-3">{props.deck.description}</p> 31 + <div class="flex gap-2 mb-3"> 32 + <For each={props.deck.tags}> 33 + {(tag) => <span class="bg-gray-800 px-2 py-1 rounded text-xs">{tag}</span>} 34 + </For> 35 + </div> 36 + </div> 37 + <div class="ml-4"> 38 + <FollowButton did={props.deck.owner_did} /> 39 + </div> 40 + </div> 41 + <div class="flex gap-2 items-center mt-2"> 42 + <A href={`/decks/${props.deck.id}`} class="no-underline"> 43 + <Button variant="secondary" size="sm">View</Button> 44 + </A> 45 + <Button 46 + variant="ghost" 47 + size="sm" 48 + onClick={() => { 49 + // TODO: use modal or toast 50 + if (confirm("Fork this deck?")) { 51 + api.forkDeck(props.deck.id).then(() => alert("Forked successfully!")); 52 + } 53 + }}> 54 + Fork 55 + </Button> 56 + </div> 57 + </Card> 58 + ); 59 + 60 + return ( 61 + <div class="container mx-auto p-4 max-w-3xl"> 62 + <h1 class="text-3xl font-bold mb-6">Discovery</h1> 63 + <Tabs tabs={[{ id: "following", label: "Following" }, { id: "trending", label: "Trending" }]}> 64 + {(activeTab) => ( 65 + <Switch> 66 + <Match when={activeTab() === "following"}> 67 + <div class="mt-4"> 68 + <Show when={followsFeed()}> 69 + {feed => ( 70 + <Show 71 + when={feed().length > 0} 72 + fallback={<div class="text-gray-500 py-8 text-center">No updates from followed users.</div>}> 73 + <For each={feed()}>{(deck) => <DeckItem deck={deck} />}</For> 74 + </Show> 75 + )} 76 + </Show> 77 + </div> 78 + </Match> 79 + <Match when={activeTab() === "trending"}> 80 + <div class="mt-4"> 81 + <For each={valuableFeed()}>{(deck) => <DeckItem deck={deck} />}</For> 82 + </div> 83 + </Match> 84 + </Switch> 85 + )} 86 + </Tabs> 87 + </div> 88 + ); 89 + }