diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 16c8c70..75ebb18 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -55,3 +55,20 @@ pub struct Deck { pub published_at: Option, pub fork_of: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Comment { + pub id: String, + pub deck_id: String, + pub author_did: String, + pub content: String, + pub parent_id: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Follow { + pub follower_did: String, + pub subject_did: String, + pub created_at: String, +} diff --git a/crates/server/src/api/deck.rs b/crates/server/src/api/deck.rs index 535dc23..bef7ee5 100644 --- a/crates/server/src/api/deck.rs +++ b/crates/server/src/api/deck.rs @@ -182,7 +182,6 @@ pub async fn get_deck( State(state): State, ctx: Option>, Path(id): Path, ) -> impl IntoResponse { let user_did = ctx.map(|Extension(u)| u.did); - let pool = &state.pool; let client = match pool.get().await { Ok(client) => client, @@ -428,7 +427,6 @@ pub async fn publish_deck( } } } else { - // Unpublish - just update local visibility let (new_visibility, published_at) = ( serde_json::to_value(&Visibility::Private).unwrap(), None::>, @@ -461,3 +459,173 @@ pub async fn publish_deck( Json(deck).into_response() } } + +pub async fn fork_deck( + State(state): State, ctx: Option>, Path(id): Path, +) -> impl IntoResponse { + let user = match ctx { + Some(axum::Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let pool = &state.pool; + let mut client = match pool.get().await { + Ok(client) => client, + Err(e) => { + tracing::error!("Failed to get database connection: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Database connection failed"})), + ) + .into_response(); + } + }; + + let original_deck_uuid = match uuid::Uuid::parse_str(&id) { + Ok(uuid) => uuid, + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), + }; + + let transaction = match client.transaction().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Failed to start transaction: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Database error"})), + ) + .into_response(); + } + }; + + let original_deck_row = match transaction + .query_opt( + "SELECT owner_did, title, description, tags, visibility FROM decks WHERE id = $1", + &[&original_deck_uuid], + ) + .await + { + Ok(Some(row)) => row, + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), + Err(e) => { + tracing::error!("Failed to query deck: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to retrieve deck"})), + ) + .into_response(); + } + }; + + let visibility_json: serde_json::Value = original_deck_row.get("visibility"); + let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private); + + let can_fork = match visibility { + Visibility::Public | Visibility::Unlisted => true, + Visibility::SharedWith(dids) => dids.contains(&user.did), + Visibility::Private => { + let owner: String = original_deck_row.get("owner_did"); + owner == user.did + } + }; + + if !can_fork { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "Cannot fork private deck"})), + ) + .into_response(); + } + + let new_deck_id = uuid::Uuid::new_v4(); + let title: String = original_deck_row.get("title"); + let description: String = original_deck_row.get("description"); + let tags: Vec = original_deck_row.get("tags"); + + if let Err(e) = transaction + .execute( + "INSERT INTO decks (id, owner_did, title, description, tags, visibility, fork_of) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + &[ + &new_deck_id, + &user.did, + &format!("Fork of {}", title), + &description, + &tags, + &serde_json::to_value(&Visibility::Private).unwrap(), + &original_deck_uuid, + ], + ) + .await + { + tracing::error!("Failed to create forked deck: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to create deck"})), + ) + .into_response(); + } + + let original_cards = match transaction + .query( + "SELECT front, back, media_url FROM cards WHERE deck_id = $1", + &[&original_deck_uuid], + ) + .await + { + Ok(rows) => rows, + Err(e) => { + tracing::error!("Failed to fetch original cards: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to fetch cards"})), + ) + .into_response(); + } + }; + + for row in original_cards { + let card_id = uuid::Uuid::new_v4(); + let front: String = row.get("front"); + let back: String = row.get("back"); + let media_url: Option = row.get("media_url"); + + if let Err(e) = transaction + .execute( + "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url) + VALUES ($1, $2, $3, $4, $5, $6)", + &[&card_id, &user.did, &new_deck_id, &front, &back, &media_url], + ) + .await + { + tracing::error!("Failed to fork card: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to fork cards"})), + ) + .into_response(); + } + } + + if let Err(e) = transaction.commit().await { + tracing::error!("Failed to commit transaction: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Transaction failed"})), + ) + .into_response(); + } + + let deck = Deck { + id: new_deck_id.to_string(), + owner_did: user.did, + title: format!("Fork of {}", title), + description, + tags, + visibility: Visibility::Private, + published_at: None, + fork_of: Some(id), + }; + + (StatusCode::CREATED, Json(deck)).into_response() +} diff --git a/crates/server/src/api/feed.rs b/crates/server/src/api/feed.rs new file mode 100644 index 0000000..b6911c0 --- /dev/null +++ b/crates/server/src/api/feed.rs @@ -0,0 +1,87 @@ +use crate::middleware::auth::UserContext; +use crate::state::SharedState; + +use axum::{ + Json, + extract::{Extension, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde_json::json; + +pub async fn get_feed_follows( + State(state): State, ctx: Option>, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + match state.social_repo.get_feed_follows(&user.did).await { + Ok(decks) => Json(decks).into_response(), + Err(e) => { + tracing::error!("Failed to get feed: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to retrieve feed"})), + ) + .into_response() + } + } +} + +pub async fn get_feed_trending(State(state): State) -> impl IntoResponse { + match state.social_repo.get_feed_trending().await { + Ok(decks) => Json(decks).into_response(), + Err(e) => { + tracing::error!("Failed to get trending: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to retrieve trending feed"})), + ) + .into_response() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::card::mock::MockCardRepository; + use crate::repository::note::mock::MockNoteRepository; + use crate::repository::oauth::mock::MockOAuthRepository; + use crate::repository::review::mock::MockReviewRepository; + use crate::repository::social::{SocialRepository, mock::MockSocialRepository}; + use crate::state::AppState; + use std::sync::Arc; + + fn create_test_state_with_social(social_repo: Arc) -> SharedState { + let pool = crate::db::create_mock_pool(); + let card_repo = Arc::new(MockCardRepository::new()) as Arc; + let note_repo = Arc::new(MockNoteRepository::new()) as Arc; + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let review_repo = Arc::new(MockReviewRepository::new()) as Arc; + + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) + } + + #[tokio::test] + async fn test_get_feed_follows_success() { + let social_repo = Arc::new(MockSocialRepository::new()); + let state = create_test_state_with_social(social_repo); + let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() }; + let response = get_feed_follows(State(state), Some(Extension(user))) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn test_get_feed_trending_success() { + let social_repo = Arc::new(MockSocialRepository::new()); + let state = create_test_state_with_social(social_repo); + let response = get_feed_trending(State(state)).await.into_response(); + assert_eq!(response.status(), StatusCode::OK); + } +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs index ebceeaf..758a272 100644 --- a/crates/server/src/api/mod.rs +++ b/crates/server/src/api/mod.rs @@ -1,7 +1,9 @@ pub mod auth; pub mod card; pub mod deck; +pub mod feed; pub mod importer; pub mod note; pub mod oauth; pub mod review; +pub mod social; diff --git a/crates/server/src/api/review.rs b/crates/server/src/api/review.rs index d8f8db5..f7207c8 100644 --- a/crates/server/src/api/review.rs +++ b/crates/server/src/api/review.rs @@ -147,8 +147,10 @@ mod tests { let card_repo = Arc::new(MockCardRepository::new()) as Arc; let note_repo = Arc::new(MockNoteRepository::new()) as Arc; let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new()) + as Arc; - Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo }) + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) } #[tokio::test] diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs new file mode 100644 index 0000000..ac37440 --- /dev/null +++ b/crates/server/src/api/social.rs @@ -0,0 +1,267 @@ +use crate::middleware::auth::UserContext; +use crate::repository::social::SocialRepoError; +use crate::state::SharedState; + +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::IntoResponse, +}; +use serde::Deserialize; +use serde_json::json; + +#[derive(Deserialize)] +pub struct AddCommentRequest { + pub content: String, + pub parent_id: Option, +} + +pub async fn follow( + State(state): State, ctx: Option>, Path(subject_did): Path, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let result = state.social_repo.follow(&user.did, &subject_did).await; + + match result { + Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to follow"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +pub async fn unfollow( + State(state): State, ctx: Option>, Path(subject_did): Path, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let result = state.social_repo.unfollow(&user.did, &subject_did).await; + + match result { + Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to unfollow"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +pub async fn get_followers(State(state): State, Path(did): Path) -> impl IntoResponse { + let result = state.social_repo.get_followers(&did).await; + + match result { + Ok(followers) => Json(followers).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to get followers"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +pub async fn get_following(State(state): State, Path(did): Path) -> impl IntoResponse { + let result = state.social_repo.get_following(&did).await; + + match result { + Ok(following) => Json(following).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to get following"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +pub async fn add_comment( + State(state): State, ctx: Option>, Path(deck_id): Path, + Json(payload): Json, +) -> impl IntoResponse { + let user = match ctx { + Some(Extension(user)) => user, + None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), + }; + + let result = state + .social_repo + .add_comment(&deck_id, &user.did, &payload.content, payload.parent_id.as_deref()) + .await; + + match result { + Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to add comment"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +pub async fn get_comments(State(state): State, Path(deck_id): Path) -> impl IntoResponse { + let result = state.social_repo.get_comments(&deck_id).await; + + match result { + Ok(comments) => Json(comments).into_response(), + Err(SocialRepoError::DatabaseError(msg)) => { + tracing::error!("Database error: {}", msg); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "Failed to get comments"})), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("{:?}", e)})), + ) + .into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::middleware::auth::UserContext; + use crate::repository::card::mock::MockCardRepository; + use crate::repository::note::mock::MockNoteRepository; + use crate::repository::oauth::mock::MockOAuthRepository; + use crate::repository::review::mock::MockReviewRepository; + use crate::repository::social::{SocialRepository, mock::MockSocialRepository}; + use crate::state::AppState; + use axum::extract::Json; + use std::sync::Arc; + + fn create_test_state_with_social(social_repo: Arc) -> SharedState { + let pool = crate::db::create_mock_pool(); + let card_repo = Arc::new(MockCardRepository::new()) as Arc; + let note_repo = Arc::new(MockNoteRepository::new()) as Arc; + let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc; + let review_repo = Arc::new(MockReviewRepository::new()) as Arc; + + Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) + } + + #[tokio::test] + async fn test_follow_success() { + let social_repo = Arc::new(MockSocialRepository::new()); + let state = create_test_state_with_social(social_repo.clone()); + let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; + + let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + + let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); + assert!(followers.contains(&"did:plc:follower".to_string())); + } + + #[tokio::test] + async fn test_unfollow_success() { + let social_repo = Arc::new(MockSocialRepository::new()); + social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap(); + + let state = create_test_state_with_social(social_repo.clone()); + let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() }; + + let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + + let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); + assert!(followers.is_empty()); + } + + #[tokio::test] + async fn test_get_followers() { + let social_repo = Arc::new(MockSocialRepository::new()); + social_repo.follow("did:plc:1", "did:plc:subject").await.unwrap(); + social_repo.follow("did:plc:2", "did:plc:subject").await.unwrap(); + + let state = create_test_state_with_social(social_repo); + + let response = get_followers(State(state), Path("did:plc:subject".to_string())) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::OK); + // TODO: parse body to verify content + } + + #[tokio::test] + async fn test_add_comment_success() { + let social_repo = Arc::new(MockSocialRepository::new()); + let state = create_test_state_with_social(social_repo.clone()); + let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() }; + + let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None }; + + let response = add_comment( + State(state), + Some(Extension(user)), + Path("deck-1".to_string()), + Json(payload), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::CREATED); + + let comments = social_repo.get_comments("deck-1").await.unwrap(); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].content, "Great deck!"); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 74ad976..9beabd8 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -48,11 +48,16 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/me", get(api::auth::me)) .route("/decks", post(api::deck::create_deck)) .route("/decks/{id}/publish", post(api::deck::publish_deck)) + .route("/decks/{id}/fork", post(api::deck::fork_deck)) .route("/notes", post(api::note::create_note)) .route("/cards", post(api::card::create_card)) .route("/review/due", get(api::review::get_due_cards)) .route("/review/submit", post(api::review::submit_review)) .route("/review/stats", get(api::review::get_stats)) + .route("/social/follow/{did}", post(api::social::follow)) + .route("/social/unfollow/{did}", post(api::social::unfollow)) + .route("/decks/{id}/comments", post(api::social::add_comment)) + .route("/feeds/follows", get(api::feed::get_feed_follows)) .layer(axum_middleware::from_fn(middleware::auth::auth_middleware)); let optional_auth_routes = Router::new() @@ -61,6 +66,10 @@ pub async fn start() -> malfestio_core::Result<()> { .route("/decks/{id}/cards", get(api::card::list_cards)) .route("/notes", get(api::note::list_notes)) .route("/notes/{id}", get(api::note::get_note)) + .route("/social/followers/{did}", get(api::social::get_followers)) + .route("/social/following/{did}", get(api::social::get_following)) + .route("/decks/{id}/comments", get(api::social::get_comments)) + .route("/feeds/trending", get(api::feed::get_feed_trending)) .layer(axum_middleware::from_fn(middleware::auth::optional_auth_middleware)); let oauth_routes = Router::new() diff --git a/crates/server/src/repository/mod.rs b/crates/server/src/repository/mod.rs index 7174806..f45c5d4 100644 --- a/crates/server/src/repository/mod.rs +++ b/crates/server/src/repository/mod.rs @@ -2,3 +2,4 @@ pub mod card; pub mod note; pub mod oauth; pub mod review; +pub mod social; diff --git a/crates/server/src/repository/social.rs b/crates/server/src/repository/social.rs new file mode 100644 index 0000000..aaaed39 --- /dev/null +++ b/crates/server/src/repository/social.rs @@ -0,0 +1,354 @@ +use async_trait::async_trait; +use chrono::Utc; +use malfestio_core::model::{Comment, Deck, Visibility}; + +use crate::db; + +#[derive(Debug)] +/// TODO: merge with core error type +pub enum SocialRepoError { + DatabaseError(String), + NotFound(String), +} + +#[async_trait] +pub trait SocialRepository: Send + Sync { + async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError>; + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError>; + async fn get_followers(&self, did: &str) -> Result, SocialRepoError>; + async fn get_following(&self, did: &str) -> Result, SocialRepoError>; + async fn add_comment( + &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, + ) -> Result; + async fn get_comments(&self, deck_id: &str) -> Result, SocialRepoError>; + async fn get_feed_follows(&self, user_did: &str) -> Result, SocialRepoError>; + async fn get_feed_trending(&self) -> Result, SocialRepoError>; +} + +pub struct DbSocialRepository { + pool: db::DbPool, +} + +impl DbSocialRepository { + pub fn new(pool: db::DbPool) -> Self { + Self { pool } + } + + fn parse_deck_rows(rows: Vec) -> Vec { + let mut decks = Vec::new(); + for row in rows { + let visibility_json: serde_json::Value = row.get("visibility"); + let visibility: Visibility = match serde_json::from_value(visibility_json) { + Ok(v) => v, + Err(e) => { + tracing::error!("Failed to deserialize visibility: {}", e); + continue; + } + }; + + let id: uuid::Uuid = row.get("id"); + let fork_of: Option = row.get("fork_of"); + + decks.push(Deck { + id: id.to_string(), + owner_did: row.get("owner_did"), + title: row.get("title"), + description: row.get("description"), + tags: row.get("tags"), + visibility, + published_at: row + .get::<_, Option>>("published_at") + .map(|dt| dt.to_rfc3339()), + fork_of: fork_of.map(|u| u.to_string()), + }); + } + decks + } +} + +#[async_trait] +impl SocialRepository for DbSocialRepository { + async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + client + .execute( + "INSERT INTO follows (follower_did, subject_did) VALUES ($1, $2) ON CONFLICT DO NOTHING", + &[&follower, &subject], + ) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to follow: {}", e)))?; + + Ok(()) + } + + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + client + .execute( + "DELETE FROM follows WHERE follower_did = $1 AND subject_did = $2", + &[&follower, &subject], + ) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to unfollow: {}", e)))?; + + Ok(()) + } + + async fn get_followers(&self, did: &str) -> Result, SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let rows = client + .query("SELECT follower_did FROM follows WHERE subject_did = $1", &[&did]) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get followers: {}", e)))?; + + Ok(rows.iter().map(|row| row.get("follower_did")).collect()) + } + + async fn get_following(&self, did: &str) -> Result, SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let rows = client + .query("SELECT subject_did FROM follows WHERE follower_did = $1", &[&did]) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get following: {}", e)))?; + + Ok(rows.iter().map(|row| row.get("subject_did")).collect()) + } + + async fn add_comment( + &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, + ) -> Result { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let deck_uuid = uuid::Uuid::parse_str(deck_id) + .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; + + let parent_uuid = parent_id + .map(uuid::Uuid::parse_str) + .transpose() + .map_err(|_| SocialRepoError::DatabaseError("Invalid parent ID".to_string()))?; + + let comment_id = uuid::Uuid::new_v4(); + let now = Utc::now(); + + client + .execute( + "INSERT INTO comments (id, deck_id, author_did, content, parent_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6)", + &[&comment_id, &deck_uuid, &author_did, &content, &parent_uuid, &now], + ) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to add comment: {}", e)))?; + + Ok(Comment { + id: comment_id.to_string(), + deck_id: deck_id.to_string(), + author_did: author_did.to_string(), + content: content.to_string(), + parent_id: parent_id.map(|s| s.to_string()), + created_at: now.to_rfc3339(), + }) + } + + async fn get_comments(&self, deck_id: &str) -> Result, SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let deck_uuid = uuid::Uuid::parse_str(deck_id) + .map_err(|_| SocialRepoError::DatabaseError("Invalid deck ID".to_string()))?; + + let rows = client + .query( + "SELECT id, deck_id, author_did, content, parent_id, created_at + FROM comments + WHERE deck_id = $1 + ORDER BY created_at ASC", + &[&deck_uuid], + ) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get comments: {}", e)))?; + + let mut comments = Vec::new(); + for row in rows { + let id: uuid::Uuid = row.get("id"); + let row_deck_id: uuid::Uuid = row.get("deck_id"); + let parent_id: Option = row.get("parent_id"); + let created_at: chrono::DateTime = row.get("created_at"); + + comments.push(Comment { + id: id.to_string(), + deck_id: row_deck_id.to_string(), + author_did: row.get("author_did"), + content: row.get("content"), + parent_id: parent_id.map(|u| u.to_string()), + created_at: created_at.to_rfc3339(), + }); + } + + Ok(comments) + } + + async fn get_feed_follows(&self, user_did: &str) -> Result, SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let query = " + SELECT d.id, d.owner_did, d.title, d.description, d.tags, d.visibility, d.published_at, d.fork_of + FROM decks d + JOIN follows f ON d.owner_did = f.subject_did + WHERE f.follower_did = $1 + AND d.published_at IS NOT NULL + AND d.visibility->>'type' != 'Private' + ORDER BY d.published_at DESC + LIMIT 50 + "; + + let rows = client + .query(query, &[&user_did]) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get feed: {}", e)))?; + + Ok(Self::parse_deck_rows(rows)) + } + + async fn get_feed_trending(&self) -> Result, SocialRepoError> { + let client = self + .pool + .get() + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get connection: {}", e)))?; + + let query = " + SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of + FROM decks + WHERE published_at IS NOT NULL + AND visibility->>'type' = 'Public' + ORDER BY published_at DESC + LIMIT 50 + "; + + let rows = client + .query(query, &[]) + .await + .map_err(|e| SocialRepoError::DatabaseError(format!("Failed to get trending: {}", e)))?; + + Ok(Self::parse_deck_rows(rows)) + } +} + +#[cfg(test)] +pub mod mock { + use super::*; + use std::sync::{Arc, Mutex}; + + pub struct MockSocialRepository { + /// (follower, subject) + pub followers: Arc>>, + pub comments: Arc>>, + } + + impl MockSocialRepository { + pub fn new() -> Self { + Self { followers: Arc::new(Mutex::new(Vec::new())), comments: Arc::new(Mutex::new(Vec::new())) } + } + } + + impl Default for MockSocialRepository { + fn default() -> Self { + Self::new() + } + } + + #[async_trait] + impl SocialRepository for MockSocialRepository { + async fn follow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { + let mut followers = self.followers.lock().unwrap(); + if !followers.contains(&(follower.to_string(), subject.to_string())) { + followers.push((follower.to_string(), subject.to_string())); + } + Ok(()) + } + + async fn unfollow(&self, follower: &str, subject: &str) -> Result<(), SocialRepoError> { + let mut followers = self.followers.lock().unwrap(); + followers.retain(|(f, s)| f != follower || s != subject); + Ok(()) + } + + async fn get_followers(&self, did: &str) -> Result, SocialRepoError> { + let followers = self.followers.lock().unwrap(); + Ok(followers + .iter() + .filter(|(_, s)| s == did) + .map(|(f, _)| f.clone()) + .collect()) + } + + async fn get_following(&self, did: &str) -> Result, SocialRepoError> { + let followers = self.followers.lock().unwrap(); + Ok(followers + .iter() + .filter(|(f, _)| f == did) + .map(|(_, s)| s.clone()) + .collect()) + } + + async fn add_comment( + &self, deck_id: &str, author_did: &str, content: &str, parent_id: Option<&str>, + ) -> Result { + let comment = Comment { + id: uuid::Uuid::new_v4().to_string(), + deck_id: deck_id.to_string(), + author_did: author_did.to_string(), + content: content.to_string(), + parent_id: parent_id.map(|s| s.to_string()), + created_at: Utc::now().to_rfc3339(), + }; + self.comments.lock().unwrap().push(comment.clone()); + Ok(comment) + } + + async fn get_comments(&self, deck_id: &str) -> Result, SocialRepoError> { + let comments = self.comments.lock().unwrap(); + Ok(comments.iter().filter(|c| c.deck_id == deck_id).cloned().collect()) + } + + /// Mock empty or predefined + async fn get_feed_follows(&self, _user_did: &str) -> Result, SocialRepoError> { + Ok(vec![]) + } + + async fn get_feed_trending(&self) -> Result, SocialRepoError> { + Ok(vec![]) + } + } +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 9e49495..49cdeae 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -3,6 +3,8 @@ use crate::repository::card::{CardRepository, DbCardRepository}; use crate::repository::note::{DbNoteRepository, NoteRepository}; use crate::repository::oauth::{DbOAuthRepository, OAuthRepository}; use crate::repository::review::{DbReviewRepository, ReviewRepository}; +use crate::repository::social::{DbSocialRepository, SocialRepository}; + use std::sync::Arc; pub type SharedState = Arc; @@ -13,6 +15,7 @@ pub struct AppState { pub note_repo: Arc, pub oauth_repo: Arc, pub review_repo: Arc, + pub social_repo: Arc, } impl AppState { @@ -21,8 +24,9 @@ impl AppState { let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc; let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc; let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc; + let social_repo = Arc::new(DbSocialRepository::new(pool.clone())) as Arc; - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) + Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) } #[cfg(test)] @@ -30,8 +34,9 @@ impl AppState { pool: DbPool, card_repo: Arc, note_repo: Arc, oauth_repo: Arc, ) -> SharedState { - let review_repo = - Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc; - Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo }) + use crate::repository; + let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc; + let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc; + Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo }) } } diff --git a/docs/core-user-journeys.md b/docs/core-user-journeys.md index b249582..210ddf3 100644 --- a/docs/core-user-journeys.md +++ b/docs/core-user-journeys.md @@ -1,11 +1,13 @@ # Core User Journeys -This document outlines the five core user journeys for the initial product version. +This document outlines the core user journeys and detailed user flows for Malfestio. ## 1. Import Source & Publish Deck **Goal**: A creator builds a study deck from an external resource and shares it. +### High-Level Workflow + 1. **Import**: User inputs a URL (Article) or pastes text. 2. **Generate**: System extracts metadata (and optionally snapshots content). 3. **Authoring**: @@ -16,10 +18,59 @@ This document outlines the five core user journeys for the initial product versi 5. **Publish**: User sets visibility (e.g., Public) and publishes the Deck. 6. **Result**: The Deck is now a shareable Artifact (ATProto record). +### Detailed Flows + +#### Content Import + +**Import Article**: + +1. Header → "Import" +2. Enter article URL +3. Submit → article parsed, deck/note created + +**Import Lecture**: + +1. Import page → "Lecture Import" tab +2. Enter lecture URL +3. Submit → lecture content extracted + +#### Note Management + +**Create Note**: + +1. Header → "Notes" → "New Note" +2. Fill: title, body (markdown), tags +3. Add wikilinks with `[[Note Title]]` +4. Set visibility +5. Submit → note created + +**View Notes**: + +1. Header → "Notes" +2. Browse notes with backlink navigation + +#### Deck Management + +**Create Deck**: + +1. Library (`/`) → "Create Deck" +2. Fill: title, description, tags +3. Set visibility (Private/Unlisted/Public/SharedWith) +4. Add cards (front/back, optional hints, card type) +5. Submit → deck created, redirected to Library + +**View Deck**: + +1. Library → click deck card +2. View title, description, tags, card list +3. Options: Edit, Study, Back to Library + ## 2. Daily Study Loop **Goal**: A learner maintains their knowledge using Spaced Repetition (SRS). +### High-Level Workflow + 1. **Session Start**: User opens the app/daily study mode. 2. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2). 3. **Interaction**: @@ -31,10 +82,52 @@ This document outlines the five core user journeys for the initial product versi 6. **Progress**: User sees feedback (cards done, streak incremented). * *Note: All grading/progress data is strictly private.* +### Detailed Flows + +#### Daily Review + +1. Navigate to `/review` or click "Review" in header +2. View study stats: due count, streak, reviewed today +3. Click "Start Study Session" +4. Card front shown → press **Space** to flip +5. View answer → grade with **1-5** keys +6. Repeat until all due cards complete +7. View completion message and updated stats + +#### Deck-Specific Review + +1. Navigate to deck view (`/decks/:id`) +2. Click "Study Deck" +3. Review only cards from that deck +4. Same keyboard controls apply + +#### Progress Tracking + +* **Due count**: Cards needing review today + +* **Streak**: Consecutive days studied +* **Reviewed today**: Cards completed this session +* **Interval growth**: SM-2 algorithm increases intervals for mastered cards + +#### Keyboard Shortcuts + +| Key | Action | +| ----- | -------------- | +| Space | Flip card | +| 1 | Grade: Again | +| 2 | Grade: Hard | +| 3 | Grade: Good | +| 4 | Grade: Easy | +| 5 | Grade: Perfect | +| E | Quick edit | +| Esc | Exit session | + ## 3. Social Collaboration (Follow/Fork) **Goal**: A learner discovers content and improves it. +### High-Level Workflow + 1. **Discovery**: * User follows a Curator. * User sees a new Deck in their "New from Follows" feed. @@ -50,6 +143,8 @@ This document outlines the five core user journeys for the initial product versi **Goal**: Community interaction while maintaining safety. +### High-Level Workflow + 1. **Context**: A User is viewing a public Card or Deck. 2. **Discuss**: User adds a **Comment** (threaded) asking for clarification. 3. **Report** (Unhappy Path): @@ -62,6 +157,8 @@ This document outlines the five core user journeys for the initial product versi **Goal**: Deep study of long-form audio/video content. +### High-Level Workflow + 1. **Import**: User provides a Lecture URL (e.g., YouTube/Video). 2. **Structure**: * User creates an **Outline** of the lecture. @@ -69,3 +166,16 @@ This document outlines the five core user journeys for the initial product versi 3. **Link**: * User creates Cards specific to timestamped segments. * Clicking context on a Card jumps video to the specific timestamp. + +## Authentication + +### Login + +1. Navigate to `/login` +2. Enter Bluesky handle and app password +3. Submit → redirected to Library + +### Logout + +1. Click avatar in header → "Logout" +2. → redirected to Landing page diff --git a/docs/todo.md b/docs/todo.md index 0164958..bc7c594 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -49,32 +49,11 @@ - OAuth 2.1 client flow (PKCE, DPoP, handle/DID resolution, token refresh). - PDS client for `putRecord`, `deleteRecord`, `uploadBlob`. - TID generation and AT-URI builder in core crate. - - Database migration for token storage and AT-URI columns. - **(Done) Milestone E**: Internal component library/UI Foundation + Animations. - **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder). - - **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX. - SM-2 spaced repetition scheduler. - - Review repository with due card queries and stats tracking. - - API endpoints: `/review/due`, `/review/submit`, `/review/stats`. - - StudySession component with keyboard-first review. - - ReviewStats component for progress display. - -### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments) - -#### Deliverables - -- Follow graph + notifications -- Feeds: - - "New decks from follows" - - "Trending this week" (simple scoring) -- Forking workflow: - - fork deck -> edit -> republish -- Threaded comments on decks/cards - -#### Acceptance - -- A user can follow a curator and see new published decks in a feed. +- **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments. ### Milestone I - Search + Discovery + Taxonomy @@ -95,6 +74,7 @@ #### Deliverables +- Look into [Ozone](https://github.com/bluesky-social/ozone) - Reporting pipeline + review queue - Rate limits + spam heuristics - Takedown/visibility states (shadowed, removed, quarantined) @@ -133,14 +113,6 @@ - Load test targets (study session + feed + search) - Beta program + feedback loop + roadmap iteration -#### Acceptance - -- You can run this as a real product with confidence. - -## Lexicon Definitions - -Authoritative Lexicon definitions are located in the [`lexicons/`](../lexicons) directory. - ## Open Questions (Parked Decisions) - Local-first mechanics: full offline authoring + later publish, or online-only creation? diff --git a/docs/user-flows.md b/docs/user-flows.md deleted file mode 100644 index 82375cd..0000000 --- a/docs/user-flows.md +++ /dev/null @@ -1,107 +0,0 @@ -# User Flows - -User experience pathways for Malfestio. - -## Authentication - -### Login - -1. Navigate to `/login` -2. Enter Bluesky handle and app password -3. Submit → redirected to Library - -### Logout - -1. Click avatar in header → "Logout" -2. → redirected to Landing page - -## Deck Management - -### Create Deck - -1. Library (`/`) → "Create Deck" -2. Fill: title, description, tags -3. Set visibility (Private/Unlisted/Public/SharedWith) -4. Add cards (front/back, optional hints, card type) -5. Submit → deck created, redirected to Library - -### View Deck - -1. Library → click deck card -2. View title, description, tags, card list -3. Options: Edit, Study, Back to Library - -### Study Deck - -1. Deck View → "Study Deck" -2. Study session with keyboard controls -3. Grade cards (1-5), view progress -4. Session complete → return to deck - -## Note Management - -### Create Note - -1. Header → "Notes" → "New Note" -2. Fill: title, body (markdown), tags -3. Add wikilinks with `[[Note Title]]` -4. Set visibility -5. Submit → note created - -### View Notes - -1. Header → "Notes" -2. Browse notes with backlink navigation - -## Content Import - -### Import Article - -1. Header → "Import" -2. Enter article URL -3. Submit → article parsed, deck/note created - -### Import Lecture - -1. Import page → "Lecture Import" tab -2. Enter lecture URL -3. Submit → lecture content extracted - -## Study Session - -### Daily Review - -1. Navigate to `/review` or click "Review" in header -2. View study stats: due count, streak, reviewed today -3. Click "Start Study Session" -4. Card front shown → press **Space** to flip -5. View answer → grade with **1-5** keys -6. Repeat until all due cards complete -7. View completion message and updated stats - -### Deck-Specific Review - -1. Navigate to deck view (`/decks/:id`) -2. Click "Study Deck" -3. Review only cards from that deck -4. Same keyboard controls apply - -### Progress Tracking - -- **Due count**: Cards needing review today -- **Streak**: Consecutive days studied -- **Reviewed today**: Cards completed this session -- **Interval growth**: SM-2 algorithm increases intervals for mastered cards - -### Keyboard Shortcuts - -| Key | Action | -| ----- | -------------- | -| Space | Flip card | -| 1 | Grade: Again | -| 2 | Grade: Hard | -| 3 | Grade: Good | -| 4 | Grade: Easy | -| 5 | Grade: Perfect | -| E | Quick edit | -| Esc | Exit session | diff --git a/migrations/005_2025_12_30_social_layer.sql b/migrations/005_2025_12_30_social_layer.sql new file mode 100644 index 0000000..72b8dcd --- /dev/null +++ b/migrations/005_2025_12_30_social_layer.sql @@ -0,0 +1,32 @@ +-- Social Layer: Follows and Comments +-- Implements Milestone H requirements + +-- Follows table: User A follows User B +CREATE TABLE follows ( + follower_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (follower_did, subject_did) +); + +CREATE INDEX idx_follows_follower ON follows(follower_did); +CREATE INDEX idx_follows_subject_did ON follows(subject_did); + +-- Comments table: Threaded comments on Decks (and potentially Cards in future) +CREATE TABLE comments ( + id UUID PRIMARY KEY, + deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + content TEXT NOT NULL, + parent_id UUID REFERENCES comments(id) ON DELETE CASCADE, -- For threading + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_comments_deck_id ON comments(deck_id); +CREATE INDEX idx_comments_parent_id ON comments(parent_id); +CREATE INDEX idx_comments_author_did ON comments(author_did); +CREATE INDEX idx_comments_created_at ON comments(created_at); + +CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/web/src/App.tsx b/web/src/App.tsx index ea85bac..216724b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,7 @@ import { AppLayout } from "$components/layout/AppLayout"; import { authStore } from "$lib/store"; import DeckNew from "$pages/DeckNew"; import DeckView from "$pages/DeckView"; +import Feed from "$pages/Feed"; import Home from "$pages/Home"; import Import from "$pages/Import"; import Landing from "$pages/Landing"; @@ -37,6 +38,7 @@ const App: Component = () => { } /> } /> } /> + } /> } /> ); diff --git a/web/src/components/social/CommentSection.tsx b/web/src/components/social/CommentSection.tsx new file mode 100644 index 0000000..5d30c51 --- /dev/null +++ b/web/src/components/social/CommentSection.tsx @@ -0,0 +1,116 @@ +import { api } from "$lib/api"; +import { authStore } from "$lib/store"; +import { Button } from "$ui/Button"; +import { createResource, createSignal, For, Show } from "solid-js"; + +type Comment = { + id: string; + deck_id: string; + author_did: string; + content: string; + parent_id: string | null; + created_at: string; +}; + +type CommentNode = { comment: Comment; children: CommentNode[] }; + +type CommentSectionProps = { deckId: string }; + +function buildTree(comments: Comment[]): CommentNode[] { + const map = new Map(); + const roots: CommentNode[] = []; + + for (const c of comments) { + map.set(c.id, { comment: c, children: [] }); + } + + for (const c of comments) { + if (c.parent_id && map.has(c.parent_id)) { + map.get(c.parent_id)!.children.push(map.get(c.id)!); + } else { + roots.push(map.get(c.id)!); + } + } + return roots; +} + +export function CommentSection(props: CommentSectionProps) { + const [comments, { refetch }] = createResource(async () => { + const res = await api.getComments(props.deckId); + if (res.ok) { + return (await res.json()) as Comment[]; + } + return []; + }); + + const [newComment, setNewComment] = createSignal(""); + const [replyTo, setReplyTo] = createSignal(null); + + const submitComment = async (parentId?: string) => { + if (!newComment().trim()) return; + await api.addComment(props.deckId, newComment(), parentId); + setNewComment(""); + setReplyTo(null); + refetch(); + }; + + const CommentItem = (node: { node: CommentNode }) => ( +
+
{node.node.comment.author_did}
+
{node.node.comment.content}
+
+ {new Date(node.node.comment.created_at).toLocaleString()} + +
+ + +
+ setNewComment(e.currentTarget.value)} + placeholder="Write a reply..." /> + + +
+
+ + {(child) => } +
+ ); + + return ( +
+

Comments

+ + +
+