# Thoughts v2 — Plan 1: Core (Domain + Postgres + Auth + REST API) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the thoughts v2 backend core — clean domain, postgres adapter, auth, and a fully working JSON REST API — without federation or event system. **Architecture:** Hexagonal. `crates/domain` defines all entities and port traits. `crates/application` contains use cases that depend only on domain traits. `crates/adapters/postgres` and `crates/adapters/auth` implement those traits. `crates/presentation` wires concrete adapters into `Arc` via axum state and serves the REST API. No layer imports from a layer below it in the dependency graph except via traits. **Tech Stack:** Rust 2021, axum 0.8, sqlx 0.8 (postgres, no ORM), tokio, jsonwebtoken 9, argon2 0.5, serde, thiserror 2 **Subsequent plans:** Plan 2 = search (postgres-search), Plan 3 = events+worker (nats), Plan 4 = federation (activitypub). --- ## File Map ``` Cargo.toml ← workspace root (NEW) crates/domain/Cargo.toml crates/domain/src/lib.rs crates/domain/src/errors.rs crates/domain/src/value_objects.rs crates/domain/src/models/mod.rs crates/domain/src/models/user.rs crates/domain/src/models/thought.rs crates/domain/src/models/social.rs ← Like, Boost, Follow, Block crates/domain/src/models/tag.rs crates/domain/src/models/api_key.rs crates/domain/src/models/top_friend.rs crates/domain/src/models/notification.rs crates/domain/src/models/remote_actor.rs crates/domain/src/models/feed.rs ← FeedEntry, PageParams, Paginated, UserSummary crates/domain/src/ports.rs crates/domain/src/events.rs crates/domain/src/testing.rs ← in-memory impls (test-helpers feature) crates/application/Cargo.toml crates/application/src/lib.rs crates/application/src/use_cases/mod.rs crates/application/src/use_cases/auth.rs crates/application/src/use_cases/thoughts.rs crates/application/src/use_cases/social.rs crates/application/src/use_cases/feed.rs crates/application/src/use_cases/profile.rs crates/application/src/use_cases/api_keys.rs crates/api-types/Cargo.toml crates/api-types/src/lib.rs crates/api-types/src/requests.rs crates/api-types/src/responses.rs crates/adapters/postgres/Cargo.toml crates/adapters/postgres/src/lib.rs crates/adapters/postgres/src/user.rs crates/adapters/postgres/src/thought.rs crates/adapters/postgres/src/follow.rs crates/adapters/postgres/src/block.rs crates/adapters/postgres/src/like.rs crates/adapters/postgres/src/boost.rs crates/adapters/postgres/src/tag.rs crates/adapters/postgres/src/api_key.rs crates/adapters/postgres/src/top_friend.rs crates/adapters/postgres/src/notification.rs crates/adapters/postgres/src/remote_actor.rs crates/adapters/postgres/src/feed.rs crates/adapters/postgres/migrations/001_initial_schema.sql crates/adapters/postgres/migrations/002_federation_columns.sql crates/adapters/postgres/migrations/003_new_tables.sql crates/adapters/auth/Cargo.toml crates/adapters/auth/src/lib.rs crates/presentation/Cargo.toml crates/presentation/src/main.rs crates/presentation/src/state.rs crates/presentation/src/errors.rs crates/presentation/src/extractors.rs crates/presentation/src/routes.rs crates/presentation/src/handlers/mod.rs crates/presentation/src/handlers/auth.rs crates/presentation/src/handlers/thoughts.rs crates/presentation/src/handlers/feed.rs crates/presentation/src/handlers/social.rs crates/presentation/src/handlers/users.rs crates/presentation/src/handlers/notifications.rs crates/presentation/src/handlers/api_keys.rs # Stub crates (empty, referenced in workspace for future plans): crates/adapters/postgres-search/Cargo.toml + src/lib.rs crates/adapters/postgres-federation/Cargo.toml + src/lib.rs crates/adapters/activitypub-base/Cargo.toml + src/lib.rs crates/adapters/activitypub/Cargo.toml + src/lib.rs crates/adapters/nats/Cargo.toml + src/lib.rs crates/adapters/event-payload/Cargo.toml + src/lib.rs crates/adapters/event-publisher/Cargo.toml + src/lib.rs crates/worker/Cargo.toml + src/main.rs ``` --- ### Task 1: Workspace scaffold **Files:** `Cargo.toml` (root), all crate `Cargo.toml`s, empty `src/lib.rs` or `src/main.rs` stubs - [ ] **Create root `Cargo.toml`:** ```toml [workspace] members = [ "crates/domain", "crates/application", "crates/api-types", "crates/presentation", "crates/worker", "crates/adapters/postgres", "crates/adapters/postgres-search", "crates/adapters/postgres-federation", "crates/adapters/activitypub-base", "crates/adapters/activitypub", "crates/adapters/auth", "crates/adapters/nats", "crates/adapters/event-payload", "crates/adapters/event-publisher", ] resolver = "2" [workspace.dependencies] tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0" thiserror = "2.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } async-trait = "0.1" uuid = { version = "1.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "macros"] } axum = { version = "0.8", features = ["macros"] } tower-http = { version = "0.6", features = ["cors", "trace"] } futures = "0.3" dotenvy = "0.15" domain = { path = "crates/domain" } application = { path = "crates/application" } api-types = { path = "crates/api-types" } postgres = { path = "crates/adapters/postgres" } postgres-search = { path = "crates/adapters/postgres-search" } postgres-federation = { path = "crates/adapters/postgres-federation" } activitypub-base = { path = "crates/adapters/activitypub-base" } activitypub = { path = "crates/adapters/activitypub" } auth = { path = "crates/adapters/auth" } nats = { path = "crates/adapters/nats" } event-payload = { path = "crates/adapters/event-payload" } event-publisher = { path = "crates/adapters/event-publisher" } ``` - [ ] **Create `crates/domain/Cargo.toml`:** ```toml [package] name = "domain" version = "0.1.0" edition = "2021" [features] test-helpers = [] [dependencies] async-trait = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } serde = { workspace = true } futures = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } ``` - [ ] **Create `crates/application/Cargo.toml`:** ```toml [package] name = "application" version = "0.1.0" edition = "2021" [dependencies] domain = { workspace = true } async-trait = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } domain = { workspace = true, features = ["test-helpers"] } ``` - [ ] **Create `crates/api-types/Cargo.toml`:** ```toml [package] name = "api-types" version = "0.1.0" edition = "2021" [dependencies] serde = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } ``` - [ ] **Create `crates/presentation/Cargo.toml`:** ```toml [package] name = "presentation" version = "0.1.0" edition = "2021" [[bin]] name = "thoughts" path = "src/main.rs" [dependencies] domain = { workspace = true } application = { workspace = true } api-types = { workspace = true } postgres = { workspace = true } auth = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } tokio = { workspace = true, features = ["full"] } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } async-trait = { workspace = true } [dev-dependencies] http-body-util = "0.1" tower = "0.5" domain = { workspace = true, features = ["test-helpers"] } ``` - [ ] **Create `crates/adapters/postgres/Cargo.toml`:** ```toml [package] name = "postgres" version = "0.1.0" edition = "2021" [dependencies] domain = { workspace = true } sqlx = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } async-trait = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } sqlx = { workspace = true, features = ["migrate"] } ``` - [ ] **Create `crates/adapters/auth/Cargo.toml`:** ```toml [package] name = "auth" version = "0.1.0" edition = "2021" [dependencies] domain = { workspace = true } async-trait = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tokio = { workspace = true } jsonwebtoken = "9" argon2 = "0.5" ``` - [ ] **Create stub crates** — for each of the 8 remaining crates create a `Cargo.toml` with just `[package]` (name/version/edition) and an empty `src/lib.rs` (or `src/main.rs` for worker): ``` crates/adapters/postgres-search/ → name = "postgres-search" crates/adapters/postgres-federation/→ name = "postgres-federation" crates/adapters/activitypub-base/ → name = "activitypub-base" crates/adapters/activitypub/ → name = "activitypub" crates/adapters/nats/ → name = "nats" crates/adapters/event-payload/ → name = "event-payload" crates/adapters/event-publisher/ → name = "event-publisher" crates/worker/ → name = "worker", bin src/main.rs = fn main(){} ``` - [ ] **Create empty `src/lib.rs`** for domain, application, api-types, postgres, auth, and all stub crates. - [ ] **Run:** `cargo check` Expected: compiles with no errors (only "unused" warnings ok). - [ ] **Commit:** ```bash git add Cargo.toml crates/ git commit -m "chore: scaffold v2 workspace" ``` --- ### Task 2: Domain — errors and value objects **Files:** `crates/domain/src/errors.rs`, `crates/domain/src/value_objects.rs`, update `crates/domain/src/lib.rs` - [ ] **Write the test** in `crates/domain/src/value_objects.rs` (bottom of file): ```rust #[cfg(test)] mod tests { use super::*; #[test] fn username_rejects_empty() { assert!(Username::new("").is_err()); } #[test] fn username_rejects_too_long() { assert!(Username::new("a".repeat(33)).is_err()); } #[test] fn username_rejects_invalid_chars() { assert!(Username::new("hello world").is_err()); } #[test] fn username_accepts_valid() { assert!(Username::new("hello_123").is_ok()); } #[test] fn content_local_rejects_over_128() { assert!(Content::new_local("a".repeat(129)).is_err()); } #[test] fn content_local_accepts_128() { assert!(Content::new_local("a".repeat(128)).is_ok()); } #[test] fn email_rejects_no_at() { assert!(Email::new("notanemail").is_err()); } } ``` - [ ] **Run:** `cargo test -p domain` — Expected: FAIL (no source yet). - [ ] **Write `crates/domain/src/errors.rs`:** ```rust use thiserror::Error; #[derive(Debug, Error, Clone)] pub enum DomainError { #[error("not found")] NotFound, #[error("unauthorized")] Unauthorized, #[error("forbidden")] Forbidden, #[error("conflict: {0}")] Conflict(String), #[error("invalid input: {0}")] InvalidInput(String), #[error("internal error: {0}")] Internal(String), } ``` - [ ] **Write `crates/domain/src/value_objects.rs`:** ```rust use uuid::Uuid; use crate::errors::DomainError; macro_rules! uuid_id { ($name:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct $name(Uuid); impl $name { pub fn new() -> Self { Self(Uuid::new_v4()) } pub fn from_uuid(u: Uuid) -> Self { Self(u) } pub fn as_uuid(&self) -> Uuid { self.0 } } impl Default for $name { fn default() -> Self { Self::new() } } impl std::fmt::Display for $name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } }; } uuid_id!(UserId); uuid_id!(ThoughtId); uuid_id!(LikeId); uuid_id!(BoostId); uuid_id!(ApiKeyId); uuid_id!(NotificationId); #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Username(String); impl Username { pub fn new(s: impl Into) -> Result { let s = s.into(); if s.is_empty() || s.len() > 32 { return Err(DomainError::InvalidInput("username: 1-32 chars".into())); } if !s.chars().all(|c| c.is_alphanumeric() || c == '_') { return Err(DomainError::InvalidInput("username: alphanumeric or underscore only".into())); } Ok(Self(s)) } pub fn from_trusted(s: String) -> Self { Self(s) } pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for Username { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Email(String); impl Email { pub fn new(s: impl Into) -> Result { let s = s.into().to_lowercase(); if !s.contains('@') || s.len() > 255 { return Err(DomainError::InvalidInput("invalid email".into())); } Ok(Self(s)) } pub fn from_trusted(s: String) -> Self { Self(s) } pub fn as_str(&self) -> &str { &self.0 } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PasswordHash(pub String); #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Content(String); impl Content { pub fn new_local(s: impl Into) -> Result { let s = s.into(); if s.is_empty() || s.len() > 128 { return Err(DomainError::InvalidInput("content: 1-128 chars".into())); } Ok(Self(s)) } pub fn new_remote(s: impl Into) -> Self { Self(s.into()) } pub fn as_str(&self) -> &str { &self.0 } } impl std::fmt::Display for Content { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } // re-export tests at bottom (already shown above) ``` - [ ] **Update `crates/domain/src/lib.rs`:** ```rust pub mod errors; pub mod value_objects; // remaining modules added in later tasks ``` - [ ] **Run:** `cargo test -p domain` — Expected: all tests PASS. - [ ] **Commit:** ```bash git add crates/domain/ git commit -m "feat(domain): errors and value objects" ``` --- ### Task 3: Domain — models **Files:** `crates/domain/src/models/mod.rs` and all model files, update `lib.rs` - [ ] **Write `crates/domain/src/models/user.rs`:** ```rust use chrono::{DateTime, Utc}; use crate::value_objects::{UserId, Username, Email, PasswordHash}; #[derive(Debug, Clone)] pub struct User { pub id: UserId, pub username: Username, pub email: Email, pub password_hash: PasswordHash, pub display_name: Option, pub bio: Option, pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, pub local: bool, pub ap_id: Option, pub inbox_url: Option, pub public_key: Option, pub private_key: Option, pub created_at: DateTime, pub updated_at: DateTime, } impl User { pub fn new_local(id: UserId, username: Username, email: Email, password_hash: PasswordHash) -> Self { let now = Utc::now(); Self { id, username, email, password_hash, display_name: None, bio: None, avatar_url: None, header_url: None, custom_css: None, local: true, ap_id: None, inbox_url: None, public_key: None, private_key: None, created_at: now, updated_at: now, } } } ``` - [ ] **Write `crates/domain/src/models/thought.rs`:** ```rust use chrono::{DateTime, Utc}; use crate::value_objects::{ThoughtId, UserId, Content}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Visibility { Public, Followers, Unlisted, Direct, } impl Visibility { pub fn from_str(s: &str) -> Self { match s { "followers" => Self::Followers, "unlisted" => Self::Unlisted, "direct" => Self::Direct, _ => Self::Public } } pub fn as_str(&self) -> &str { match self { Self::Public => "public", Self::Followers => "followers", Self::Unlisted => "unlisted", Self::Direct => "direct" } } } #[derive(Debug, Clone)] pub struct Thought { pub id: ThoughtId, pub user_id: UserId, pub content: Content, pub in_reply_to_id: Option, pub in_reply_to_url: Option, pub ap_id: Option, pub visibility: Visibility, pub content_warning: Option, pub sensitive: bool, pub local: bool, pub created_at: DateTime, pub updated_at: Option>, } impl Thought { pub fn new_local( id: ThoughtId, user_id: UserId, content: Content, in_reply_to_id: Option, visibility: Visibility, content_warning: Option, sensitive: bool, ) -> Self { Self { id, user_id, content, in_reply_to_id, in_reply_to_url: None, ap_id: None, visibility, content_warning, sensitive, local: true, created_at: Utc::now(), updated_at: None, } } } ``` - [ ] **Write `crates/domain/src/models/social.rs`:** ```rust use chrono::{DateTime, Utc}; use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; #[derive(Debug, Clone)] pub struct Like { pub id: LikeId, pub user_id: UserId, pub thought_id: ThoughtId, pub ap_id: Option, pub created_at: DateTime, } #[derive(Debug, Clone)] pub struct Boost { pub id: BoostId, pub user_id: UserId, pub thought_id: ThoughtId, pub ap_id: Option, pub created_at: DateTime, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum FollowState { Pending, Accepted, Rejected } impl FollowState { pub fn from_str(s: &str) -> Self { match s { "pending" => Self::Pending, "rejected" => Self::Rejected, _ => Self::Accepted } } pub fn as_str(&self) -> &str { match self { Self::Pending => "pending", Self::Accepted => "accepted", Self::Rejected => "rejected" } } } #[derive(Debug, Clone)] pub struct Follow { pub follower_id: UserId, pub following_id: UserId, pub state: FollowState, pub ap_id: Option, pub created_at: DateTime, } #[derive(Debug, Clone)] pub struct Block { pub blocker_id: UserId, pub blocked_id: UserId, pub created_at: DateTime, } ``` - [ ] **Write `crates/domain/src/models/tag.rs`:** ```rust #[derive(Debug, Clone)] pub struct Tag { pub id: i32, pub name: String } ``` - [ ] **Write `crates/domain/src/models/api_key.rs`:** ```rust use chrono::{DateTime, Utc}; use crate::value_objects::{ApiKeyId, UserId}; #[derive(Debug, Clone)] pub struct ApiKey { pub id: ApiKeyId, pub user_id: UserId, pub key_hash: String, pub name: String, pub created_at: DateTime, } ``` - [ ] **Write `crates/domain/src/models/top_friend.rs`:** ```rust use crate::value_objects::UserId; #[derive(Debug, Clone)] pub struct TopFriend { pub user_id: UserId, pub friend_id: UserId, pub position: i16 } ``` - [ ] **Write `crates/domain/src/models/notification.rs`:** ```rust use chrono::{DateTime, Utc}; use crate::value_objects::{NotificationId, UserId, ThoughtId}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum NotificationType { Like, Boost, Follow, Mention, Reply } impl NotificationType { pub fn from_str(s: &str) -> Self { match s { "like" => Self::Like, "boost" => Self::Boost, "follow" => Self::Follow, "mention" => Self::Mention, _ => Self::Reply } } pub fn as_str(&self) -> &str { match self { Self::Like => "like", Self::Boost => "boost", Self::Follow => "follow", Self::Mention => "mention", Self::Reply => "reply" } } } #[derive(Debug, Clone)] pub struct Notification { pub id: NotificationId, pub user_id: UserId, pub notification_type: NotificationType, pub from_user_id: Option, pub thought_id: Option, pub read: bool, pub created_at: DateTime, } ``` - [ ] **Write `crates/domain/src/models/remote_actor.rs`:** ```rust use chrono::{DateTime, Utc}; #[derive(Debug, Clone)] pub struct RemoteActor { pub url: String, pub handle: String, pub display_name: Option, pub inbox_url: String, pub shared_inbox_url: Option, pub public_key: String, pub last_fetched_at: DateTime, } ``` - [ ] **Write `crates/domain/src/models/feed.rs`:** ```rust use crate::models::{user::User, thought::Thought}; use crate::value_objects::UserId; #[derive(Debug, Clone)] pub struct UserSummary { pub id: UserId, pub username: String, pub display_name: Option, pub avatar_url: Option, pub bio: Option, pub thought_count: i64, pub follower_count: i64, pub following_count: i64, } #[derive(Debug, Clone)] pub struct FeedEntry { pub thought: Thought, pub author: User, pub like_count: i64, pub boost_count: i64, pub reply_count: i64, pub liked_by_viewer: bool, pub boosted_by_viewer: bool, } #[derive(Debug, Clone)] pub struct PageParams { pub page: u64, pub per_page: u64 } impl PageParams { pub fn offset(&self) -> i64 { ((self.page.saturating_sub(1)) * self.per_page) as i64 } pub fn limit(&self) -> i64 { self.per_page as i64 } } #[derive(Debug, Clone)] pub struct Paginated { pub items: Vec, pub total: i64, pub page: u64, pub per_page: u64, } ``` - [ ] **Write `crates/domain/src/models/mod.rs`:** ```rust pub mod api_key; pub mod feed; pub mod notification; pub mod remote_actor; pub mod social; pub mod tag; pub mod thought; pub mod top_friend; pub mod user; ``` - [ ] **Update `crates/domain/src/lib.rs`:** ```rust pub mod errors; pub mod events; pub mod models; pub mod ports; pub mod value_objects; #[cfg(any(test, feature = "test-helpers"))] pub mod testing; ``` - [ ] **Run:** `cargo check -p domain` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/domain/ git commit -m "feat(domain): models" ``` --- ### Task 4: Domain — ports, events, and in-memory test helpers **Files:** `crates/domain/src/ports.rs`, `crates/domain/src/events.rs`, `crates/domain/src/testing.rs` - [ ] **Write `crates/domain/src/events.rs`:** ```rust use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; #[derive(Debug, Clone)] pub enum DomainEvent { ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option }, ThoughtDeleted { thought_id: ThoughtId, user_id: UserId }, ThoughtUpdated { thought_id: ThoughtId, user_id: UserId }, LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId }, LikeRemoved { user_id: UserId, thought_id: ThoughtId }, BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId }, BoostRemoved { user_id: UserId, thought_id: ThoughtId }, FollowRequested { follower_id: UserId, following_id: UserId }, FollowAccepted { follower_id: UserId, following_id: UserId }, FollowRejected { follower_id: UserId, following_id: UserId }, Unfollowed { follower_id: UserId, following_id: UserId }, UserBlocked { blocker_id: UserId, blocked_id: UserId }, } pub struct EventEnvelope { pub event: DomainEvent, pub ack: Box, pub nack: Box, } impl std::fmt::Debug for EventEnvelope { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("EventEnvelope").field("event", &self.event).finish() } } ``` - [ ] **Write `crates/domain/src/ports.rs`:** ```rust use async_trait::async_trait; use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, models::{ api_key::ApiKey, feed::{FeedEntry, PageParams, Paginated, UserSummary}, notification::Notification, remote_actor::RemoteActor, social::{Block, Boost, Follow, FollowState, Like}, tag::Tag, thought::Thought, top_friend::TopFriend, user::User, }, value_objects::{ApiKeyId, BoostId, Content, Email, LikeId, NotificationId, PasswordHash, ThoughtId, UserId, Username}, }; pub struct GeneratedToken { pub token: String, pub user_id: UserId } #[async_trait] pub trait AuthService: Send + Sync { fn generate_token(&self, user_id: &UserId) -> Result; fn validate_token(&self, token: &str) -> Result; } #[async_trait] pub trait PasswordHasher: Send + Sync { async fn hash(&self, plain: &str) -> Result; async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result; } #[async_trait] pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; } pub trait EventConsumer: Send + Sync { fn consume(&self) -> futures::stream::BoxStream<'_, Result>; } #[async_trait] pub trait UserRepository: Send + Sync { async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>; async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError>; async fn list_with_stats(&self) -> Result, DomainError>; } #[async_trait] pub trait ThoughtRepository: Send + Sync { async fn save(&self, thought: &Thought) -> Result<(), DomainError>; async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError>; async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>; async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>; async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError>; async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; } #[async_trait] pub trait LikeRepository: Send + Sync { async fn save(&self, like: &Like) -> Result<(), DomainError>; async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; } #[async_trait] pub trait BoostRepository: Send + Sync { async fn save(&self, boost: &Boost) -> Result<(), DomainError>; async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; } #[async_trait] pub trait FollowRepository: Send + Sync { async fn save(&self, follow: &Follow) -> Result<(), DomainError>; async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>; async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError>; async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError>; async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError>; } #[async_trait] pub trait BlockRepository: Send + Sync { async fn save(&self, block: &Block) -> Result<(), DomainError>; async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError>; async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result; } #[async_trait] pub trait TagRepository: Send + Sync { async fn find_or_create(&self, name: &str) -> Result; async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError>; async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>; async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError>; async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError>; } #[async_trait] pub trait ApiKeyRepository: Send + Sync { async fn save(&self, key: &ApiKey) -> Result<(), DomainError>; async fn find_by_hash(&self, key_hash: &str) -> Result, DomainError>; async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError>; } #[async_trait] pub trait TopFriendRepository: Send + Sync { async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError>; async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; } #[async_trait] pub trait NotificationRepository: Send + Sync { async fn save(&self, n: &Notification) -> Result<(), DomainError>; async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>; async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>; } #[async_trait] pub trait RemoteActorRepository: Send + Sync { async fn upsert(&self, actor: &RemoteActor) -> Result<(), DomainError>; async fn find_by_url(&self, url: &str) -> Result, DomainError>; } #[async_trait] pub trait FeedRepository: Send + Sync { async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; } ``` - [ ] **Write `crates/domain/src/testing.rs`** — in-memory impls of all ports for use-case tests: ```rust use std::sync::{Arc, Mutex}; use async_trait::async_trait; use chrono::Utc; use crate::{ errors::DomainError, events::DomainEvent, models::{api_key::ApiKey, feed::{FeedEntry, PageParams, Paginated, UserSummary}, notification::Notification, remote_actor::RemoteActor, social::{Block, Boost, Follow, FollowState, Like}, tag::Tag, thought::Thought, top_friend::TopFriend, user::User}, ports::*, value_objects::{ApiKeyId, BoostId, Content, Email, LikeId, NotificationId, PasswordHash, ThoughtId, UserId, Username}, }; #[derive(Default, Clone)] pub struct TestStore { pub users: Arc>>, pub thoughts: Arc>>, pub likes: Arc>>, pub boosts: Arc>>, pub follows: Arc>>, pub blocks: Arc>>, pub tags: Arc>>, pub api_keys: Arc>>, pub top_friends: Arc>>, pub notifications:Arc>>, pub events: Arc>>, } #[async_trait] impl UserRepository for TestStore { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { Ok(self.users.lock().unwrap().iter().find(|u| &u.id == id).cloned()) } async fn find_by_username(&self, username: &Username) -> Result, DomainError> { Ok(self.users.lock().unwrap().iter().find(|u| u.username.as_str() == username.as_str()).cloned()) } async fn find_by_email(&self, email: &Email) -> Result, DomainError> { Ok(self.users.lock().unwrap().iter().find(|u| u.email.as_str() == email.as_str()).cloned()) } async fn save(&self, user: &User) -> Result<(), DomainError> { let mut g = self.users.lock().unwrap(); g.retain(|u| u.id != user.id); g.push(user.clone()); Ok(()) } async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { if let Some(u) = self.users.lock().unwrap().iter_mut().find(|u| &u.id == user_id) { u.display_name = display_name; u.bio = bio; u.avatar_url = avatar_url; u.header_url = header_url; u.custom_css = custom_css; } Ok(()) } async fn list_with_stats(&self) -> Result, DomainError> { Ok(vec![]) } } #[async_trait] impl ThoughtRepository for TestStore { async fn save(&self, t: &Thought) -> Result<(), DomainError> { let mut g = self.thoughts.lock().unwrap(); g.retain(|x| x.id != t.id); g.push(t.clone()); Ok(()) } async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { Ok(self.thoughts.lock().unwrap().iter().find(|t| &t.id == id).cloned()) } async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { let mut g = self.thoughts.lock().unwrap(); let before = g.len(); g.retain(|t| !(&t.id == id && &t.user_id == user_id)); if g.len() == before { return Err(DomainError::NotFound); } Ok(()) } async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { if let Some(t) = self.thoughts.lock().unwrap().iter_mut().find(|t| &t.id == id) { t.content = content.clone(); t.updated_at = Some(Utc::now()); } Ok(()) } async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id).cloned().collect()) } async fn list_by_user(&self, user_id: &UserId, _page: &PageParams) -> Result, DomainError> { let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } } #[async_trait] impl LikeRepository for TestStore { async fn save(&self, like: &Like) -> Result<(), DomainError> { let mut g = self.likes.lock().unwrap(); if g.iter().any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) { return Err(DomainError::Conflict("already liked".into())); } g.push(like.clone()); Ok(()) } async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let mut g = self.likes.lock().unwrap(); let before = g.len(); g.retain(|l| !(&l.user_id == user_id && &l.thought_id == thought_id)); if g.len() == before { return Err(DomainError::NotFound); } Ok(()) } async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { Ok(self.likes.lock().unwrap().iter().find(|l| &l.user_id == user_id && &l.thought_id == thought_id).cloned()) } async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { Ok(self.likes.lock().unwrap().iter().filter(|l| &l.thought_id == thought_id).count() as i64) } } #[async_trait] impl BoostRepository for TestStore { async fn save(&self, boost: &Boost) -> Result<(), DomainError> { let mut g = self.boosts.lock().unwrap(); if g.iter().any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) { return Err(DomainError::Conflict("already boosted".into())); } g.push(boost.clone()); Ok(()) } async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let mut g = self.boosts.lock().unwrap(); let before = g.len(); g.retain(|b| !(&b.user_id == user_id && &b.thought_id == thought_id)); if g.len() == before { return Err(DomainError::NotFound); } Ok(()) } async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { Ok(self.boosts.lock().unwrap().iter().find(|b| &b.user_id == user_id && &b.thought_id == thought_id).cloned()) } async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { Ok(self.boosts.lock().unwrap().iter().filter(|b| &b.thought_id == thought_id).count() as i64) } } #[async_trait] impl FollowRepository for TestStore { async fn save(&self, follow: &Follow) -> Result<(), DomainError> { let mut g = self.follows.lock().unwrap(); g.retain(|f| !(f.follower_id == follow.follower_id && f.following_id == follow.following_id)); g.push(follow.clone()); Ok(()) } async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { let mut g = self.follows.lock().unwrap(); let before = g.len(); g.retain(|f| !(&f.follower_id == follower_id && &f.following_id == following_id)); if g.len() == before { return Err(DomainError::NotFound); } Ok(()) } async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { Ok(self.follows.lock().unwrap().iter().find(|f| &f.follower_id == follower_id && &f.following_id == following_id).cloned()) } async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { if let Some(f) = self.follows.lock().unwrap().iter_mut().find(|f| &f.follower_id == follower_id && &f.following_id == following_id) { f.state = state.clone(); } Ok(()) } async fn list_followers(&self, user_id: &UserId, _p: &PageParams) -> Result, DomainError> { let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } async fn list_following(&self, user_id: &UserId, _p: &PageParams) -> Result, DomainError> { let _ = user_id; Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { Ok(self.follows.lock().unwrap().iter().filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted).map(|f| f.following_id.clone()).collect()) } } #[async_trait] impl BlockRepository for TestStore { async fn save(&self, block: &Block) -> Result<(), DomainError> { self.blocks.lock().unwrap().push(block.clone()); Ok(()) } async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { self.blocks.lock().unwrap().retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id)); Ok(()) } async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { Ok(self.blocks.lock().unwrap().iter().any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id)) } } #[async_trait] impl TagRepository for TestStore { async fn find_or_create(&self, name: &str) -> Result { let mut g = self.tags.lock().unwrap(); if let Some(t) = g.iter().find(|t| t.name == name) { return Ok(t.clone()); } let tag = Tag { id: g.len() as i32 + 1, name: name.to_string() }; g.push(tag.clone()); Ok(tag) } async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { Ok(()) } async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { Ok(()) } async fn list_for_thought(&self, _tid: &ThoughtId) -> Result, DomainError> { Ok(vec![]) } async fn list_thoughts_by_tag(&self, _name: &str, _p: &PageParams) -> Result, DomainError> { Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } } #[async_trait] impl ApiKeyRepository for TestStore { async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { self.api_keys.lock().unwrap().push(key.clone()); Ok(()) } async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { Ok(self.api_keys.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) } async fn list_for_user(&self, uid: &UserId) -> Result, DomainError> { Ok(self.api_keys.lock().unwrap().iter().filter(|k| &k.user_id == uid).cloned().collect()) } async fn delete(&self, id: &ApiKeyId, uid: &UserId) -> Result<(), DomainError> { self.api_keys.lock().unwrap().retain(|k| !(&k.id == id && &k.user_id == uid)); Ok(()) } } #[async_trait] impl TopFriendRepository for TestStore { async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { let mut g = self.top_friends.lock().unwrap(); g.retain(|tf| &tf.user_id != user_id); for (fid, pos) in friends { g.push(TopFriend { user_id: user_id.clone(), friend_id: fid, position: pos }); } Ok(()) } async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { Ok(vec![]) } } #[async_trait] impl NotificationRepository for TestStore { async fn save(&self, n: &Notification) -> Result<(), DomainError> { self.notifications.lock().unwrap().push(n.clone()); Ok(()) } async fn list_for_user(&self, uid: &UserId, _p: &PageParams) -> Result, DomainError> { let items: Vec<_> = self.notifications.lock().unwrap().iter().filter(|n| &n.user_id == uid).cloned().collect(); let total = items.len() as i64; Ok(Paginated { items, total, page: 1, per_page: 20 }) } async fn mark_read(&self, id: &NotificationId, _uid: &UserId) -> Result<(), DomainError> { if let Some(n) = self.notifications.lock().unwrap().iter_mut().find(|n| &n.id == id) { n.read = true; } Ok(()) } async fn mark_all_read(&self, uid: &UserId) -> Result<(), DomainError> { for n in self.notifications.lock().unwrap().iter_mut().filter(|n| &n.user_id == uid) { n.read = true; } Ok(()) } } #[async_trait] impl RemoteActorRepository for TestStore { async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { Ok(()) } async fn find_by_url(&self, _url: &str) -> Result, DomainError> { Ok(None) } } #[async_trait] impl FeedRepository for TestStore { async fn home_feed(&self, _ids: &[UserId], _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } async fn public_feed(&self, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } async fn search(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) } } #[async_trait] impl EventPublisher for TestStore { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { self.events.lock().unwrap().push(event.clone()); Ok(()) } } pub struct NoOpEventPublisher; #[async_trait] impl EventPublisher for NoOpEventPublisher { async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } } ``` - [ ] **Run:** `cargo check -p domain` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/domain/ git commit -m "feat(domain): ports, events, test helpers" ``` --- ### Task 5: Postgres — migrations **Files:** `crates/adapters/postgres/migrations/001_initial_schema.sql`, `002_federation_columns.sql`, `003_new_tables.sql` - [ ] **Write `migrations/001_initial_schema.sql`** (recreates production schema exactly): ```sql CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(32) NOT NULL UNIQUE, email VARCHAR(255) NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name VARCHAR(50), bio VARCHAR(160), avatar_url TEXT, header_url TEXT, custom_css TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS thoughts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, content VARCHAR(128) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS follows ( follower_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, following_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (follower_id, following_id) ); CREATE TABLE IF NOT EXISTS top_friends ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, friend_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, position SMALLINT NOT NULL, PRIMARY KEY (user_id, friend_id), UNIQUE (user_id, position) ); CREATE TABLE IF NOT EXISTS tags ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE ); CREATE TABLE IF NOT EXISTS thought_tags ( thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, PRIMARY KEY (thought_id, tag_id) ); CREATE TABLE IF NOT EXISTS api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, key_hash TEXT NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` - [ ] **Write `migrations/002_federation_columns.sql`:** ```sql ALTER TABLE users ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE, ADD COLUMN IF NOT EXISTS inbox_url TEXT, ADD COLUMN IF NOT EXISTS public_key TEXT, ADD COLUMN IF NOT EXISTS private_key TEXT, ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true; ALTER TABLE thoughts ADD COLUMN IF NOT EXISTS in_reply_to_id UUID REFERENCES thoughts(id), ADD COLUMN IF NOT EXISTS in_reply_to_url TEXT, ADD COLUMN IF NOT EXISTS ap_id TEXT UNIQUE, ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'public', ADD COLUMN IF NOT EXISTS content_warning TEXT, ADD COLUMN IF NOT EXISTS sensitive BOOLEAN NOT NULL DEFAULT false, ADD COLUMN IF NOT EXISTS local BOOLEAN NOT NULL DEFAULT true, ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ; ALTER TABLE follows ADD COLUMN IF NOT EXISTS state TEXT NOT NULL DEFAULT 'accepted', ADD COLUMN IF NOT EXISTS ap_id TEXT, ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); ``` - [ ] **Write `migrations/003_new_tables.sql`:** ```sql CREATE TABLE IF NOT EXISTS likes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, ap_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id, thought_id) ); CREATE TABLE IF NOT EXISTS boosts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, thought_id UUID NOT NULL REFERENCES thoughts(id) ON DELETE CASCADE, ap_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (user_id, thought_id) ); CREATE TABLE IF NOT EXISTS blocks ( blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (blocker_id, blocked_id) ); CREATE TABLE IF NOT EXISTS remote_actors ( url TEXT PRIMARY KEY, handle TEXT NOT NULL, display_name TEXT, inbox_url TEXT NOT NULL, shared_inbox_url TEXT, public_key TEXT NOT NULL, last_fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, type TEXT NOT NULL, from_user_id UUID REFERENCES users(id) ON DELETE SET NULL, thought_id UUID REFERENCES thoughts(id) ON DELETE CASCADE, read BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_thoughts_user_id ON thoughts(user_id); CREATE INDEX IF NOT EXISTS idx_thoughts_created_at ON thoughts(created_at DESC); CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows(following_id); CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id, read); ``` - [ ] **Start test database:** `docker compose up -d postgres` (or equivalent). - [ ] **Run migrations against test DB** to verify SQL is valid: ```bash export DATABASE_URL=postgres://postgres:password@localhost:5432/thoughts_test sqlx database create sqlx migrate run --source crates/adapters/postgres/migrations ``` Expected: `Applied 3 migrations` - [ ] **Commit:** ```bash git add crates/adapters/postgres/migrations/ git commit -m "feat(postgres): initial migrations" ``` --- ### Task 6: Postgres — UserRepository **Files:** `crates/adapters/postgres/src/lib.rs`, `crates/adapters/postgres/src/user.rs` - [ ] **Write the integration test** (bottom of `user.rs`): ```rust #[cfg(test)] mod tests { use super::*; use domain::value_objects::*; use domain::models::user::User; #[sqlx::test(migrations = "migrations")] async fn save_and_find_by_id(pool: sqlx::PgPool) { let repo = PgUserRepository::new(pool); let user = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("hash".into())); repo.save(&user).await.unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); assert_eq!(found.username.as_str(), "alice"); } #[sqlx::test(migrations = "migrations")] async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { let repo = PgUserRepository::new(pool); let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap(); assert!(result.is_none()); } } ``` - [ ] **Run:** `cargo test -p postgres save_and_find` — Expected: FAIL (no impl). - [ ] **Write `crates/adapters/postgres/src/lib.rs`:** ```rust pub mod api_key; pub mod block; pub mod boost; pub mod feed; pub mod follow; pub mod like; pub mod notification; pub mod remote_actor; pub mod tag; pub mod thought; pub mod top_friend; pub mod user; ``` - [ ] **Write `crates/adapters/postgres/src/user.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{ errors::DomainError, models::{feed::UserSummary, user::User}, ports::UserRepository, value_objects::{Email, PasswordHash, UserId, Username}, }; pub struct PgUserRepository { pool: PgPool } impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[derive(sqlx::FromRow)] struct UserRow { id: uuid::Uuid, username: String, email: String, password_hash: String, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, local: bool, ap_id: Option, inbox_url: Option, public_key: Option, private_key: Option, created_at: DateTime, updated_at: DateTime, } impl From for User { fn from(r: UserRow) -> Self { User { id: UserId::from_uuid(r.id), username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key, private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at, } } } #[async_trait] impl UserRepository for PgUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { sqlx::query_as::<_, UserRow>( "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1" ).bind(id.as_uuid()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(User::from)) } async fn find_by_username(&self, username: &Username) -> Result, DomainError> { sqlx::query_as::<_, UserRow>( "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE username=$1" ).bind(username.as_str()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(User::from)) } async fn find_by_email(&self, email: &Email) -> Result, DomainError> { sqlx::query_as::<_, UserRow>( "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE email=$1" ).bind(email.as_str()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(User::from)) } async fn save(&self, user: &User) -> Result<(), DomainError> { sqlx::query( "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(id) DO UPDATE SET username=EXCLUDED.username,email=EXCLUDED.email,password_hash=EXCLUDED.password_hash,display_name=EXCLUDED.display_name,bio=EXCLUDED.bio,avatar_url=EXCLUDED.avatar_url,header_url=EXCLUDED.header_url,custom_css=EXCLUDED.custom_css,local=EXCLUDED.local,ap_id=EXCLUDED.ap_id,inbox_url=EXCLUDED.inbox_url,public_key=EXCLUDED.public_key,private_key=EXCLUDED.private_key,updated_at=NOW()" ) .bind(user.id.as_uuid()).bind(user.username.as_str()).bind(user.email.as_str()) .bind(&user.password_hash.0).bind(&user.display_name).bind(&user.bio) .bind(&user.avatar_url).bind(&user.header_url).bind(&user.custom_css) .bind(user.local).bind(&user.ap_id).bind(&user.inbox_url) .bind(&user.public_key).bind(&user.private_key) .bind(user.created_at).bind(user.updated_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { sqlx::query("UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1") .bind(user_id.as_uuid()).bind(display_name).bind(bio).bind(avatar_url).bind(header_url).bind(custom_css) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn list_with_stats(&self) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, username: String, display_name: Option, avatar_url: Option, bio: Option, thought_count: i64, follower_count: i64, following_count: i64 } sqlx::query_as::<_, Row>( "SELECT u.id,u.username,u.display_name,u.avatar_url,u.bio, COUNT(DISTINCT t.id) AS thought_count, COUNT(DISTINCT f1.follower_id) AS follower_count, COUNT(DISTINCT f2.following_id) AS following_count FROM users u LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted' LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted' WHERE u.local=true GROUP BY u.id ORDER BY u.username" ).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) .map(|rows| rows.into_iter().map(|r| UserSummary { id: UserId::from_uuid(r.id), username: r.username, display_name: r.display_name, avatar_url: r.avatar_url, bio: r.bio, thought_count: r.thought_count, follower_count: r.follower_count, following_count: r.following_count, }).collect()) } } ``` - [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/adapters/postgres/ git commit -m "feat(postgres): UserRepository" ``` --- ### Task 7: Postgres — ThoughtRepository **Files:** `crates/adapters/postgres/src/thought.rs` - [ ] **Write the test:** ```rust #[cfg(test)] mod tests { use super::*; use domain::value_objects::*; use domain::models::{thought::{Thought, Visibility}, user::User}; use domain::models::feed::PageParams; use crate::user::PgUserRepository; use domain::ports::UserRepository; async fn seed_user(pool: &sqlx::PgPool) -> User { let repo = PgUserRepository::new(pool.clone()); let u = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); repo.save(&u).await.unwrap(); u } #[sqlx::test(migrations = "migrations")] async fn save_and_find_thought(pool: sqlx::PgPool) { let user = seed_user(&pool).await; let repo = PgThoughtRepository::new(pool); let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("hello").unwrap(), None, Visibility::Public, None, false); repo.save(&t).await.unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap(); assert_eq!(found.content.as_str(), "hello"); } #[sqlx::test(migrations = "migrations")] async fn delete_thought(pool: sqlx::PgPool) { let user = seed_user(&pool).await; let repo = PgThoughtRepository::new(pool); let t = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("bye").unwrap(), None, Visibility::Public, None, false); repo.save(&t).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap(); assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); } } ``` - [ ] **Run:** `cargo test -p postgres thought` — Expected: FAIL. - [ ] **Write `crates/adapters/postgres/src/thought.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{ errors::DomainError, models::{feed::{FeedEntry, PageParams, Paginated}, thought::{Thought, Visibility}, user::User}, ports::ThoughtRepository, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; pub struct PgThoughtRepository { pool: PgPool } impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[derive(sqlx::FromRow)] struct ThoughtRow { id: uuid::Uuid, user_id: uuid::Uuid, content: String, in_reply_to_id: Option, in_reply_to_url: Option, ap_id: Option, visibility: String, content_warning: Option, sensitive: bool, local: bool, created_at: DateTime, updated_at: Option>, } impl From for Thought { fn from(r: ThoughtRow) -> Self { Thought { id: ThoughtId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_url: r.in_reply_to_url, ap_id: r.ap_id, visibility: Visibility::from_str(&r.visibility), content_warning: r.content_warning, sensitive: r.sensitive, local: r.local, created_at: r.created_at, updated_at: r.updated_at, } } } #[async_trait] impl ThoughtRepository for PgThoughtRepository { async fn save(&self, t: &Thought) -> Result<(), DomainError> { sqlx::query( "INSERT INTO thoughts(id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT(id) DO UPDATE SET content=EXCLUDED.content,updated_at=NOW()" ) .bind(t.id.as_uuid()).bind(t.user_id.as_uuid()).bind(t.content.as_str()) .bind(t.in_reply_to_id.as_ref().map(|x| x.as_uuid())) .bind(&t.in_reply_to_url).bind(&t.ap_id).bind(t.visibility.as_str()) .bind(&t.content_warning).bind(t.sensitive).bind(t.local).bind(t.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { sqlx::query_as::<_, ThoughtRow>( "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts WHERE id=$1" ).bind(id.as_uuid()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())).map(|o| o.map(Thought::from)) } async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { let r = sqlx::query("DELETE FROM thoughts WHERE id=$1 AND user_id=$2") .bind(id.as_uuid()).bind(user_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; if r.rows_affected() == 0 { return Err(DomainError::NotFound); } Ok(()) } async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE id=$1") .bind(id.as_uuid()).bind(content.as_str()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { sqlx::query_as::<_, ThoughtRow>( "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC" ).bind(id.as_uuid()).fetch_all(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|rows| rows.into_iter().map(Thought::from).collect()) } async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id=$1") .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let rows = sqlx::query_as::<_, ThoughtRow>( "SELECT id,user_id,content,in_reply_to_id,in_reply_to_url,ap_id,visibility,content_warning,sensitive,local,created_at,updated_at FROM thoughts WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()) .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; // Note: FeedEntry requires author User — feed.rs handles joined queries; here we build minimal entries // This method is used for profile pages where the author is already known let thought_ids: Vec<_> = rows.iter().map(|r| r.id).collect(); let user_row = sqlx::query_as::<_, crate::user::UserRow>( "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1" ).bind(user_id.as_uuid()).fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))? .ok_or(DomainError::NotFound)?; let author = User::from(user_row); let items = rows.into_iter().map(|r| { let thought = Thought::from(r); FeedEntry { thought, author: author.clone(), like_count: 0, boost_count: 0, reply_count: 0, liked_by_viewer: false, boosted_by_viewer: false } }).collect(); Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) } } ``` Note: `crate::user::UserRow` needs to be `pub`. Update `user.rs`: change `struct UserRow` to `pub(crate) struct UserRow` and add `impl From for User` as `pub(crate)`. - [ ] **Run:** `cargo test -p postgres thought` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/adapters/postgres/src/thought.rs crates/adapters/postgres/src/user.rs git commit -m "feat(postgres): ThoughtRepository" ``` --- ### Task 8: Postgres — FollowRepository + BlockRepository **Files:** `crates/adapters/postgres/src/follow.rs`, `crates/adapters/postgres/src/block.rs` - [ ] **Write the tests:** ```rust // follow.rs tests #[sqlx::test(migrations = "migrations")] async fn follow_and_find(pool: sqlx::PgPool) { // seed two users first (copy seed_user pattern from Task 7, use "alice"/"bob") let alice = seed_user(&pool, "alice", "alice@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgFollowRepository::new(pool); let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Accepted, ap_id: None, created_at: chrono::Utc::now() }; repo.save(&follow).await.unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); assert_eq!(found.state, FollowState::Accepted); } // block.rs tests #[sqlx::test(migrations = "migrations")] async fn block_exists(pool: sqlx::PgPool) { let alice = seed_user(&pool, "alice", "alice@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgBlockRepository::new(pool); let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: chrono::Utc::now() }; repo.save(&block).await.unwrap(); assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); } ``` - [ ] **Run:** `cargo test -p postgres follow block` — Expected: FAIL. - [ ] **Write `crates/adapters/postgres/src/follow.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User}, ports::FollowRepository, value_objects::UserId}; pub struct PgFollowRepository { pool: PgPool } impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl FollowRepository for PgFollowRepository { async fn save(&self, f: &Follow) -> Result<(), DomainError> { sqlx::query( "INSERT INTO follows(follower_id,following_id,state,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(follower_id,following_id) DO UPDATE SET state=EXCLUDED.state,ap_id=EXCLUDED.ap_id" ).bind(f.follower_id.as_uuid()).bind(f.following_id.as_uuid()).bind(f.state.as_str()).bind(&f.ap_id).bind(f.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { let r = sqlx::query("DELETE FROM follows WHERE follower_id=$1 AND following_id=$2") .bind(follower_id.as_uuid()).bind(following_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; if r.rows_affected() == 0 { return Err(DomainError::NotFound); } Ok(()) } async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option, created_at: DateTime } sqlx::query_as::<_, Row>("SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2") .bind(follower_id.as_uuid()).bind(following_id.as_uuid()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(|r| Follow { follower_id: UserId::from_uuid(r.follower_id), following_id: UserId::from_uuid(r.following_id), state: FollowState::from_str(&r.state), ap_id: r.ap_id, created_at: r.created_at })) } async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2") .bind(follower_id.as_uuid()).bind(following_id.as_uuid()).bind(state.as_str()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'") .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let rows = sqlx::query_as::<_, crate::user::UserRow>( "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at FROM users u JOIN follows f ON f.follower_id=u.id WHERE f.following_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(User::from).collect(), total, page: page.page, per_page: page.per_page }) } async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'") .bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let rows = sqlx::query_as::<_, crate::user::UserRow>( "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at FROM users u JOIN follows f ON f.following_id=u.id WHERE f.follower_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(User::from).collect(), total, page: page.page, per_page: page.per_page }) } async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { let ids: Vec = sqlx::query_scalar("SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'") .bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(ids.into_iter().map(UserId::from_uuid).collect()) } } ``` - [ ] **Write `crates/adapters/postgres/src/block.rs`:** ```rust use async_trait::async_trait; use chrono::Utc; use sqlx::PgPool; use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId}; pub struct PgBlockRepository { pool: PgPool } impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl BlockRepository for PgBlockRepository { async fn save(&self, b: &Block) -> Result<(), DomainError> { sqlx::query("INSERT INTO blocks(blocker_id,blocked_id,created_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING") .bind(b.blocker_id.as_uuid()).bind(b.blocked_id.as_uuid()).bind(b.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { sqlx::query("DELETE FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") .bind(blocker_id.as_uuid()).bind(blocked_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2") .bind(blocker_id.as_uuid()).bind(blocked_id.as_uuid()) .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(count > 0) } } ``` - [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/adapters/postgres/src/follow.rs crates/adapters/postgres/src/block.rs git commit -m "feat(postgres): FollowRepository, BlockRepository" ``` --- ### Task 9: Postgres — LikeRepository + BoostRepository **Files:** `crates/adapters/postgres/src/like.rs`, `crates/adapters/postgres/src/boost.rs` - [ ] **Write tests** (same seed_user + seed_thought pattern): ```rust // like.rs #[sqlx::test(migrations = "migrations")] async fn like_and_count(pool: sqlx::PgPool) { let (user, thought) = seed_user_and_thought(&pool).await; let repo = PgLikeRepository::new(pool); let like = Like { id: LikeId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; repo.save(&like).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); repo.delete(&user.id, &thought.id).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); } // boost.rs — identical structure, use PgBoostRepository ``` - [ ] **Run:** `cargo test -p postgres like boost` — Expected: FAIL. - [ ] **Write `crates/adapters/postgres/src/like.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}}; pub struct PgLikeRepository { pool: PgPool } impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl LikeRepository for PgLikeRepository { async fn save(&self, l: &Like) -> Result<(), DomainError> { sqlx::query("INSERT INTO likes(id,user_id,thought_id,ap_id,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,thought_id) DO NOTHING") .bind(l.id.as_uuid()).bind(l.user_id.as_uuid()).bind(l.thought_id.as_uuid()).bind(&l.ap_id).bind(l.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let r = sqlx::query("DELETE FROM likes WHERE user_id=$1 AND thought_id=$2") .bind(user_id.as_uuid()).bind(thought_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; if r.rows_affected() == 0 { return Err(DomainError::NotFound); } Ok(()) } async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option, created_at: DateTime } sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2") .bind(user_id.as_uuid()).bind(thought_id.as_uuid()).fetch_optional(&self.pool).await .map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(|r| Like { id: LikeId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), thought_id: ThoughtId::from_uuid(r.thought_id), ap_id: r.ap_id, created_at: r.created_at })) } async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1") .bind(thought_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) } } ``` - [ ] **Write `crates/adapters/postgres/src/boost.rs`** — identical structure, replace `likes` table with `boosts`, `LikeId`→`BoostId`, `PgLikeRepository`→`PgBoostRepository`, `LikeRepository`→`BoostRepository`, `Like`→`Boost`. - [ ] **Run:** `cargo test -p postgres` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/adapters/postgres/src/like.rs crates/adapters/postgres/src/boost.rs git commit -m "feat(postgres): LikeRepository, BoostRepository" ``` --- ### Task 10: Postgres — TagRepository, ApiKeyRepository, TopFriendRepository, NotificationRepository, RemoteActorRepository **Files:** `src/tag.rs`, `src/api_key.rs`, `src/top_friend.rs`, `src/notification.rs`, `src/remote_actor.rs` - [ ] **Write `src/tag.rs`:** ```rust use async_trait::async_trait; use sqlx::PgPool; use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, tag::Tag, thought::Thought}, ports::TagRepository, value_objects::ThoughtId}; pub struct PgTagRepository { pool: PgPool } impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl TagRepository for PgTagRepository { async fn find_or_create(&self, name: &str) -> Result { let name = name.to_lowercase(); sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING").bind(&name) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name) .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Tag { id: row.id, name: row.name }) } async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError> { sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING") .bind(thought_id.as_uuid()).bind(tag_id) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError> { sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1").bind(thought_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: i32, name: String } sqlx::query_as::<_, Row>("SELECT t.id,t.name FROM tags t JOIN thought_tags tt ON tt.tag_id=t.id WHERE tt.thought_id=$1") .bind(thought_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) .map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect()) } async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thought_tags tt JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1") .bind(tag_name).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>( "SELECT th.id,th.user_id,th.content,th.in_reply_to_id,th.in_reply_to_url,th.ap_id,th.visibility,th.content_warning,th.sensitive,th.local,th.created_at,th.updated_at FROM thoughts th JOIN thought_tags tt ON tt.thought_id=th.id JOIN tags t ON t.id=tt.tag_id WHERE t.name=$1 ORDER BY th.created_at DESC LIMIT $2 OFFSET $3" ).bind(tag_name).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(Thought::from).collect(), total, page: page.page, per_page: page.per_page }) } } ``` Make `ThoughtRow` `pub(crate)` in `thought.rs` (same as UserRow pattern). - [ ] **Write `src/api_key.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}}; pub struct PgApiKeyRepository { pool: PgPool } impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl ApiKeyRepository for PgApiKeyRepository { async fn save(&self, k: &ApiKey) -> Result<(), DomainError> { sqlx::query("INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)") .bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1").bind(hash) .fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at })) } async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime } sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE user_id=$1 ORDER BY created_at DESC").bind(user_id.as_uuid()) .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) .map(|rows| rows.into_iter().map(|r| ApiKey { id: ApiKeyId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), key_hash: r.key_hash, name: r.name, created_at: r.created_at }).collect()) } async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2").bind(id.as_uuid()).bind(user_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } } ``` - [ ] **Write `src/top_friend.rs`:** ```rust use async_trait::async_trait; use sqlx::PgPool; use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId}; pub struct PgTopFriendRepository { pool: PgPool } impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl TopFriendRepository for PgTopFriendRepository { async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?; sqlx::query("DELETE FROM top_friends WHERE user_id=$1").bind(user_id.as_uuid()).execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; for (friend_id, pos) in friends { sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)") .bind(user_id.as_uuid()).bind(friend_id.as_uuid()).bind(pos) .execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; } tx.commit().await.map_err(|e| DomainError::Internal(e.to_string())) } async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16, id: uuid::Uuid, username: String, email: String, password_hash: String, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, local: bool, ap_id: Option, inbox_url: Option, public_key: Option, private_key: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime } let rows = sqlx::query_as::<_, Row>( "SELECT tf.user_id,tf.friend_id,tf.position,u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.public_key,u.private_key,u.created_at,u.updated_at FROM top_friends tf JOIN users u ON u.id=tf.friend_id WHERE tf.user_id=$1 ORDER BY tf.position" ).bind(user_id.as_uuid()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(rows.into_iter().map(|r| { let tf = TopFriend { user_id: UserId::from_uuid(r.user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position }; let u = crate::user::UserRow { id: r.id, username: r.username, email: r.email, password_hash: r.password_hash, display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key, private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at }; (tf, User::from(u)) }).collect()) } } ``` - [ ] **Write `src/notification.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::{feed::{PageParams, Paginated}, notification::{Notification, NotificationType}}, ports::NotificationRepository, value_objects::{NotificationId, ThoughtId, UserId}}; pub struct PgNotificationRepository { pool: PgPool } impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl NotificationRepository for PgNotificationRepository { async fn save(&self, n: &Notification) -> Result<(), DomainError> { sqlx::query("INSERT INTO notifications(id,user_id,type,from_user_id,thought_id,read,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)") .bind(n.id.as_uuid()).bind(n.user_id.as_uuid()).bind(n.notification_type.as_str()) .bind(n.from_user_id.as_ref().map(|u| u.as_uuid())).bind(n.thought_id.as_ref().map(|t| t.as_uuid())) .bind(n.read).bind(n.created_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1").bind(user_id.as_uuid()).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; #[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, r#type: String, from_user_id: Option, thought_id: Option, read: bool, created_at: DateTime } let rows = sqlx::query_as::<_, Row>("SELECT id,user_id,type,from_user_id,thought_id,read,created_at FROM notifications WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3") .bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let items = rows.into_iter().map(|r| Notification { id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), notification_type: NotificationType::from_str(&r.r#type), from_user_id: r.from_user_id.map(UserId::from_uuid), thought_id: r.thought_id.map(ThoughtId::from_uuid), read: r.read, created_at: r.created_at }).collect(); Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) } async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2").bind(id.as_uuid()).bind(user_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> { sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1").bind(user_id.as_uuid()) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } } ``` - [ ] **Write `src/remote_actor.rs`:** ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository}; pub struct PgRemoteActorRepository { pool: PgPool } impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl RemoteActorRepository for PgRemoteActorRepository { async fn upsert(&self, a: &RemoteActor) -> Result<(), DomainError> { sqlx::query( "INSERT INTO remote_actors(url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(url) DO UPDATE SET handle=EXCLUDED.handle,display_name=EXCLUDED.display_name,inbox_url=EXCLUDED.inbox_url,shared_inbox_url=EXCLUDED.shared_inbox_url,public_key=EXCLUDED.public_key,last_fetched_at=EXCLUDED.last_fetched_at" ).bind(&a.url).bind(&a.handle).bind(&a.display_name).bind(&a.inbox_url).bind(&a.shared_inbox_url).bind(&a.public_key).bind(a.last_fetched_at) .execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) } async fn find_by_url(&self, url: &str) -> Result, DomainError> { #[derive(sqlx::FromRow)] struct Row { url: String, handle: String, display_name: Option, inbox_url: String, shared_inbox_url: Option, public_key: String, last_fetched_at: DateTime } sqlx::query_as::<_, Row>("SELECT url,handle,display_name,inbox_url,shared_inbox_url,public_key,last_fetched_at FROM remote_actors WHERE url=$1").bind(url) .fetch_optional(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())) .map(|o| o.map(|r| RemoteActor { url: r.url, handle: r.handle, display_name: r.display_name, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, public_key: r.public_key, last_fetched_at: r.last_fetched_at })) } } ``` - [ ] **Write `src/feed.rs`** — home feed + public feed + basic ILIKE search: ```rust use async_trait::async_trait; use chrono::{DateTime, Utc}; use sqlx::PgPool; use domain::{errors::DomainError, models::{feed::{FeedEntry, PageParams, Paginated}, thought::Thought, user::User}, ports::FeedRepository, value_objects::UserId}; pub struct PgFeedRepository { pool: PgPool } impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } #[derive(sqlx::FromRow)] struct FeedRow { // thought fields thought_id: uuid::Uuid, user_id: uuid::Uuid, content: String, in_reply_to_id: Option, in_reply_to_url: Option, ap_id: Option, visibility: String, content_warning: Option, sensitive: bool, local: bool, thought_created_at: DateTime, updated_at: Option>, // author fields author_id: uuid::Uuid, username: String, email: String, password_hash: String, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option, author_local: bool, author_ap_id: Option, inbox_url: Option, public_key: Option, private_key: Option, author_created_at: DateTime, author_updated_at: DateTime, // counts like_count: i64, boost_count: i64, reply_count: i64, } const FEED_SELECT: &str = " SELECT t.id AS thought_id, t.user_id, t.content, t.in_reply_to_id, t.in_reply_to_url, t.ap_id, t.visibility, t.content_warning, t.sensitive, t.local, t.created_at AS thought_created_at, t.updated_at, u.id AS author_id, u.username, u.email, u.password_hash, u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.local AS author_local, u.ap_id AS author_ap_id, u.inbox_url, u.public_key, u.private_key, u.created_at AS author_created_at, u.updated_at AS author_updated_at, (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count, (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count, (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count FROM thoughts t JOIN users u ON u.id=t.user_id"; fn row_to_entry(r: FeedRow, viewer_id: Option<&UserId>) -> FeedEntry { use domain::models::thought::Visibility; use domain::value_objects::{Content, Email, PasswordHash, ThoughtId, Username}; let thought = Thought { id: ThoughtId::from_uuid(r.thought_id), user_id: UserId::from_uuid(r.user_id), content: Content::new_remote(r.content), in_reply_to_id: r.in_reply_to_id.map(ThoughtId::from_uuid), in_reply_to_url: r.in_reply_to_url, ap_id: r.ap_id, visibility: Visibility::from_str(&r.visibility), content_warning: r.content_warning, sensitive: r.sensitive, local: r.local, created_at: r.thought_created_at, updated_at: r.updated_at, }; let author = User { id: UserId::from_uuid(r.author_id), username: Username::from_trusted(r.username), email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, local: r.author_local, ap_id: r.author_ap_id, inbox_url: r.inbox_url, public_key: r.public_key, private_key: r.private_key, created_at: r.author_created_at, updated_at: r.author_updated_at, }; FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false } } #[async_trait] impl FeedRepository for PgFeedRepository { async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { let ids: Vec = following_ids.iter().map(|id| id.as_uuid()).collect(); let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'") .bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let sql = format!("{FEED_SELECT} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(&ids).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) } async fn public_feed(&self, page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'") .fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let sql = format!("{FEED_SELECT} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2"); let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) } async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts t WHERE t.content ILIKE $1 AND t.visibility='public'") .bind(&pattern).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; let sql = format!("{FEED_SELECT} WHERE t.content ILIKE $1 AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); let rows = sqlx::query_as::<_, FeedRow>(&sql).bind(&pattern).bind(page.limit()).bind(page.offset()).fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Paginated { items: rows.into_iter().map(|r| row_to_entry(r, _viewer_id)).collect(), total, page: page.page, per_page: page.per_page }) } } ``` - [ ] **Run:** `cargo test -p postgres` — Expected: all PASS. - [ ] **Commit:** ```bash git add crates/adapters/postgres/src/ git commit -m "feat(postgres): Tag, ApiKey, TopFriend, Notification, RemoteActor, Feed repos" ``` --- ### Task 11: Auth adapter **Files:** `crates/adapters/auth/src/lib.rs` - [ ] **Write the test:** ```rust #[cfg(test)] mod tests { use super::*; use domain::{ports::AuthService, value_objects::UserId}; #[test] fn generate_and_validate_token() { let svc = JwtAuthService::new("secret".into(), 3600); let id = UserId::new(); let tok = svc.generate_token(&id).unwrap(); let parsed = svc.validate_token(&tok.token).unwrap(); assert_eq!(parsed.as_uuid(), id.as_uuid()); } #[test] fn invalid_token_returns_unauthorized() { let svc = JwtAuthService::new("secret".into(), 3600); assert!(svc.validate_token("not.a.token").is_err()); } #[tokio::test] async fn hash_and_verify() { let hasher = Argon2PasswordHasher; let hash = hasher.hash("mypassword").await.unwrap(); assert!(hasher.verify("mypassword", &hash).await.unwrap()); assert!(!hasher.verify("wrongpassword", &hash).await.unwrap()); } } ``` - [ ] **Run:** `cargo test -p auth` — Expected: FAIL. - [ ] **Write `crates/adapters/auth/src/lib.rs`:** ```rust use async_trait::async_trait; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use domain::{ errors::DomainError, ports::{AuthService, GeneratedToken, PasswordHasher}, value_objects::{PasswordHash, UserId}, }; #[derive(Serialize, Deserialize)] struct Claims { sub: String, exp: usize } pub struct JwtAuthService { secret: String, ttl_seconds: i64 } impl JwtAuthService { pub fn new(secret: String, ttl_seconds: i64) -> Self { Self { secret, ttl_seconds } } } impl AuthService for JwtAuthService { fn generate_token(&self, user_id: &UserId) -> Result { let exp = (Utc::now() + Duration::seconds(self.ttl_seconds)).timestamp() as usize; let claims = Claims { sub: user_id.as_uuid().to_string(), exp }; let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(self.secret.as_bytes())) .map_err(|e| DomainError::Internal(e.to_string()))?; Ok(GeneratedToken { token, user_id: user_id.clone() }) } fn validate_token(&self, token: &str) -> Result { let data = decode::(token, &DecodingKey::from_secret(self.secret.as_bytes()), &Validation::default()) .map_err(|_| DomainError::Unauthorized)?; let uuid = uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?; Ok(UserId::from_uuid(uuid)) } } pub struct Argon2PasswordHasher; #[async_trait] impl PasswordHasher for Argon2PasswordHasher { async fn hash(&self, plain: &str) -> Result { use argon2::{password_hash::{rand_core::OsRng, SaltString}, Argon2, PasswordHasher as _}; let salt = SaltString::generate(&mut OsRng); let hash = Argon2::default().hash_password(plain.as_bytes(), &salt) .map_err(|e| DomainError::Internal(e.to_string()))?.to_string(); Ok(PasswordHash(hash)) } async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?; Ok(Argon2::default().verify_password(plain.as_bytes(), &parsed).is_ok()) } } ``` - [ ] **Run:** `cargo test -p auth` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/adapters/auth/ git commit -m "feat(auth): JWT AuthService and Argon2 PasswordHasher" ``` --- ### Task 12: api-types — request/response DTOs **Files:** `crates/api-types/src/lib.rs`, `src/requests.rs`, `src/responses.rs` - [ ] **Write `crates/api-types/src/requests.rs`:** ```rust use serde::Deserialize; use uuid::Uuid; #[derive(Deserialize)] pub struct RegisterRequest { pub username: String, pub email: String, pub password: String } #[derive(Deserialize)] pub struct LoginRequest { pub email: String, pub password: String } #[derive(Deserialize)] pub struct CreateThoughtRequest { pub content: String, pub in_reply_to_id: Option, pub visibility: Option, pub content_warning: Option, pub sensitive: Option, } #[derive(Deserialize)] pub struct EditThoughtRequest { pub content: String } #[derive(Deserialize)] pub struct UpdateProfileRequest { pub display_name: Option, pub bio: Option, pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, } #[derive(Deserialize)] pub struct SetTopFriendsRequest { pub friend_ids: Vec } // ordered list, max 8 #[derive(Deserialize)] pub struct CreateApiKeyRequest { pub name: String } #[derive(Deserialize)] pub struct PaginationQuery { pub page: Option, pub per_page: Option } impl PaginationQuery { pub fn page(&self) -> u64 { self.page.unwrap_or(1).max(1) } pub fn per_page(&self) -> u64 { self.per_page.unwrap_or(20).min(100) } } #[derive(Deserialize)] pub struct SearchQuery { pub q: String, pub page: Option, pub per_page: Option } ``` - [ ] **Write `crates/api-types/src/responses.rs`:** ```rust use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; #[derive(Serialize)] pub struct AuthResponse { pub token: String, pub user: UserResponse } #[derive(Serialize, Clone)] pub struct UserResponse { pub id: Uuid, pub username: String, pub display_name: Option, pub bio: Option, pub avatar_url: Option, pub header_url: Option, pub local: bool, pub created_at: DateTime, } #[derive(Serialize, Clone)] pub struct ThoughtResponse { pub id: Uuid, pub content: String, pub author: UserResponse, pub in_reply_to_id: Option, pub visibility: String, pub content_warning: Option, pub sensitive: bool, pub like_count: i64, pub boost_count: i64, pub reply_count: i64, pub liked_by_viewer: bool, pub boosted_by_viewer: bool, pub created_at: DateTime, pub updated_at: Option>, } #[derive(Serialize)] pub struct PagedResponse { pub items: Vec, pub total: i64, pub page: u64, pub per_page: u64, } #[derive(Serialize)] pub struct ApiKeyResponse { pub id: Uuid, pub name: String, pub created_at: DateTime } #[derive(Serialize)] pub struct NotificationResponse { pub id: Uuid, pub notification_type: String, pub from_user: Option, pub thought_id: Option, pub read: bool, pub created_at: DateTime, } #[derive(Serialize)] pub struct ErrorResponse { pub error: String } ``` - [ ] **Update `crates/api-types/src/lib.rs`:** ```rust pub mod requests; pub mod responses; ``` - [ ] **Run:** `cargo check -p api-types` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/api-types/ git commit -m "feat(api-types): request and response DTOs" ``` --- ### Task 13: Application — auth use cases **Files:** `crates/application/src/use_cases/auth.rs`, `src/use_cases/mod.rs`, `src/lib.rs` - [ ] **Write the test:** ```rust #[cfg(test)] mod tests { use super::*; use domain::testing::{TestStore, NoOpEventPublisher}; use domain::ports::{PasswordHasher, AuthService}; struct FakeHasher; #[async_trait::async_trait] impl PasswordHasher for FakeHasher { async fn hash(&self, plain: &str) -> Result { Ok(domain::value_objects::PasswordHash(plain.to_string())) } async fn verify(&self, plain: &str, hash: &domain::value_objects::PasswordHash) -> Result { Ok(plain == hash.0) } } struct FakeAuth; impl AuthService for FakeAuth { fn generate_token(&self, uid: &domain::value_objects::UserId) -> Result { Ok(domain::ports::GeneratedToken { token: uid.to_string(), user_id: uid.clone() }) } fn validate_token(&self, token: &str) -> Result { Ok(domain::value_objects::UserId::from_uuid(uuid::Uuid::parse_str(token).map_err(|_| domain::errors::DomainError::Unauthorized)?)) } } #[tokio::test] async fn register_creates_user_and_returns_token() { let store = TestStore::default(); let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() }).await.unwrap(); assert_eq!(out.user.username.as_str(), "alice"); assert!(!out.token.is_empty()); } #[tokio::test] async fn register_rejects_duplicate_username() { let store = TestStore::default(); let input = || RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() }; register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap_err(); assert!(matches!(err, domain::errors::DomainError::Conflict(_))); } #[tokio::test] async fn login_returns_token_on_valid_credentials() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "bob".into(), email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); let out = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); assert!(!out.token.is_empty()); } #[tokio::test] async fn login_fails_on_wrong_password() { let store = TestStore::default(); register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "bob".into(), email: "bob@ex.com".into(), password: "pw".into() }).await.unwrap(); let err = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "bob@ex.com".into(), password: "wrong".into() }).await.unwrap_err(); assert!(matches!(err, domain::errors::DomainError::Unauthorized)); } } ``` - [ ] **Run:** `cargo test -p application auth` — Expected: FAIL. - [ ] **Write `crates/application/src/use_cases/auth.rs`:** ```rust use domain::{ errors::DomainError, models::user::User, ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}, value_objects::{Email, UserId, Username}, }; pub struct RegisterInput { pub username: String, pub email: String, pub password: String } pub struct RegisterOutput { pub user: User, pub token: String } pub async fn register(users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, _events: &dyn EventPublisher, input: RegisterInput) -> Result { let username = Username::new(input.username)?; let email = Email::new(input.email)?; if users.find_by_username(&username).await?.is_some() { return Err(DomainError::Conflict("username taken".into())); } if users.find_by_email(&email).await?.is_some() { return Err(DomainError::Conflict("email taken".into())); } let hash = hasher.hash(&input.password).await?; let user = User::new_local(UserId::new(), username, email, hash); users.save(&user).await?; let token = auth.generate_token(&user.id)?; Ok(RegisterOutput { user, token: token.token }) } pub struct LoginInput { pub email: String, pub password: String } pub struct LoginOutput { pub user: User, pub token: String } pub async fn login(users: &dyn UserRepository, hasher: &dyn PasswordHasher, auth: &dyn AuthService, input: LoginInput) -> Result { let email = Email::new(input.email)?; let user = users.find_by_email(&email).await?.ok_or(DomainError::Unauthorized)?; if !hasher.verify(&input.password, &user.password_hash).await? { return Err(DomainError::Unauthorized); } let token = auth.generate_token(&user.id)?; Ok(LoginOutput { user, token: token.token }) } ``` - [ ] **Run:** `cargo test -p application auth` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/application/ git commit -m "feat(application): register and login use cases" ``` --- ### Task 14: Application — thought use cases **Files:** `crates/application/src/use_cases/thoughts.rs` - [ ] **Write the test:** ```rust #[cfg(test)] mod tests { use super::*; use domain::testing::{TestStore, NoOpEventPublisher}; use domain::value_objects::*; use domain::models::user::User; fn make_user() -> User { User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())) } #[tokio::test] async fn create_thought_saves_and_publishes_event() { let store = TestStore::default(); store.save(&make_user()).await.unwrap(); // need user in store for FK let user = { store.users.lock().unwrap().first().unwrap().clone() }; let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: user.id.clone(), content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(store.events.lock().unwrap().len(), 1); } #[tokio::test] async fn delete_own_thought_succeeds() { let store = TestStore::default(); let user = make_user(); store.users.lock().unwrap().push(user.clone()); let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: user.id.clone(), content: "bye".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); delete_thought(&store, &store, &out.thought.id, &user.id).await.unwrap(); assert!(store.thoughts.lock().unwrap().is_empty()); } #[tokio::test] async fn delete_other_thought_returns_forbidden() { let store = TestStore::default(); let alice = make_user(); let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); store.users.lock().unwrap().extend([alice.clone(), bob.clone()]); let out = create_thought(&store, &store, &store, CreateThoughtInput { user_id: alice.id.clone(), content: "secret".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false }).await.unwrap(); let err = delete_thought(&store, &store, &out.thought.id, &bob.id).await.unwrap_err(); assert!(matches!(err, domain::errors::DomainError::NotFound)); } } ``` - [ ] **Run:** `cargo test -p application thoughts` — Expected: FAIL. - [ ] **Write `crates/application/src/use_cases/thoughts.rs`:** ```rust use domain::{ errors::DomainError, events::DomainEvent, models::thought::{Thought, Visibility}, ports::{EventPublisher, ThoughtRepository, UserRepository}, value_objects::{Content, ThoughtId, UserId}, }; pub struct CreateThoughtInput { pub user_id: UserId, pub content: String, pub in_reply_to_id: Option, pub visibility: Option, pub content_warning: Option, pub sensitive: bool, } pub struct CreateThoughtOutput { pub thought: Thought } pub async fn create_thought(thoughts: &dyn ThoughtRepository, _users: &dyn UserRepository, events: &dyn EventPublisher, input: CreateThoughtInput) -> Result { let content = Content::new_local(input.content)?; let visibility = input.visibility.as_deref().map(Visibility::from_str).unwrap_or(Visibility::Public); let thought = Thought::new_local(ThoughtId::new(), input.user_id, content, input.in_reply_to_id.clone(), visibility, input.content_warning, input.sensitive); thoughts.save(&thought).await?; events.publish(&DomainEvent::ThoughtCreated { thought_id: thought.id.clone(), user_id: thought.user_id.clone(), in_reply_to_id: input.in_reply_to_id }).await?; Ok(CreateThoughtOutput { thought }) } pub async fn delete_thought(thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; if thought.user_id != *user_id { return Err(DomainError::NotFound); } // don't leak existence thoughts.delete(id, user_id).await?; events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?; Ok(()) } pub async fn edit_thought(thoughts: &dyn ThoughtRepository, events: &dyn EventPublisher, id: &ThoughtId, user_id: &UserId, new_content: String) -> Result<(), DomainError> { let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; if thought.user_id != *user_id { return Err(DomainError::NotFound); } let content = Content::new_local(new_content)?; thoughts.update_content(id, &content).await?; events.publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone() }).await?; Ok(()) } pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result { thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) } pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result, DomainError> { thoughts.get_thread(id).await } ``` - [ ] **Run:** `cargo test -p application thoughts` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/application/src/use_cases/thoughts.rs git commit -m "feat(application): thought use cases" ``` --- ### Task 15: Application — social use cases **Files:** `crates/application/src/use_cases/social.rs` - [ ] **Write the test:** ```rust #[cfg(test)] mod tests { use super::*; use domain::testing::{TestStore, NoOpEventPublisher}; use domain::value_objects::*; use domain::models::user::User; use domain::models::thought::{Thought, Visibility}; fn user(name: &str) -> User { User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into())) } #[tokio::test] async fn like_and_unlike() { let store = TestStore::default(); let alice = user("alice"); let thought_id = ThoughtId::new(); store.thoughts.lock().unwrap().push(Thought::new_local(thought_id.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false)); like_thought(&store, &store, &alice.id, &thought_id).await.unwrap(); assert_eq!(store.likes.lock().unwrap().len(), 1); unlike_thought(&store, &store, &alice.id, &thought_id).await.unwrap(); assert!(store.likes.lock().unwrap().is_empty()); } #[tokio::test] async fn follow_and_unfollow() { let store = TestStore::default(); let alice = user("alice"); let bob = user("bob"); follow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); assert!(store.follows.lock().unwrap().iter().any(|f| f.follower_id == alice.id && f.following_id == bob.id)); unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); assert!(store.follows.lock().unwrap().is_empty()); } #[tokio::test] async fn cannot_follow_self() { let store = TestStore::default(); let alice = user("alice"); let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err(); assert!(matches!(err, domain::errors::DomainError::InvalidInput(_))); } } ``` - [ ] **Run:** `cargo test -p application social` — Expected: FAIL. - [ ] **Write `crates/application/src/use_cases/social.rs`:** ```rust use chrono::Utc; use domain::{ errors::DomainError, events::DomainEvent, models::social::{Block, Boost, Follow, FollowState, Like}, ports::{BlockRepository, BoostRepository, EventPublisher, FollowRepository, LikeRepository, ThoughtRepository}, value_objects::{BoostId, LikeId, ThoughtId, UserId}, }; pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; likes.save(&like).await?; events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { likes.delete(user_id, thought_id).await?; events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; boosts.save(&boost).await?; events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { boosts.delete(user_id, thought_id).await?; events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; Ok(()) } pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); } let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; follows.save(&follow).await?; events.publish(&DomainEvent::FollowRequested { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.delete(follower_id, following_id).await?; events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.update_state(follower_id, following_id, &FollowState::Accepted).await?; events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { follows.update_state(follower_id, following_id, &FollowState::Rejected).await?; events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; Ok(()) } pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); } let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() }; blocks.save(&block).await?; events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?; Ok(()) } pub async fn unblock_user(blocks: &dyn BlockRepository, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { blocks.delete(blocker_id, blocked_id).await?; Ok(()) } ``` - [ ] **Run:** `cargo test -p application social` — Expected: PASS. - [ ] **Commit:** ```bash git add crates/application/src/use_cases/social.rs git commit -m "feat(application): social use cases (like, boost, follow, block)" ``` --- ### Task 16: Application — feed, profile, and api-key use cases **Files:** `use_cases/feed.rs`, `use_cases/profile.rs`, `use_cases/api_keys.rs`, `use_cases/mod.rs`, update `src/lib.rs` - [ ] **Write `use_cases/feed.rs`:** ```rust use domain::{errors::DomainError, models::feed::{FeedEntry, PageParams, Paginated, UserSummary}, ports::{FeedRepository, FollowRepository, TagRepository, ThoughtRepository, UserRepository}, value_objects::{ThoughtId, UserId}}; pub async fn get_home_feed(feed: &dyn FeedRepository, follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { let following_ids = follows.get_accepted_following_ids(user_id).await?; feed.home_feed(&following_ids, &page, Some(user_id)).await } pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserId>, page: PageParams) -> Result, DomainError> { feed.public_feed(&page, viewer_id).await } pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { thoughts.list_by_user(user_id, &page).await } pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { follows.list_followers(user_id, &page).await } pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { follows.list_following(user_id, &page).await } pub async fn get_by_tag(tags: &dyn TagRepository, tag_name: &str, page: PageParams) -> Result, DomainError> { tags.list_thoughts_by_tag(tag_name, &page).await } pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { feed.search(query, &page, viewer_id).await } pub async fn get_profile(users: &dyn UserRepository) -> Result, DomainError> { users.list_with_stats().await } ``` - [ ] **Write `use_cases/profile.rs`:** ```rust use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::{TopFriendRepository, UserRepository}, value_objects::UserId}; pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result { users.find_by_id(user_id).await?.ok_or(DomainError::NotFound) } pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result { let username = domain::value_objects::Username::from_trusted(username.to_string()); users.find_by_username(&username).await?.ok_or(DomainError::NotFound) } pub async fn update_profile(users: &dyn UserRepository, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { users.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css).await } pub async fn get_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId) -> Result, DomainError> { top_friends.list_for_user(user_id).await } pub async fn set_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec) -> Result<(), DomainError> { if friend_ids.len() > 8 { return Err(DomainError::InvalidInput("top friends: max 8".into())); } let friends: Vec<(UserId, i16)> = friend_ids.into_iter().enumerate().map(|(i, id)| (id, (i + 1) as i16)).collect(); top_friends.set_top_friends(user_id, friends).await } ``` - [ ] **Write `use_cases/api_keys.rs`:** ```rust use chrono::Utc; use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}}; pub async fn list_api_keys(keys: &dyn ApiKeyRepository, user_id: &UserId) -> Result, DomainError> { keys.list_for_user(user_id).await } pub async fn create_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, name: String) -> Result<(ApiKey, String)> { // Return the raw key once — after this it's only stored as hash let raw_key = uuid::Uuid::new_v4().to_string().replace('-', ""); let key_hash = sha256(&raw_key); let key = ApiKey { id: ApiKeyId::new(), user_id: user_id.clone(), key_hash, name, created_at: Utc::now() }; keys.save(&key).await?; Ok((key, raw_key)) } pub async fn delete_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, key_id: &ApiKeyId) -> Result<(), DomainError> { keys.delete(key_id, user_id).await } fn sha256(s: &str) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; // Use SHA-256 properly — add `sha2` to auth Cargo.toml or compute here with ring/sha2 // Simplest: use sha2 crate // For now stub — replace with real sha2 in implementation: let mut h = DefaultHasher::new(); s.hash(&mut h); format!("{:x}", h.finish()) } ``` Note: replace the `sha256` stub with real SHA-256 using the `sha2` crate. Add `sha2 = "0.10"` to `presentation/Cargo.toml` (or a dedicated hashing util). The api_keys use case should use `sha2::Sha256` and `hex` encoding. - [ ] **Write `use_cases/mod.rs`:** ```rust pub mod api_keys; pub mod auth; pub mod feed; pub mod profile; pub mod social; pub mod thoughts; ``` - [ ] **Update `crates/application/src/lib.rs`:** ```rust pub mod use_cases; ``` - [ ] **Run:** `cargo check -p application` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/application/ git commit -m "feat(application): feed, profile, api-key use cases" ``` --- ### Task 17: Presentation — state, extractors, errors **Files:** `crates/presentation/src/state.rs`, `src/extractors.rs`, `src/errors.rs` - [ ] **Write `crates/presentation/src/state.rs`:** ```rust use std::sync::Arc; use domain::ports::*; #[derive(Clone)] pub struct AppState { pub users: Arc, pub thoughts: Arc, pub likes: Arc, pub boosts: Arc, pub follows: Arc, pub blocks: Arc, pub tags: Arc, pub api_keys: Arc, pub top_friends: Arc, pub notifications: Arc, pub remote_actors: Arc, pub feed: Arc, pub auth: Arc, pub hasher: Arc, pub events: Arc, } ``` - [ ] **Write `crates/presentation/src/errors.rs`:** ```rust use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use domain::errors::DomainError; use api_types::responses::ErrorResponse; pub enum ApiError { Domain(DomainError), Unauthorized, BadRequest(String), } impl From for ApiError { fn from(e: DomainError) -> Self { Self::Domain(e) } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, msg) = match self { Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()), Self::Domain(DomainError::Unauthorized) => (StatusCode::UNAUTHORIZED, "unauthorized".into()), Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()), Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m), Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m), Self::Domain(DomainError::Internal(m)) => (StatusCode::INTERNAL_SERVER_ERROR, m), Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), }; (status, Json(ErrorResponse { error: msg })).into_response() } } ``` - [ ] **Write `crates/presentation/src/extractors.rs`:** ```rust use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use domain::value_objects::UserId; use crate::{errors::ApiError, state::AppState}; pub struct AuthUser(pub UserId); pub struct OptionalAuthUser(pub Option); #[async_trait] impl FromRequestParts for AuthUser { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { let uid = extract_user_id(parts, state).await?.ok_or(ApiError::Unauthorized)?; Ok(AuthUser(uid)) } } #[async_trait] impl FromRequestParts for OptionalAuthUser { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { Ok(OptionalAuthUser(extract_user_id(parts, state).await?)) } } async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result, ApiError> { // Try Bearer token first if let Some(auth_header) = parts.headers.get("Authorization") { if let Ok(s) = auth_header.to_str() { if let Some(token) = s.strip_prefix("Bearer ") { return state.auth.validate_token(token).map(Some).map_err(|_| ApiError::Unauthorized); } } } // Try X-Api-Key header if let Some(key_header) = parts.headers.get("X-Api-Key") { if let Ok(raw) = key_header.to_str() { let hash = sha256_hex(raw); if let Ok(Some(key)) = state.api_keys.find_by_hash(&hash).await { return Ok(Some(key.user_id)); } } } Ok(None) } fn sha256_hex(s: &str) -> String { use sha2::{Sha256, Digest}; let hash = Sha256::digest(s.as_bytes()); hex::encode(hash) } ``` Add `sha2 = "0.10"` and `hex = "0.4"` to `crates/presentation/Cargo.toml` dependencies. - [ ] **Run:** `cargo check -p presentation` — Expected: no errors (handlers not wired yet). - [ ] **Commit:** ```bash git add crates/presentation/src/state.rs crates/presentation/src/errors.rs crates/presentation/src/extractors.rs git commit -m "feat(presentation): state, errors, extractors" ``` --- ### Task 18: Presentation — auth and user handlers **Files:** `src/handlers/auth.rs`, `src/handlers/users.rs`, `src/handlers/mod.rs` - [ ] **Write `src/handlers/mod.rs`:** ```rust pub mod api_keys; pub mod auth; pub mod feed; pub mod notifications; pub mod social; pub mod thoughts; pub mod users; ``` - [ ] **Write `src/handlers/auth.rs`:** ```rust use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, UserResponse}}; use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use crate::{errors::ApiError, state::AppState}; pub async fn post_register(State(s): State, Json(body): Json) -> Result { let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput { username: body.username, email: body.email, password: body.password }).await?; let resp = AuthResponse { token: out.token, user: to_user_response(&out.user) }; Ok((StatusCode::CREATED, Json(resp))) } pub async fn post_login(State(s): State, Json(body): Json) -> Result { let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput { email: body.email, password: body.password }).await?; Ok(Json(AuthResponse { token: out.token, user: to_user_response(&out.user) })) } pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { UserResponse { id: u.id.as_uuid(), username: u.username.to_string(), display_name: u.display_name.clone(), bio: u.bio.clone(), avatar_url: u.avatar_url.clone(), header_url: u.header_url.clone(), local: u.local, created_at: u.created_at } } ``` - [ ] **Write `src/handlers/users.rs`:** ```rust use axum::{extract::{Path, State}, Json}; use api_types::{requests::UpdateProfileRequest, responses::UserResponse}; use application::use_cases::profile::{get_user_by_username, update_profile}; use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState}; pub async fn get_user(State(s): State, Path(username): Path) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; Ok(Json(to_user_response(&user))) } pub async fn patch_profile(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?; let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; Ok(Json(to_user_response(&user))) } ``` - [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/presentation/src/handlers/ git commit -m "feat(presentation): auth and user handlers" ``` --- ### Task 19: Presentation — thought, feed, social, notification, api-key handlers **Files:** `handlers/thoughts.rs`, `handlers/feed.rs`, `handlers/social.rs`, `handlers/notifications.rs`, `handlers/api_keys.rs` - [ ] **Write `handlers/thoughts.rs`:** ```rust use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json}; use uuid::Uuid; use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest, PaginationQuery}, responses::{PagedResponse, ThoughtResponse}}; use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thread, get_thought, CreateThoughtInput}; use domain::{models::feed::PageParams, value_objects::ThoughtId}; use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState}; fn to_thought_response(e: domain::models::feed::FeedEntry) -> ThoughtResponse { ThoughtResponse { id: e.thought.id.as_uuid(), content: e.thought.content.to_string(), author: to_user_response(&e.author), in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|x| x.as_uuid()), visibility: e.thought.visibility.as_str().to_string(), content_warning: e.thought.content_warning, sensitive: e.thought.sensitive, like_count: e.like_count, boost_count: e.boost_count, reply_count: e.reply_count, liked_by_viewer: e.liked_by_viewer, boosted_by_viewer: e.boosted_by_viewer, created_at: e.thought.created_at, updated_at: e.thought.updated_at, } } pub async fn post_thought(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid); let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput { user_id: uid, content: body.content, in_reply_to_id: in_reply_to, visibility: body.visibility, content_warning: body.content_warning, sensitive: body.sensitive.unwrap_or(false) }).await?; let user = s.users.find_by_id(&out.thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?; let entry = domain::models::feed::FeedEntry { thought: out.thought, author: user, like_count: 0, boost_count: 0, reply_count: 0, liked_by_viewer: false, boosted_by_viewer: false }; Ok((StatusCode::CREATED, Json(to_thought_response(entry)))) } pub async fn get_thought_handler(State(s): State, Path(id): Path, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result, ApiError> { let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; let user = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?; Ok(Json(serde_json::json!({ "id": thought.id.as_uuid(), "content": thought.content.as_str(), "author": { "username": user.username.as_str() } }))) } pub async fn delete_thought_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?; Ok(StatusCode::NO_CONTENT) } pub async fn patch_thought(State(s): State, AuthUser(uid): AuthUser, Path(id): Path, Json(body): Json) -> Result { edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?; Ok(StatusCode::NO_CONTENT) } pub async fn get_thread_handler(State(s): State, Path(id): Path) -> Result>, ApiError> { let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; let items: Vec<_> = thoughts.iter().map(|t| serde_json::json!({ "id": t.id.as_uuid(), "content": t.content.as_str() })).collect(); Ok(Json(items)) } ``` - [ ] **Write `handlers/feed.rs`:** ```rust use axum::{extract::{Query, State}, Json}; use api_types::requests::{PaginationQuery, SearchQuery}; use application::use_cases::feed::{get_home_feed, get_public_feed, search}; use domain::models::feed::PageParams; use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, state::AppState}; pub async fn home_feed(State(s): State, AuthUser(uid): AuthUser, Query(q): Query) -> Result, ApiError> { let page = PageParams { page: q.page(), per_page: q.per_page() }; let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?; Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) } pub async fn public_feed(State(s): State, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query) -> Result, ApiError> { let page = PageParams { page: q.page(), per_page: q.per_page() }; let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?; Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) } pub async fn search_handler(State(s): State, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query) -> Result, ApiError> { let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) }; let result = search(&*s.feed, &q.q, page, viewer.as_ref()).await?; Ok(Json(serde_json::json!({ "items": result.items.len(), "total": result.total }))) } ``` - [ ] **Write `handlers/social.rs`:** ```rust use axum::{extract::{Path, State}, http::StatusCode, Json}; use uuid::Uuid; use application::use_cases::social::*; use application::use_cases::profile::{get_top_friends, set_top_friends}; use api_types::requests::SetTopFriendsRequest; use domain::value_objects::{ThoughtId, UserId}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; pub async fn post_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn delete_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn post_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn delete_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn post_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn delete_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn post_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn delete_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { unblock_user(&*s.blocks, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } pub async fn put_top_friends(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { let ids: Vec = body.friend_ids.into_iter().map(UserId::from_uuid).collect(); set_top_friends(&*s.top_friends, &uid, ids).await?; Ok(StatusCode::NO_CONTENT) } pub async fn get_top_friends_handler(State(s): State, Path(username): Path) -> Result, ApiError> { let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; let friends = get_top_friends(&*s.top_friends, &user.id).await?; let ids: Vec = friends.iter().map(|(tf, _)| tf.friend_id.as_uuid()).collect(); Ok(Json(serde_json::json!({ "top_friends": ids }))) } ``` - [ ] **Write `handlers/notifications.rs`:** ```rust use axum::{extract::{Path, State}, http::StatusCode, Json}; use uuid::Uuid; use domain::{models::feed::PageParams, value_objects::NotificationId}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; pub async fn list_notifications(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { let page = PageParams { page: 1, per_page: 20 }; let result = s.notifications.list_for_user(&uid, &page).await?; Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }))) } pub async fn mark_notification_read(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?; Ok(StatusCode::NO_CONTENT) } pub async fn mark_all_read(State(s): State, AuthUser(uid): AuthUser) -> Result { s.notifications.mark_all_read(&uid).await?; Ok(StatusCode::NO_CONTENT) } ``` - [ ] **Write `handlers/api_keys.rs`:** ```rust use axum::{extract::{Path, State}, http::StatusCode, Json}; use uuid::Uuid; use api_types::{requests::CreateApiKeyRequest, responses::ApiKeyResponse}; use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys}; use domain::value_objects::ApiKeyId; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; pub async fn get_api_keys(State(s): State, AuthUser(uid): AuthUser) -> Result>, ApiError> { let keys = list_api_keys(&*s.api_keys, &uid).await?; Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect())) } pub async fn post_api_key(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await.map_err(domain::errors::DomainError::from)?; Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }))) } pub async fn delete_api_key_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } ``` - [ ] **Run:** `cargo check -p presentation` — Expected: no errors. - [ ] **Commit:** ```bash git add crates/presentation/src/handlers/ git commit -m "feat(presentation): thought, feed, social, notification, api-key handlers" ``` --- ### Task 20: Presentation — routes and main **Files:** `src/routes.rs`, `src/main.rs`, update `src/lib.rs` - [ ] **Write `crates/presentation/src/routes.rs`:** ```rust use axum::{routing::{delete, get, patch, post, put}, Router}; use crate::{handlers::*, state::AppState}; pub fn router() -> Router { Router::new() // auth .route("/auth/register", post(auth::post_register)) .route("/auth/login", post(auth::post_login)) // users .route("/users/:username", get(users::get_user)) .route("/users/me", patch(users::patch_profile)) .route("/users/:username/following", get(feed::get_following_handler)) .route("/users/:username/followers", get(feed::get_followers_handler)) .route("/users/:username/top-friends",get(social::get_top_friends_handler)) .route("/users/me/top-friends", put(social::put_top_friends)) // thoughts .route("/thoughts", post(thoughts::post_thought)) .route("/thoughts/:id", get(thoughts::get_thought_handler)) .route("/thoughts/:id", patch(thoughts::patch_thought)) .route("/thoughts/:id", delete(thoughts::delete_thought_handler)) .route("/thoughts/:id/thread", get(thoughts::get_thread_handler)) // likes & boosts .route("/thoughts/:id/like", post(social::post_like)) .route("/thoughts/:id/like", delete(social::delete_like)) .route("/thoughts/:id/boost", post(social::post_boost)) .route("/thoughts/:id/boost", delete(social::delete_boost)) // follows & blocks .route("/users/:id/follow", post(social::post_follow)) .route("/users/:id/follow", delete(social::delete_follow)) .route("/users/:id/block", post(social::post_block)) .route("/users/:id/block", delete(social::delete_block)) // feeds .route("/feed", get(feed::home_feed)) .route("/feed/public", get(feed::public_feed)) .route("/search", get(feed::search_handler)) // notifications .route("/notifications", get(notifications::list_notifications)) .route("/notifications/read-all", post(notifications::mark_all_read)) .route("/notifications/:id/read", post(notifications::mark_notification_read)) // api keys .route("/api-keys", get(api_keys::get_api_keys)) .route("/api-keys", post(api_keys::post_api_key)) .route("/api-keys/:id", delete(api_keys::delete_api_key_handler)) } ``` Add `get_following_handler` and `get_followers_handler` to `handlers/feed.rs`: ```rust pub async fn get_following_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; let page = PageParams { page: q.page(), per_page: q.per_page() }; let result = application::use_cases::feed::get_following(&*s.follows, &user.id, page).await?; Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.len() }))) } pub async fn get_followers_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { let user = application::use_cases::profile::get_user_by_username(&*s.users, &username).await?; let page = PageParams { page: q.page(), per_page: q.per_page() }; let result = application::use_cases::feed::get_followers(&*s.follows, &user.id, page).await?; Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.len() }))) } ``` - [ ] **Write `crates/presentation/src/main.rs`:** ```rust use std::sync::Arc; use sqlx::PgPool; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); tracing_subscriber::fmt().with_env_filter(EnvFilter::from_default_env()).init(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET required"); let port = std::env::var("PORT").unwrap_or_else(|_| "3000".into()); let pool = PgPool::connect(&database_url).await.expect("DB connect failed"); sqlx::migrate!("../adapters/postgres/migrations").run(&pool).await.expect("Migrations failed"); let state = presentation::build_state(pool, jwt_secret); let app = presentation::routes::router() .with_state(state) .layer(CorsLayer::permissive()); let addr = format!("0.0.0.0:{port}"); tracing::info!("Listening on {addr}"); let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); } ``` - [ ] **Write `crates/presentation/src/lib.rs`:** ```rust pub mod errors; pub mod extractors; pub mod handlers; pub mod routes; pub mod state; use std::sync::Arc; use sqlx::PgPool; use state::AppState; use domain::testing::NoOpEventPublisher; pub fn build_state(pool: PgPool, jwt_secret: String) -> AppState { AppState { users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), auth: Arc::new(auth::JwtAuthService::new(jwt_secret, 86400 * 30)), hasher: Arc::new(auth::Argon2PasswordHasher), events: Arc::new(NoOpEventPublisher), } } ``` - [ ] **Run:** `cargo build -p presentation` — Expected: compiles cleanly. - [ ] **Smoke test:** Start a local postgres, set env vars, run: ```bash DATABASE_URL=postgres://... JWT_SECRET=dev cargo run -p presentation curl -X POST http://localhost:3000/auth/register -H 'content-type: application/json' -d '{"username":"alice","email":"alice@ex.com","password":"pw123"}' ``` Expected: `201 Created` with `token` in response. - [ ] **Commit:** ```bash git add crates/presentation/ git commit -m "feat(presentation): routes and main — Plan 1 complete" ``` --- ## Self-Review Spec coverage check: - ✅ Crate structure matches spec (all crates scaffolded) - ✅ Domain: all entities, value objects, ports, events - ✅ Postgres: all repos + 3-migration sequence (prod-safe) - ✅ Auth: JWT + Argon2 - ✅ Application: all use cases (register, login, thoughts, social, feed, profile, api-keys) - ✅ Presentation: all REST endpoints, auth extractors, error handling - ✅ NoOpEventPublisher wired — events publish but are no-ops until Plan 3 - ⚠️ `sha256_hex` in extractors and `create_api_key` need `sha2`+`hex` crates added to `presentation/Cargo.toml` - ⚠️ `crate::thought::ThoughtRow` and `crate::user::UserRow` must be `pub(crate)` — noted in tasks - ⚠️ `create_api_key` returns `Result<_, DomainError>` but uses `Result<(ApiKey, String)>` — fix return type to `Result<(ApiKey, String), DomainError>` - ✅ Remote actors repo included (needed for Plan 4 federation) - ✅ Migration strategy: additive only, production UUIDs preserved