···182182 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
183183) -> impl IntoResponse {
184184 let user_did = ctx.map(|Extension(u)| u.did);
185185-186185 let pool = &state.pool;
187186 let client = match pool.get().await {
188187 Ok(client) => client,
···428427 }
429428 }
430429 } else {
431431- // Unpublish - just update local visibility
432430 let (new_visibility, published_at) = (
433431 serde_json::to_value(&Visibility::Private).unwrap(),
434432 None::<chrono::DateTime<chrono::Utc>>,
···461459 Json(deck).into_response()
462460 }
463461}
462462+463463+pub async fn fork_deck(
464464+ State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>,
465465+) -> impl IntoResponse {
466466+ let user = match ctx {
467467+ Some(axum::Extension(user)) => user,
468468+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
469469+ };
470470+471471+ let pool = &state.pool;
472472+ let mut client = match pool.get().await {
473473+ Ok(client) => client,
474474+ Err(e) => {
475475+ tracing::error!("Failed to get database connection: {}", e);
476476+ return (
477477+ StatusCode::INTERNAL_SERVER_ERROR,
478478+ Json(json!({"error": "Database connection failed"})),
479479+ )
480480+ .into_response();
481481+ }
482482+ };
483483+484484+ let original_deck_uuid = match uuid::Uuid::parse_str(&id) {
485485+ Ok(uuid) => uuid,
486486+ Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(),
487487+ };
488488+489489+ let transaction = match client.transaction().await {
490490+ Ok(tx) => tx,
491491+ Err(e) => {
492492+ tracing::error!("Failed to start transaction: {}", e);
493493+ return (
494494+ StatusCode::INTERNAL_SERVER_ERROR,
495495+ Json(json!({"error": "Database error"})),
496496+ )
497497+ .into_response();
498498+ }
499499+ };
500500+501501+ let original_deck_row = match transaction
502502+ .query_opt(
503503+ "SELECT owner_did, title, description, tags, visibility FROM decks WHERE id = $1",
504504+ &[&original_deck_uuid],
505505+ )
506506+ .await
507507+ {
508508+ Ok(Some(row)) => row,
509509+ Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(),
510510+ Err(e) => {
511511+ tracing::error!("Failed to query deck: {}", e);
512512+ return (
513513+ StatusCode::INTERNAL_SERVER_ERROR,
514514+ Json(json!({"error": "Failed to retrieve deck"})),
515515+ )
516516+ .into_response();
517517+ }
518518+ };
519519+520520+ let visibility_json: serde_json::Value = original_deck_row.get("visibility");
521521+ let visibility: Visibility = serde_json::from_value(visibility_json).unwrap_or(Visibility::Private);
522522+523523+ let can_fork = match visibility {
524524+ Visibility::Public | Visibility::Unlisted => true,
525525+ Visibility::SharedWith(dids) => dids.contains(&user.did),
526526+ Visibility::Private => {
527527+ let owner: String = original_deck_row.get("owner_did");
528528+ owner == user.did
529529+ }
530530+ };
531531+532532+ if !can_fork {
533533+ return (
534534+ StatusCode::FORBIDDEN,
535535+ Json(json!({"error": "Cannot fork private deck"})),
536536+ )
537537+ .into_response();
538538+ }
539539+540540+ let new_deck_id = uuid::Uuid::new_v4();
541541+ let title: String = original_deck_row.get("title");
542542+ let description: String = original_deck_row.get("description");
543543+ let tags: Vec<String> = original_deck_row.get("tags");
544544+545545+ if let Err(e) = transaction
546546+ .execute(
547547+ "INSERT INTO decks (id, owner_did, title, description, tags, visibility, fork_of)
548548+ VALUES ($1, $2, $3, $4, $5, $6, $7)",
549549+ &[
550550+ &new_deck_id,
551551+ &user.did,
552552+ &format!("Fork of {}", title),
553553+ &description,
554554+ &tags,
555555+ &serde_json::to_value(&Visibility::Private).unwrap(),
556556+ &original_deck_uuid,
557557+ ],
558558+ )
559559+ .await
560560+ {
561561+ tracing::error!("Failed to create forked deck: {}", e);
562562+ return (
563563+ StatusCode::INTERNAL_SERVER_ERROR,
564564+ Json(json!({"error": "Failed to create deck"})),
565565+ )
566566+ .into_response();
567567+ }
568568+569569+ let original_cards = match transaction
570570+ .query(
571571+ "SELECT front, back, media_url FROM cards WHERE deck_id = $1",
572572+ &[&original_deck_uuid],
573573+ )
574574+ .await
575575+ {
576576+ Ok(rows) => rows,
577577+ Err(e) => {
578578+ tracing::error!("Failed to fetch original cards: {}", e);
579579+ return (
580580+ StatusCode::INTERNAL_SERVER_ERROR,
581581+ Json(json!({"error": "Failed to fetch cards"})),
582582+ )
583583+ .into_response();
584584+ }
585585+ };
586586+587587+ for row in original_cards {
588588+ let card_id = uuid::Uuid::new_v4();
589589+ let front: String = row.get("front");
590590+ let back: String = row.get("back");
591591+ let media_url: Option<String> = row.get("media_url");
592592+593593+ if let Err(e) = transaction
594594+ .execute(
595595+ "INSERT INTO cards (id, owner_did, deck_id, front, back, media_url)
596596+ VALUES ($1, $2, $3, $4, $5, $6)",
597597+ &[&card_id, &user.did, &new_deck_id, &front, &back, &media_url],
598598+ )
599599+ .await
600600+ {
601601+ tracing::error!("Failed to fork card: {}", e);
602602+ return (
603603+ StatusCode::INTERNAL_SERVER_ERROR,
604604+ Json(json!({"error": "Failed to fork cards"})),
605605+ )
606606+ .into_response();
607607+ }
608608+ }
609609+610610+ if let Err(e) = transaction.commit().await {
611611+ tracing::error!("Failed to commit transaction: {}", e);
612612+ return (
613613+ StatusCode::INTERNAL_SERVER_ERROR,
614614+ Json(json!({"error": "Transaction failed"})),
615615+ )
616616+ .into_response();
617617+ }
618618+619619+ let deck = Deck {
620620+ id: new_deck_id.to_string(),
621621+ owner_did: user.did,
622622+ title: format!("Fork of {}", title),
623623+ description,
624624+ tags,
625625+ visibility: Visibility::Private,
626626+ published_at: None,
627627+ fork_of: Some(id),
628628+ };
629629+630630+ (StatusCode::CREATED, Json(deck)).into_response()
631631+}
+87
crates/server/src/api/feed.rs
···11+use crate::middleware::auth::UserContext;
22+use crate::state::SharedState;
33+44+use axum::{
55+ Json,
66+ extract::{Extension, State},
77+ http::StatusCode,
88+ response::IntoResponse,
99+};
1010+use serde_json::json;
1111+1212+pub async fn get_feed_follows(
1313+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>,
1414+) -> impl IntoResponse {
1515+ let user = match ctx {
1616+ Some(Extension(user)) => user,
1717+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
1818+ };
1919+2020+ match state.social_repo.get_feed_follows(&user.did).await {
2121+ Ok(decks) => Json(decks).into_response(),
2222+ Err(e) => {
2323+ tracing::error!("Failed to get feed: {:?}", e);
2424+ (
2525+ StatusCode::INTERNAL_SERVER_ERROR,
2626+ Json(json!({"error": "Failed to retrieve feed"})),
2727+ )
2828+ .into_response()
2929+ }
3030+ }
3131+}
3232+3333+pub async fn get_feed_trending(State(state): State<SharedState>) -> impl IntoResponse {
3434+ match state.social_repo.get_feed_trending().await {
3535+ Ok(decks) => Json(decks).into_response(),
3636+ Err(e) => {
3737+ tracing::error!("Failed to get trending: {:?}", e);
3838+ (
3939+ StatusCode::INTERNAL_SERVER_ERROR,
4040+ Json(json!({"error": "Failed to retrieve trending feed"})),
4141+ )
4242+ .into_response()
4343+ }
4444+ }
4545+}
4646+4747+#[cfg(test)]
4848+mod tests {
4949+ use super::*;
5050+ use crate::repository::card::mock::MockCardRepository;
5151+ use crate::repository::note::mock::MockNoteRepository;
5252+ use crate::repository::oauth::mock::MockOAuthRepository;
5353+ use crate::repository::review::mock::MockReviewRepository;
5454+ use crate::repository::social::{SocialRepository, mock::MockSocialRepository};
5555+ use crate::state::AppState;
5656+ use std::sync::Arc;
5757+5858+ fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState {
5959+ let pool = crate::db::create_mock_pool();
6060+ let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
6161+ let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
6262+ let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
6363+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>;
6464+6565+ Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
6666+ }
6767+6868+ #[tokio::test]
6969+ async fn test_get_feed_follows_success() {
7070+ let social_repo = Arc::new(MockSocialRepository::new());
7171+ let state = create_test_state_with_social(social_repo);
7272+ let user = UserContext { did: "did:plc:test".to_string(), handle: "test.handle".to_string() };
7373+ let response = get_feed_follows(State(state), Some(Extension(user)))
7474+ .await
7575+ .into_response();
7676+7777+ assert_eq!(response.status(), StatusCode::OK);
7878+ }
7979+8080+ #[tokio::test]
8181+ async fn test_get_feed_trending_success() {
8282+ let social_repo = Arc::new(MockSocialRepository::new());
8383+ let state = create_test_state_with_social(social_repo);
8484+ let response = get_feed_trending(State(state)).await.into_response();
8585+ assert_eq!(response.status(), StatusCode::OK);
8686+ }
8787+}
+2
crates/server/src/api/mod.rs
···11pub mod auth;
22pub mod card;
33pub mod deck;
44+pub mod feed;
45pub mod importer;
56pub mod note;
67pub mod oauth;
78pub mod review;
99+pub mod social;
+3-1
crates/server/src/api/review.rs
···147147 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
148148 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
149149 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
150150+ let social_repo = Arc::new(crate::repository::social::mock::MockSocialRepository::new())
151151+ as Arc<dyn crate::repository::social::SocialRepository>;
150152151151- Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo })
153153+ Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
152154 }
153155154156 #[tokio::test]
+267
crates/server/src/api/social.rs
···11+use crate::middleware::auth::UserContext;
22+use crate::repository::social::SocialRepoError;
33+use crate::state::SharedState;
44+55+use axum::{
66+ Json,
77+ extract::{Extension, Path, State},
88+ http::StatusCode,
99+ response::IntoResponse,
1010+};
1111+use serde::Deserialize;
1212+use serde_json::json;
1313+1414+#[derive(Deserialize)]
1515+pub struct AddCommentRequest {
1616+ pub content: String,
1717+ pub parent_id: Option<String>,
1818+}
1919+2020+pub async fn follow(
2121+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>,
2222+) -> impl IntoResponse {
2323+ let user = match ctx {
2424+ Some(Extension(user)) => user,
2525+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
2626+ };
2727+2828+ let result = state.social_repo.follow(&user.did, &subject_did).await;
2929+3030+ match result {
3131+ Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(),
3232+ Err(SocialRepoError::DatabaseError(msg)) => {
3333+ tracing::error!("Database error: {}", msg);
3434+ (
3535+ StatusCode::INTERNAL_SERVER_ERROR,
3636+ Json(json!({"error": "Failed to follow"})),
3737+ )
3838+ .into_response()
3939+ }
4040+ Err(e) => (
4141+ StatusCode::INTERNAL_SERVER_ERROR,
4242+ Json(json!({"error": format!("{:?}", e)})),
4343+ )
4444+ .into_response(),
4545+ }
4646+}
4747+4848+pub async fn unfollow(
4949+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>,
5050+) -> impl IntoResponse {
5151+ let user = match ctx {
5252+ Some(Extension(user)) => user,
5353+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
5454+ };
5555+5656+ let result = state.social_repo.unfollow(&user.did, &subject_did).await;
5757+5858+ match result {
5959+ Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(),
6060+ Err(SocialRepoError::DatabaseError(msg)) => {
6161+ tracing::error!("Database error: {}", msg);
6262+ (
6363+ StatusCode::INTERNAL_SERVER_ERROR,
6464+ Json(json!({"error": "Failed to unfollow"})),
6565+ )
6666+ .into_response()
6767+ }
6868+ Err(e) => (
6969+ StatusCode::INTERNAL_SERVER_ERROR,
7070+ Json(json!({"error": format!("{:?}", e)})),
7171+ )
7272+ .into_response(),
7373+ }
7474+}
7575+7676+pub async fn get_followers(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse {
7777+ let result = state.social_repo.get_followers(&did).await;
7878+7979+ match result {
8080+ Ok(followers) => Json(followers).into_response(),
8181+ Err(SocialRepoError::DatabaseError(msg)) => {
8282+ tracing::error!("Database error: {}", msg);
8383+ (
8484+ StatusCode::INTERNAL_SERVER_ERROR,
8585+ Json(json!({"error": "Failed to get followers"})),
8686+ )
8787+ .into_response()
8888+ }
8989+ Err(e) => (
9090+ StatusCode::INTERNAL_SERVER_ERROR,
9191+ Json(json!({"error": format!("{:?}", e)})),
9292+ )
9393+ .into_response(),
9494+ }
9595+}
9696+9797+pub async fn get_following(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse {
9898+ let result = state.social_repo.get_following(&did).await;
9999+100100+ match result {
101101+ Ok(following) => Json(following).into_response(),
102102+ Err(SocialRepoError::DatabaseError(msg)) => {
103103+ tracing::error!("Database error: {}", msg);
104104+ (
105105+ StatusCode::INTERNAL_SERVER_ERROR,
106106+ Json(json!({"error": "Failed to get following"})),
107107+ )
108108+ .into_response()
109109+ }
110110+ Err(e) => (
111111+ StatusCode::INTERNAL_SERVER_ERROR,
112112+ Json(json!({"error": format!("{:?}", e)})),
113113+ )
114114+ .into_response(),
115115+ }
116116+}
117117+118118+pub async fn add_comment(
119119+ State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(deck_id): Path<String>,
120120+ Json(payload): Json<AddCommentRequest>,
121121+) -> impl IntoResponse {
122122+ let user = match ctx {
123123+ Some(Extension(user)) => user,
124124+ None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(),
125125+ };
126126+127127+ let result = state
128128+ .social_repo
129129+ .add_comment(&deck_id, &user.did, &payload.content, payload.parent_id.as_deref())
130130+ .await;
131131+132132+ match result {
133133+ Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(),
134134+ Err(SocialRepoError::DatabaseError(msg)) => {
135135+ tracing::error!("Database error: {}", msg);
136136+ (
137137+ StatusCode::INTERNAL_SERVER_ERROR,
138138+ Json(json!({"error": "Failed to add comment"})),
139139+ )
140140+ .into_response()
141141+ }
142142+ Err(e) => (
143143+ StatusCode::INTERNAL_SERVER_ERROR,
144144+ Json(json!({"error": format!("{:?}", e)})),
145145+ )
146146+ .into_response(),
147147+ }
148148+}
149149+150150+pub async fn get_comments(State(state): State<SharedState>, Path(deck_id): Path<String>) -> impl IntoResponse {
151151+ let result = state.social_repo.get_comments(&deck_id).await;
152152+153153+ match result {
154154+ Ok(comments) => Json(comments).into_response(),
155155+ Err(SocialRepoError::DatabaseError(msg)) => {
156156+ tracing::error!("Database error: {}", msg);
157157+ (
158158+ StatusCode::INTERNAL_SERVER_ERROR,
159159+ Json(json!({"error": "Failed to get comments"})),
160160+ )
161161+ .into_response()
162162+ }
163163+ Err(e) => (
164164+ StatusCode::INTERNAL_SERVER_ERROR,
165165+ Json(json!({"error": format!("{:?}", e)})),
166166+ )
167167+ .into_response(),
168168+ }
169169+}
170170+171171+#[cfg(test)]
172172+mod tests {
173173+ use super::*;
174174+ use crate::middleware::auth::UserContext;
175175+ use crate::repository::card::mock::MockCardRepository;
176176+ use crate::repository::note::mock::MockNoteRepository;
177177+ use crate::repository::oauth::mock::MockOAuthRepository;
178178+ use crate::repository::review::mock::MockReviewRepository;
179179+ use crate::repository::social::{SocialRepository, mock::MockSocialRepository};
180180+ use crate::state::AppState;
181181+ use axum::extract::Json;
182182+ use std::sync::Arc;
183183+184184+ fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState {
185185+ let pool = crate::db::create_mock_pool();
186186+ let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
187187+ let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
188188+ let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
189189+ let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>;
190190+191191+ Arc::new(AppState { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
192192+ }
193193+194194+ #[tokio::test]
195195+ async fn test_follow_success() {
196196+ let social_repo = Arc::new(MockSocialRepository::new());
197197+ let state = create_test_state_with_social(social_repo.clone());
198198+ let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() };
199199+200200+ let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
201201+ .await
202202+ .into_response();
203203+204204+ assert_eq!(response.status(), StatusCode::OK);
205205+206206+ let followers = social_repo.get_followers("did:plc:subject").await.unwrap();
207207+ assert!(followers.contains(&"did:plc:follower".to_string()));
208208+ }
209209+210210+ #[tokio::test]
211211+ async fn test_unfollow_success() {
212212+ let social_repo = Arc::new(MockSocialRepository::new());
213213+ social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap();
214214+215215+ let state = create_test_state_with_social(social_repo.clone());
216216+ let user = UserContext { did: "did:plc:follower".to_string(), handle: "follower".to_string() };
217217+218218+ let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string()))
219219+ .await
220220+ .into_response();
221221+222222+ assert_eq!(response.status(), StatusCode::OK);
223223+224224+ let followers = social_repo.get_followers("did:plc:subject").await.unwrap();
225225+ assert!(followers.is_empty());
226226+ }
227227+228228+ #[tokio::test]
229229+ async fn test_get_followers() {
230230+ let social_repo = Arc::new(MockSocialRepository::new());
231231+ social_repo.follow("did:plc:1", "did:plc:subject").await.unwrap();
232232+ social_repo.follow("did:plc:2", "did:plc:subject").await.unwrap();
233233+234234+ let state = create_test_state_with_social(social_repo);
235235+236236+ let response = get_followers(State(state), Path("did:plc:subject".to_string()))
237237+ .await
238238+ .into_response();
239239+240240+ assert_eq!(response.status(), StatusCode::OK);
241241+ // TODO: parse body to verify content
242242+ }
243243+244244+ #[tokio::test]
245245+ async fn test_add_comment_success() {
246246+ let social_repo = Arc::new(MockSocialRepository::new());
247247+ let state = create_test_state_with_social(social_repo.clone());
248248+ let user = UserContext { did: "did:plc:author".to_string(), handle: "author".to_string() };
249249+250250+ let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None };
251251+252252+ let response = add_comment(
253253+ State(state),
254254+ Some(Extension(user)),
255255+ Path("deck-1".to_string()),
256256+ Json(payload),
257257+ )
258258+ .await
259259+ .into_response();
260260+261261+ assert_eq!(response.status(), StatusCode::CREATED);
262262+263263+ let comments = social_repo.get_comments("deck-1").await.unwrap();
264264+ assert_eq!(comments.len(), 1);
265265+ assert_eq!(comments[0].content, "Great deck!");
266266+ }
267267+}
···33use crate::repository::note::{DbNoteRepository, NoteRepository};
44use crate::repository::oauth::{DbOAuthRepository, OAuthRepository};
55use crate::repository::review::{DbReviewRepository, ReviewRepository};
66+use crate::repository::social::{DbSocialRepository, SocialRepository};
77+68use std::sync::Arc;
79810pub type SharedState = Arc<AppState>;
···1315 pub note_repo: Arc<dyn NoteRepository>,
1416 pub oauth_repo: Arc<dyn OAuthRepository>,
1517 pub review_repo: Arc<dyn ReviewRepository>,
1818+ pub social_repo: Arc<dyn SocialRepository>,
1619}
17201821impl AppState {
···2124 let note_repo = Arc::new(DbNoteRepository::new(pool.clone())) as Arc<dyn NoteRepository>;
2225 let oauth_repo = Arc::new(DbOAuthRepository::new(pool.clone())) as Arc<dyn OAuthRepository>;
2326 let review_repo = Arc::new(DbReviewRepository::new(pool.clone())) as Arc<dyn ReviewRepository>;
2727+ let social_repo = Arc::new(DbSocialRepository::new(pool.clone())) as Arc<dyn SocialRepository>;
24282525- Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
2929+ Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
2630 }
27312832 #[cfg(test)]
···3034 pool: DbPool, card_repo: Arc<dyn CardRepository>, note_repo: Arc<dyn NoteRepository>,
3135 oauth_repo: Arc<dyn OAuthRepository>,
3236 ) -> SharedState {
3333- let review_repo =
3434- Arc::new(crate::repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
3535- Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo })
3737+ use crate::repository;
3838+ let review_repo = Arc::new(repository::review::mock::MockReviewRepository::new()) as Arc<dyn ReviewRepository>;
3939+ let social_repo = Arc::new(repository::social::mock::MockSocialRepository::new()) as Arc<dyn SocialRepository>;
4040+ Arc::new(Self { pool, card_repo, note_repo, oauth_repo, review_repo, social_repo })
3641 }
3742}
+111-1
docs/core-user-journeys.md
···11# Core User Journeys
2233-This document outlines the five core user journeys for the initial product version.
33+This document outlines the core user journeys and detailed user flows for Malfestio.
4455## 1. Import Source & Publish Deck
6677**Goal**: A creator builds a study deck from an external resource and shares it.
88+99+### High-Level Workflow
8109111. **Import**: User inputs a URL (Article) or pastes text.
10122. **Generate**: System extracts metadata (and optionally snapshots content).
···16185. **Publish**: User sets visibility (e.g., Public) and publishes the Deck.
17196. **Result**: The Deck is now a shareable Artifact (ATProto record).
18202121+### Detailed Flows
2222+2323+#### Content Import
2424+2525+**Import Article**:
2626+2727+1. Header → "Import"
2828+2. Enter article URL
2929+3. Submit → article parsed, deck/note created
3030+3131+**Import Lecture**:
3232+3333+1. Import page → "Lecture Import" tab
3434+2. Enter lecture URL
3535+3. Submit → lecture content extracted
3636+3737+#### Note Management
3838+3939+**Create Note**:
4040+4141+1. Header → "Notes" → "New Note"
4242+2. Fill: title, body (markdown), tags
4343+3. Add wikilinks with `[[Note Title]]`
4444+4. Set visibility
4545+5. Submit → note created
4646+4747+**View Notes**:
4848+4949+1. Header → "Notes"
5050+2. Browse notes with backlink navigation
5151+5252+#### Deck Management
5353+5454+**Create Deck**:
5555+5656+1. Library (`/`) → "Create Deck"
5757+2. Fill: title, description, tags
5858+3. Set visibility (Private/Unlisted/Public/SharedWith)
5959+4. Add cards (front/back, optional hints, card type)
6060+5. Submit → deck created, redirected to Library
6161+6262+**View Deck**:
6363+6464+1. Library → click deck card
6565+2. View title, description, tags, card list
6666+3. Options: Edit, Study, Back to Library
6767+1968## 2. Daily Study Loop
20692170**Goal**: A learner maintains their knowledge using Spaced Repetition (SRS).
7171+7272+### High-Level Workflow
227323741. **Session Start**: User opens the app/daily study mode.
24752. **Review Queue**: System presents cards due for review based on SRS algorithm (e.g., SM-2).
···31826. **Progress**: User sees feedback (cards done, streak incremented).
3283 * *Note: All grading/progress data is strictly private.*
33848585+### Detailed Flows
8686+8787+#### Daily Review
8888+8989+1. Navigate to `/review` or click "Review" in header
9090+2. View study stats: due count, streak, reviewed today
9191+3. Click "Start Study Session"
9292+4. Card front shown → press **Space** to flip
9393+5. View answer → grade with **1-5** keys
9494+6. Repeat until all due cards complete
9595+7. View completion message and updated stats
9696+9797+#### Deck-Specific Review
9898+9999+1. Navigate to deck view (`/decks/:id`)
100100+2. Click "Study Deck"
101101+3. Review only cards from that deck
102102+4. Same keyboard controls apply
103103+104104+#### Progress Tracking
105105+106106+* **Due count**: Cards needing review today
107107+108108+* **Streak**: Consecutive days studied
109109+* **Reviewed today**: Cards completed this session
110110+* **Interval growth**: SM-2 algorithm increases intervals for mastered cards
111111+112112+#### Keyboard Shortcuts
113113+114114+| Key | Action |
115115+| ----- | -------------- |
116116+| Space | Flip card |
117117+| 1 | Grade: Again |
118118+| 2 | Grade: Hard |
119119+| 3 | Grade: Good |
120120+| 4 | Grade: Easy |
121121+| 5 | Grade: Perfect |
122122+| E | Quick edit |
123123+| Esc | Exit session |
124124+34125## 3. Social Collaboration (Follow/Fork)
3512636127**Goal**: A learner discovers content and improves it.
128128+129129+### High-Level Workflow
37130381311. **Discovery**:
39132 * User follows a Curator.
···5014351144**Goal**: Community interaction while maintaining safety.
52145146146+### High-Level Workflow
147147+531481. **Context**: A User is viewing a public Card or Deck.
541492. **Discuss**: User adds a **Comment** (threaded) asking for clarification.
551503. **Report** (Unhappy Path):
···6215763158**Goal**: Deep study of long-form audio/video content.
64159160160+### High-Level Workflow
161161+651621. **Import**: User provides a Lecture URL (e.g., YouTube/Video).
661632. **Structure**:
67164 * User creates an **Outline** of the lecture.
···691663. **Link**:
70167 * User creates Cards specific to timestamped segments.
71168 * Clicking context on a Card jumps video to the specific timestamp.
169169+170170+## Authentication
171171+172172+### Login
173173+174174+1. Navigate to `/login`
175175+2. Enter Bluesky handle and app password
176176+3. Submit → redirected to Library
177177+178178+### Logout
179179+180180+1. Click avatar in header → "Logout"
181181+2. → redirected to Landing page
+2-30
docs/todo.md
···4949 - OAuth 2.1 client flow (PKCE, DPoP, handle/DID resolution, token refresh).
5050 - PDS client for `putRecord`, `deleteRecord`, `uploadBlob`.
5151 - TID generation and AT-URI builder in core crate.
5252- - Database migration for token storage and AT-URI columns.
5352- **(Done) Milestone E**: Internal component library/UI Foundation + Animations.
5453- **(Done) Milestone F**: Content Authoring (Notes + Cards + Deck Builder).
5555-5654- **(Done) Milestone G**: Study Engine (SRS) + Daily Review UX.
5755 - SM-2 spaced repetition scheduler.
5858- - Review repository with due card queries and stats tracking.
5959- - API endpoints: `/review/due`, `/review/submit`, `/review/stats`.
6060- - StudySession component with keyboard-first review.
6161- - ReviewStats component for progress display.
6262-6363-### Milestone H - Social Layer v1 (Follow, Feed, Fork, Comments)
6464-6565-#### Deliverables
6666-6767-- Follow graph + notifications
6868-- Feeds:
6969- - "New decks from follows"
7070- - "Trending this week" (simple scoring)
7171-- Forking workflow:
7272- - fork deck -> edit -> republish
7373-- Threaded comments on decks/cards
7474-7575-#### Acceptance
7676-7777-- A user can follow a curator and see new published decks in a feed.
5656+- **(Done) Milestone H**: Social Layer v1: Follow graph, Feeds (Follows/Trending), Forking workflow, and Threaded comments.
78577958### Milestone I - Search + Discovery + Taxonomy
8059···95749675#### Deliverables
97767777+- Look into [Ozone](https://github.com/bluesky-social/ozone)
9878- Reporting pipeline + review queue
9979- Rate limits + spam heuristics
10080- Takedown/visibility states (shadowed, removed, quarantined)
···132112- Backups + restore drills
133113- Load test targets (study session + feed + search)
134114- Beta program + feedback loop + roadmap iteration
135135-136136-#### Acceptance
137137-138138-- You can run this as a real product with confidence.
139139-140140-## Lexicon Definitions
141141-142142-Authoritative Lexicon definitions are located in the [`lexicons/`](../lexicons) directory.
143115144116## Open Questions (Parked Decisions)
145117
-107
docs/user-flows.md
···11-# User Flows
22-33-User experience pathways for Malfestio.
44-55-## Authentication
66-77-### Login
88-99-1. Navigate to `/login`
1010-2. Enter Bluesky handle and app password
1111-3. Submit → redirected to Library
1212-1313-### Logout
1414-1515-1. Click avatar in header → "Logout"
1616-2. → redirected to Landing page
1717-1818-## Deck Management
1919-2020-### Create Deck
2121-2222-1. Library (`/`) → "Create Deck"
2323-2. Fill: title, description, tags
2424-3. Set visibility (Private/Unlisted/Public/SharedWith)
2525-4. Add cards (front/back, optional hints, card type)
2626-5. Submit → deck created, redirected to Library
2727-2828-### View Deck
2929-3030-1. Library → click deck card
3131-2. View title, description, tags, card list
3232-3. Options: Edit, Study, Back to Library
3333-3434-### Study Deck
3535-3636-1. Deck View → "Study Deck"
3737-2. Study session with keyboard controls
3838-3. Grade cards (1-5), view progress
3939-4. Session complete → return to deck
4040-4141-## Note Management
4242-4343-### Create Note
4444-4545-1. Header → "Notes" → "New Note"
4646-2. Fill: title, body (markdown), tags
4747-3. Add wikilinks with `[[Note Title]]`
4848-4. Set visibility
4949-5. Submit → note created
5050-5151-### View Notes
5252-5353-1. Header → "Notes"
5454-2. Browse notes with backlink navigation
5555-5656-## Content Import
5757-5858-### Import Article
5959-6060-1. Header → "Import"
6161-2. Enter article URL
6262-3. Submit → article parsed, deck/note created
6363-6464-### Import Lecture
6565-6666-1. Import page → "Lecture Import" tab
6767-2. Enter lecture URL
6868-3. Submit → lecture content extracted
6969-7070-## Study Session
7171-7272-### Daily Review
7373-7474-1. Navigate to `/review` or click "Review" in header
7575-2. View study stats: due count, streak, reviewed today
7676-3. Click "Start Study Session"
7777-4. Card front shown → press **Space** to flip
7878-5. View answer → grade with **1-5** keys
7979-6. Repeat until all due cards complete
8080-7. View completion message and updated stats
8181-8282-### Deck-Specific Review
8383-8484-1. Navigate to deck view (`/decks/:id`)
8585-2. Click "Study Deck"
8686-3. Review only cards from that deck
8787-4. Same keyboard controls apply
8888-8989-### Progress Tracking
9090-9191-- **Due count**: Cards needing review today
9292-- **Streak**: Consecutive days studied
9393-- **Reviewed today**: Cards completed this session
9494-- **Interval growth**: SM-2 algorithm increases intervals for mastered cards
9595-9696-### Keyboard Shortcuts
9797-9898-| Key | Action |
9999-| ----- | -------------- |
100100-| Space | Flip card |
101101-| 1 | Grade: Again |
102102-| 2 | Grade: Hard |
103103-| 3 | Grade: Good |
104104-| 4 | Grade: Easy |
105105-| 5 | Grade: Perfect |
106106-| E | Quick edit |
107107-| Esc | Exit session |
+32
migrations/005_2025_12_30_social_layer.sql
···11+-- Social Layer: Follows and Comments
22+-- Implements Milestone H requirements
33+44+-- Follows table: User A follows User B
55+CREATE TABLE follows (
66+ follower_did TEXT NOT NULL,
77+ subject_did TEXT NOT NULL,
88+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
99+ PRIMARY KEY (follower_did, subject_did)
1010+);
1111+1212+CREATE INDEX idx_follows_follower ON follows(follower_did);
1313+CREATE INDEX idx_follows_subject_did ON follows(subject_did);
1414+1515+-- Comments table: Threaded comments on Decks (and potentially Cards in future)
1616+CREATE TABLE comments (
1717+ id UUID PRIMARY KEY,
1818+ deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE,
1919+ author_did TEXT NOT NULL,
2020+ content TEXT NOT NULL,
2121+ parent_id UUID REFERENCES comments(id) ON DELETE CASCADE, -- For threading
2222+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
2323+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
2424+);
2525+2626+CREATE INDEX idx_comments_deck_id ON comments(deck_id);
2727+CREATE INDEX idx_comments_parent_id ON comments(parent_id);
2828+CREATE INDEX idx_comments_author_did ON comments(author_did);
2929+CREATE INDEX idx_comments_created_at ON comments(created_at);
3030+3131+CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON comments
3232+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+2
web/src/App.tsx
···22import { authStore } from "$lib/store";
33import DeckNew from "$pages/DeckNew";
44import DeckView from "$pages/DeckView";
55+import Feed from "$pages/Feed";
56import Home from "$pages/Home";
67import Import from "$pages/Import";
78import Landing from "$pages/Landing";
···3738 <Route path="/import/lecture" component={() => <ProtectedRoute component={LectureImport} />} />
3839 <Route path="/review" component={() => <ProtectedRoute component={Review} />} />
3940 <Route path="/review/:deckId" component={() => <ProtectedRoute component={Review} />} />
4141+ <Route path="/feed" component={() => <ProtectedRoute component={Feed} />} />
4042 <Route path="*" component={() => <ProtectedRoute component={NotFound} />} />
4143 </Router>
4244 );