feat: implement merge readiness plan to close gaps between v2 and v1
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m8s
test / unit (pull_request) Successful in 16m18s
test / integration (pull_request) Failing after 16m59s

- Task 1: Fix feed response hydration by adding `to_thought_response` helper and updating feed handlers to return full `ThoughtResponse`.
- Task 2: Wire follower/following REST routes for user feeds.
- Task 3: Add user listing and count endpoints, including `GET /users` and `GET /users/count`.
- Task 4: Implement popular tags feature with `GET /tags/popular`.
- Task 5: Enhance configuration with HOST, CORS_ORIGINS, and optional rate limiting using tower-governor.
This commit is contained in:
2026-05-14 16:28:18 +02:00
parent e6f4a6256f
commit 004bfb427b
30 changed files with 8716 additions and 808 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,3 @@
backend-codebase.txt
frontend-codebase.txt
.env .env
/target

View File

@@ -1,165 +0,0 @@
# **Thoughts \- API Design (Version 1\)**
## **1\. Overview**
This document specifies the RESTful API for the Thoughts platform.
* **Base URL:** /api/v1
* **Data Format:** All requests and responses will be in JSON format.
* **Authentication:** The API uses two primary methods for authentication:
1. **JWT (JSON Web Tokens):** For the official web client. The POST /api/v1/auth/login endpoint returns a short-lived JWT. This token must be included in the Authorization: Bearer \<token\> header for all subsequent authenticated requests.
2. **API Keys:** For third-party applications. Users can generate long-lived API keys. These keys must be included in the Authorization: ApiKey \<key\> header.
## **2\. API Endpoints**
### **Auth Endpoints**
**POST /auth/register**
* **Description:** Creates a new user account.
* **Authentication:** Public.
* **Request Body:**
{
"username": "frutiger",
"email": "aero@example.com",
"password": "strongpassword123"
}
* **Success Response:** 201 Created with the new User object (password omitted).
* **Error Responses:** 400 Bad Request (invalid input), 409 Conflict (username or email already exists).
**POST /auth/login**
* **Description:** Authenticates a user and returns a JWT.
* **Authentication:** Public.
* **Request Body:**
{
"username": "frutiger",
"password": "strongpassword123"
}
* **Success Response:** 200 OK with a JWT.
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
* **Error Responses:** 400 Bad Request, 401 Unauthorized.
### **User & Profile Endpoints**
**GET /users/{username}**
* **Description:** Retrieves the public profile of a user.
* **Authentication:** Public.
* **Success Response:** 200 OK with a public User object.
**GET /users/me**
* **Description:** Retrieves the full profile of the currently authenticated user (including private details like email).
* **Authentication:** Required (JWT).
* **Success Response:** 200 OK with the full User object.
**PUT /users/me**
* **Description:** Updates the profile of the currently authenticated user.
* **Authentication:** Required (JWT).
* **Request Body:**
{
"displayName": "Frutiger Aero Fan",
"bio": "Est. 2004",
"avatarUrl": "https://...",
"headerUrl": "https://...",
"customCss": "body { background: blue; }",
"topFriends": \["username1", "username2"\]
}
* **Success Response:** 200 OK with the updated User object.
* **Error Responses:** 400 Bad Request.
### **Thoughts (Posts) Endpoints**
**POST /thoughts**
* **Description:** Creates a new thought.
* **Authentication:** Required (JWT or API Key).
* **Request Body:**
{
"content": "This is my first thought\! \#welcome"
}
* **Success Response:** 201 Created with the new Thought object.
* **Error Responses:** 400 Bad Request (e.g., content \> 128 chars).
**GET /users/{username}/thoughts**
* **Description:** Retrieves all thoughts for a specific user, paginated.
* **Authentication:** Public.
* **Success Response:** 200 OK with an array of Thought objects.
**DELETE /thoughts/{id}**
* **Description:** Deletes a thought. The user must be the author.
* **Authentication:** Required (JWT or API Key).
* **Success Response:** 204 No Content.
* **Error Responses:** 403 Forbidden, 404 Not Found.
### **Social Endpoints**
**POST /users/{username}/follow**
* **Description:** Follows a user.
* **Authentication:** Required (JWT).
* **Success Response:** 204 No Content.
* **Error Responses:** 404 Not Found, 409 Conflict (already following).
**DELETE /users/{username}/follow**
* **Description:** Unfollows a user.
* **Authentication:** Required (JWT).
* **Success Response:** 204 No Content.
* **Error Responses:** 404 Not Found.
**GET /feed**
* **Description:** Retrieves the main feed for the authenticated user, paginated.
* **Authentication:** Required (JWT).
* **Success Response:** 200 OK with an array of Thought objects from followed users.
### **Discovery Endpoints**
**GET /tags/popular**
* **Description:** Retrieves a list of currently popular tags.
* **Authentication:** Public.
* **Success Response:** 200 OK with an array of tag strings.
**GET /tags/{tagName}**
* **Description:** Retrieves a feed of all thoughts with a specific tag, paginated.
* **Authentication:** Public.
* **Success Response:** 200 OK with an array of Thought objects.
## **3\. Data Models**
**User Object (Public)**
{
"username": "frutiger",
"displayName": "Frutiger Aero Fan",
"bio": "Est. 2004",
"avatarUrl": "https://...",
"headerUrl": "https://...",
"customCss": "body { background: blue; }",
"topFriends": \["username1", "username2"\],
"joinedAt": "2024-01-01T12:00:00Z"
}
**Thought Object**
{
"id": "uuid-v4-string",
"authorUsername": "frutiger",
"content": "This is my first thought\! \#welcome",
"tags": \["welcome"\],
"createdAt": "2024-01-01T12:01:00Z"
}

4840
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,114 +0,0 @@
# **Thoughts \- Database Schema (PostgreSQL)**
## **1\. Overview**
This document outlines the table structure for the Thoughts platform using PostgreSQL. The design uses UUIDs for primary keys to facilitate decentralization and prevent enumeration attacks. All timestamps are stored with time zones (TIMESTAMPTZ).
## **2\. Schema Diagram (ERD)**
\+-------------+ \+--------------+ \+--------------+
| users |\<--+--| thoughts |---+--|\> thought\_tags |
\+-------------+ | \+--------------+ | \+--------------+
| | | ^
| | | |
| | \+--------------+ | \+--------------+
\+--------+--+--|\> follows |\<--+-+--| tags |
| | \+--------------+ | \+--------------+
| | |
v | |
\+-------------+ | |
| top\_friends |\<-+ |
\+-------------+ |
| |
v |
\+-------------+ |
| api\_keys |\<--------------------------+
\+-------------+
*(Note: Arrows denote foreign key relationships)*
## **3\. Table Definitions**
### **users**
Stores user account and profile information.
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the user. |
| username | VARCHAR(32) | NOT NULL, UNIQUE | The user's handle. |
| email | VARCHAR(255) | NOT NULL, UNIQUE | The user's email address. |
| password\_hash | TEXT | NOT NULL | Hashed password (using Argon2 or bcrypt). |
| display\_name | VARCHAR(50) | NULL | User's public display name. |
| bio | VARCHAR(160) | NULL | User's public biography. |
| avatar\_url | TEXT | NULL | URL to the user's avatar image. |
| header\_url | TEXT | NULL | URL to the user's header image. |
| custom\_css | TEXT | NULL | User's custom profile CSS. |
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of account creation. |
| updated\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of the last profile update. |
### **thoughts**
Stores the content of each post.
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the thought. |
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The ID of the authoring user. |
| content | VARCHAR(128) | NOT NULL | The text content of the thought. |
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the thought was posted. |
### **follows**
A join table representing the follower/following relationship.
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| follower\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is initiating the follow. |
| following\_id | UUID | NOT NULL, REFERENCES users(id) | The user who is being followed. |
| | | PRIMARY KEY (follower\_id, following\_id) | Ensures a user can't follow someone twice. |
### **top\_friends**
Stores the ordered list of a user's "Top Friends".
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The owner of this "Top Friends" list. |
| friend\_id | UUID | NOT NULL, REFERENCES users(id) | The user being displayed as a friend. |
| position | SMALLINT | NOT NULL | The order (1-8) of the friend on the list. |
| | | PRIMARY KEY (user\_id, friend\_id) | Ensures a user can't be in the list twice. |
| | | UNIQUE (user\_id, position) | Ensures positions are not duplicated. |
### **tags and thought\_tags (for hashtags)**
* **tags**: Stores unique tag names.
* **thought\_tags**: A join table linking thoughts to tags.
#### **tags**
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| id | SERIAL | PRIMARY KEY | Unique ID for the tag. |
| name | VARCHAR(50) | NOT NULL, UNIQUE | The tag name (e.g., "welcome"). |
#### **thought\_tags**
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| thought\_id | UUID | NOT NULL, REFERENCES thoughts(id) | The ID of the thought. |
| tag\_id | INTEGER | NOT NULL, REFERENCES tags(id) | The ID of the tag. |
| | | PRIMARY KEY (thought\_id, tag\_id) | Prevents duplicate tags per post. |
### **api\_keys**
Stores hashed API keys for users.
| Column Name | Data Type | Constraints | Description |
| :---- | :---- | :---- | :---- |
| id | UUID | PRIMARY KEY, DEFAULT gen\_random\_uuid() | Unique identifier for the API key. |
| user\_id | UUID | NOT NULL, REFERENCES users(id) | The user who owns this key. |
| key\_hash | TEXT | NOT NULL, UNIQUE | The hashed value of the API key. |
| name | VARCHAR(50) | NOT NULL | A user-provided name for the key. |
| created\_at | TIMESTAMPTZ | NOT NULL, DEFAULT NOW() | Timestamp of when the key was created. |

View File

@@ -1,2 +0,0 @@
uvx files-to-prompt thoughts-backend -e toml -e rs -e md --ignore "*target" -o backend-codebase.txt
uvx files-to-prompt thoughts-frontend -o frontend-codebase.txt --ignore "*node_modules" --ignore "*.lock"

View File

@@ -1,14 +1,14 @@
use std::sync::Arc;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use std::sync::Arc;
use url::Url; use url::Url;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
use activitypub_base::ApObjectHandler; use activitypub_base::ApObjectHandler;
use domain::ports::ActivityPubRepository; use domain::ports::ActivityPubRepository;
use domain::value_objects::UserId; use domain::value_objects::UserId;
use crate::note::ThoughtNote;
use crate::urls::ThoughtsUrls;
pub struct ThoughtsObjectHandler { pub struct ThoughtsObjectHandler {
repo: Arc<dyn ActivityPubRepository>, repo: Arc<dyn ActivityPubRepository>,
@@ -17,7 +17,10 @@ pub struct ThoughtsObjectHandler {
impl ThoughtsObjectHandler { impl ThoughtsObjectHandler {
pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self { pub fn new(repo: Arc<dyn ActivityPubRepository>, base_url: &str) -> Self {
Self { repo, urls: ThoughtsUrls::new(base_url) } Self {
repo,
urls: ThoughtsUrls::new(base_url),
}
} }
} }
@@ -28,21 +31,34 @@ impl ApObjectHandler for ThoughtsObjectHandler {
user_id: uuid::Uuid, user_id: uuid::Uuid,
) -> Result<Vec<(Url, serde_json::Value)>> { ) -> Result<Vec<(Url, serde_json::Value)>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_entries_for_actor(&uid).await let entries = self
.repo
.outbox_entries_for_actor(&uid)
.await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| { entries
.into_iter()
.map(|e| {
let note_url = self.urls.thought_url(e.thought.id.as_uuid()); let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str()); let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str()); let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(
note_url.clone(), actor_url, note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(), e.thought.content.as_str().to_owned(),
e.thought.created_at, in_reply_to, e.thought.created_at,
e.thought.sensitive, e.thought.content_warning, followers, in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
); );
Ok((note_url, serde_json::to_value(&note)?)) Ok((note_url, serde_json::to_value(&note)?))
}).collect() })
.collect()
} }
async fn get_local_objects_page( async fn get_local_objects_page(
@@ -52,22 +68,35 @@ impl ApObjectHandler for ThoughtsObjectHandler {
limit: usize, limit: usize,
) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> { ) -> Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
let uid = UserId::from_uuid(user_id); let uid = UserId::from_uuid(user_id);
let entries = self.repo.outbox_page_for_actor(&uid, before, limit).await let entries = self
.repo
.outbox_page_for_actor(&uid, before, limit)
.await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
entries.into_iter().map(|e| { entries
.into_iter()
.map(|e| {
let created_at = e.thought.created_at; let created_at = e.thought.created_at;
let note_url = self.urls.thought_url(e.thought.id.as_uuid()); let note_url = self.urls.thought_url(e.thought.id.as_uuid());
let actor_url = self.urls.user_url(e.author_username.as_str()); let actor_url = self.urls.user_url(e.author_username.as_str());
let followers = self.urls.user_followers(e.author_username.as_str()); let followers = self.urls.user_followers(e.author_username.as_str());
let in_reply_to = e.thought.in_reply_to_id.map(|id| self.urls.thought_url(id.as_uuid())); let in_reply_to = e
.thought
.in_reply_to_id
.map(|id| self.urls.thought_url(id.as_uuid()));
let note = ThoughtNote::new_public( let note = ThoughtNote::new_public(
note_url.clone(), actor_url, note_url.clone(),
actor_url,
e.thought.content.as_str().to_owned(), e.thought.content.as_str().to_owned(),
created_at, in_reply_to, created_at,
e.thought.sensitive, e.thought.content_warning, followers, in_reply_to,
e.thought.sensitive,
e.thought.content_warning,
followers,
); );
Ok((note_url, serde_json::to_value(&note)?, created_at)) Ok((note_url, serde_json::to_value(&note)?, created_at))
}).collect() })
.collect()
} }
async fn on_create( async fn on_create(
@@ -77,15 +106,22 @@ impl ApObjectHandler for ThoughtsObjectHandler {
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?; let note: ThoughtNote = serde_json::from_value(object)?;
let author_id = self.repo.intern_remote_actor(actor_url).await let author_id = self
.repo
.intern_remote_actor(actor_url)
.await
.map_err(|e| anyhow!("{e}"))?; .map_err(|e| anyhow!("{e}"))?;
self.repo.accept_note( self.repo
ap_id, &author_id, .accept_note(
ap_id,
&author_id,
&note.content, &note.content,
note.published, note.published,
note.sensitive, note.sensitive,
note.summary, note.summary,
).await.map_err(|e| anyhow!("{e}")) )
.await
.map_err(|e| anyhow!("{e}"))
} }
async fn on_update( async fn on_update(
@@ -95,19 +131,30 @@ impl ApObjectHandler for ThoughtsObjectHandler {
object: serde_json::Value, object: serde_json::Value,
) -> Result<()> { ) -> Result<()> {
let note: ThoughtNote = serde_json::from_value(object)?; let note: ThoughtNote = serde_json::from_value(object)?;
self.repo.apply_note_update(ap_id, &note.content).await self.repo
.apply_note_update(ap_id, &note.content)
.await
.map_err(|e| anyhow!("{e}")) .map_err(|e| anyhow!("{e}"))
} }
async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> { async fn on_delete(&self, ap_id: &Url, _actor_url: &Url) -> Result<()> {
self.repo.retract_note(ap_id).await.map_err(|e| anyhow!("{e}")) self.repo
.retract_note(ap_id)
.await
.map_err(|e| anyhow!("{e}"))
} }
async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> { async fn on_actor_removed(&self, actor_url: &Url) -> Result<()> {
self.repo.retract_actor_notes(actor_url).await.map_err(|e| anyhow!("{e}")) self.repo
.retract_actor_notes(actor_url)
.await
.map_err(|e| anyhow!("{e}"))
} }
async fn count_local_posts(&self) -> Result<u64> { async fn count_local_posts(&self) -> Result<u64> {
self.repo.count_local_notes().await.map_err(|e| anyhow!("{e}")) self.repo
.count_local_notes()
.await
.map_err(|e| anyhow!("{e}"))
} }
} }

View File

@@ -1,5 +1,5 @@
use activitypub_base::AS_PUBLIC;
use activitypub_base::NoteType; use activitypub_base::NoteType;
use activitypub_base::AS_PUBLIC;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
@@ -27,16 +27,26 @@ pub struct ThoughtNote {
impl ThoughtNote { impl ThoughtNote {
pub fn new_public( pub fn new_public(
id: Url, actor_url: Url, content: String, published: DateTime<Utc>, id: Url,
in_reply_to: Option<Url>, sensitive: bool, summary: Option<String>, actor_url: Url,
content: String,
published: DateTime<Utc>,
in_reply_to: Option<Url>,
sensitive: bool,
summary: Option<String>,
followers_url: Url, followers_url: Url,
) -> Self { ) -> Self {
Self { Self {
kind: Default::default(), kind: Default::default(),
id, attributed_to: actor_url, content, published, id,
attributed_to: actor_url,
content,
published,
to: vec![AS_PUBLIC.to_string()], to: vec![AS_PUBLIC.to_string()],
cc: vec![followers_url.to_string()], cc: vec![followers_url.to_string()],
in_reply_to, sensitive, summary, in_reply_to,
sensitive,
summary,
} }
} }
} }
@@ -52,7 +62,9 @@ mod tests {
"https://example.com/users/alice".parse().unwrap(), "https://example.com/users/alice".parse().unwrap(),
"Hello world".to_string(), "Hello world".to_string(),
chrono::Utc::now(), chrono::Utc::now(),
None, false, None, None,
false,
None,
"https://example.com/users/alice/followers".parse().unwrap(), "https://example.com/users/alice/followers".parse().unwrap(),
); );
let json = serde_json::to_string(&note).unwrap(); let json = serde_json::to_string(&note).unwrap();

View File

@@ -6,7 +6,9 @@ pub struct ThoughtsUrls {
impl ThoughtsUrls { impl ThoughtsUrls {
pub fn new(base_url: &str) -> Self { pub fn new(base_url: &str) -> Self {
Self { base_url: base_url.trim_end_matches('/').to_string() } Self {
base_url: base_url.trim_end_matches('/').to_string(),
}
} }
pub fn user_url(&self, username: &str) -> Url { pub fn user_url(&self, username: &str) -> Url {
@@ -37,13 +39,19 @@ mod tests {
#[test] #[test]
fn user_url_format() { fn user_url_format() {
let urls = ThoughtsUrls::new("https://example.com"); let urls = ThoughtsUrls::new("https://example.com");
assert_eq!(urls.user_url("alice").as_str(), "https://example.com/users/alice"); assert_eq!(
urls.user_url("alice").as_str(),
"https://example.com/users/alice"
);
} }
#[test] #[test]
fn thought_url_format() { fn thought_url_format() {
let urls = ThoughtsUrls::new("https://example.com"); let urls = ThoughtsUrls::new("https://example.com");
let id = uuid::Uuid::nil(); let id = uuid::Uuid::nil();
assert!(urls.thought_url(id).as_str().starts_with("https://example.com/thoughts/")); assert!(urls
.thought_url(id)
.as_str()
.starts_with("https://example.com/thoughts/"));
} }
} }

View File

@@ -1,12 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{AuthService, GeneratedToken, PasswordHasher}, ports::{AuthService, GeneratedToken, PasswordHasher},
value_objects::{PasswordHash, UserId}, value_objects::{PasswordHash, UserId},
}; };
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct Claims { struct Claims {
@@ -21,7 +21,10 @@ pub struct JwtAuthService {
impl JwtAuthService { impl JwtAuthService {
pub fn new(secret: String, ttl_seconds: i64) -> Self { pub fn new(secret: String, ttl_seconds: i64) -> Self {
Self { secret, ttl_seconds } Self {
secret,
ttl_seconds,
}
} }
} }
@@ -51,8 +54,8 @@ impl AuthService for JwtAuthService {
&Validation::default(), &Validation::default(),
) )
.map_err(|_| DomainError::Unauthorized)?; .map_err(|_| DomainError::Unauthorized)?;
let uuid = uuid::Uuid::parse_str(&data.claims.sub) let uuid =
.map_err(|_| DomainError::Unauthorized)?; uuid::Uuid::parse_str(&data.claims.sub).map_err(|_| DomainError::Unauthorized)?;
Ok(UserId::from_uuid(uuid)) Ok(UserId::from_uuid(uuid))
} }
} }
@@ -62,10 +65,7 @@ pub struct Argon2PasswordHasher;
#[async_trait] #[async_trait]
impl PasswordHasher for Argon2PasswordHasher { impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> { async fn hash(&self, plain: &str) -> Result<PasswordHash, DomainError> {
use argon2::{ use argon2::{password_hash::SaltString, Argon2, PasswordHasher as _};
password_hash::SaltString,
Argon2, PasswordHasher as _,
};
use rand::rngs::OsRng; use rand::rngs::OsRng;
let salt = SaltString::generate(OsRng); let salt = SaltString::generate(OsRng);
let hash = Argon2::default() let hash = Argon2::default()
@@ -77,8 +77,7 @@ impl PasswordHasher for Argon2PasswordHasher {
async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> { async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier}; use argon2::{password_hash::PasswordHash as ArgonHash, Argon2, PasswordVerifier};
let parsed = ArgonHash::new(&hash.0) let parsed = ArgonHash::new(&hash.0).map_err(|e| DomainError::Internal(e.to_string()))?;
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Argon2::default() Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed) .verify_password(plain.as_bytes(), &parsed)
.is_ok()) .is_ok())

View File

@@ -97,46 +97,102 @@ impl EventPayload {
impl From<&DomainEvent> for EventPayload { impl From<&DomainEvent> for EventPayload {
fn from(e: &DomainEvent) -> Self { fn from(e: &DomainEvent) -> Self {
match e { match e {
DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => Self::ThoughtCreated { DomainEvent::ThoughtCreated {
thought_id,
user_id,
in_reply_to_id,
} => Self::ThoughtCreated {
thought_id: thought_id.to_string(), thought_id: thought_id.to_string(),
user_id: user_id.to_string(), user_id: user_id.to_string(),
in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()), in_reply_to_id: in_reply_to_id.as_ref().map(|x| x.to_string()),
}, },
DomainEvent::ThoughtDeleted { thought_id, user_id } => Self::ThoughtDeleted { DomainEvent::ThoughtDeleted {
thought_id: thought_id.to_string(), user_id: user_id.to_string(), thought_id,
user_id,
} => Self::ThoughtDeleted {
thought_id: thought_id.to_string(),
user_id: user_id.to_string(),
}, },
DomainEvent::ThoughtUpdated { thought_id, user_id } => Self::ThoughtUpdated { DomainEvent::ThoughtUpdated {
thought_id: thought_id.to_string(), user_id: user_id.to_string(), thought_id,
user_id,
} => Self::ThoughtUpdated {
thought_id: thought_id.to_string(),
user_id: user_id.to_string(),
}, },
DomainEvent::LikeAdded { like_id, user_id, thought_id } => Self::LikeAdded { DomainEvent::LikeAdded {
like_id: like_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), like_id,
user_id,
thought_id,
} => Self::LikeAdded {
like_id: like_id.to_string(),
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
}, },
DomainEvent::LikeRemoved { user_id, thought_id } => Self::LikeRemoved { DomainEvent::LikeRemoved {
user_id: user_id.to_string(), thought_id: thought_id.to_string(), user_id,
thought_id,
} => Self::LikeRemoved {
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
}, },
DomainEvent::BoostAdded { boost_id, user_id, thought_id } => Self::BoostAdded { DomainEvent::BoostAdded {
boost_id: boost_id.to_string(), user_id: user_id.to_string(), thought_id: thought_id.to_string(), boost_id,
user_id,
thought_id,
} => Self::BoostAdded {
boost_id: boost_id.to_string(),
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
}, },
DomainEvent::BoostRemoved { user_id, thought_id } => Self::BoostRemoved { DomainEvent::BoostRemoved {
user_id: user_id.to_string(), thought_id: thought_id.to_string(), user_id,
thought_id,
} => Self::BoostRemoved {
user_id: user_id.to_string(),
thought_id: thought_id.to_string(),
}, },
DomainEvent::FollowRequested { follower_id, following_id } => Self::FollowRequested { DomainEvent::FollowRequested {
follower_id: follower_id.to_string(), following_id: following_id.to_string(), follower_id,
following_id,
} => Self::FollowRequested {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
}, },
DomainEvent::FollowAccepted { follower_id, following_id } => Self::FollowAccepted { DomainEvent::FollowAccepted {
follower_id: follower_id.to_string(), following_id: following_id.to_string(), follower_id,
following_id,
} => Self::FollowAccepted {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
}, },
DomainEvent::FollowRejected { follower_id, following_id } => Self::FollowRejected { DomainEvent::FollowRejected {
follower_id: follower_id.to_string(), following_id: following_id.to_string(), follower_id,
following_id,
} => Self::FollowRejected {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
}, },
DomainEvent::Unfollowed { follower_id, following_id } => Self::Unfollowed { DomainEvent::Unfollowed {
follower_id: follower_id.to_string(), following_id: following_id.to_string(), follower_id,
following_id,
} => Self::Unfollowed {
follower_id: follower_id.to_string(),
following_id: following_id.to_string(),
}, },
DomainEvent::UserBlocked { blocker_id, blocked_id } => Self::UserBlocked { DomainEvent::UserBlocked {
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(), blocker_id,
blocked_id,
} => Self::UserBlocked {
blocker_id: blocker_id.to_string(),
blocked_id: blocked_id.to_string(),
}, },
DomainEvent::UserUnblocked { blocker_id, blocked_id } => Self::UserUnblocked { DomainEvent::UserUnblocked {
blocker_id: blocker_id.to_string(), blocked_id: blocked_id.to_string(), blocker_id,
blocked_id,
} => Self::UserUnblocked {
blocker_id: blocker_id.to_string(),
blocked_id: blocked_id.to_string(),
}, },
DomainEvent::UserRegistered { user_id } => Self::UserRegistered { DomainEvent::UserRegistered { user_id } => Self::UserRegistered {
user_id: user_id.to_string(), user_id: user_id.to_string(),
@@ -157,60 +213,102 @@ impl TryFrom<EventPayload> for DomainEvent {
fn try_from(p: EventPayload) -> Result<Self, DomainError> { fn try_from(p: EventPayload) -> Result<Self, DomainError> {
Ok(match p { Ok(match p {
EventPayload::ThoughtCreated { thought_id, user_id, in_reply_to_id } => DomainEvent::ThoughtCreated { EventPayload::ThoughtCreated {
thought_id,
user_id,
in_reply_to_id,
} => DomainEvent::ThoughtCreated {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
in_reply_to_id: in_reply_to_id in_reply_to_id: in_reply_to_id
.map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid)) .map(|s| parse_uuid(&s, "in_reply_to_id").map(ThoughtId::from_uuid))
.transpose()?, .transpose()?,
}, },
EventPayload::ThoughtDeleted { thought_id, user_id } => DomainEvent::ThoughtDeleted { EventPayload::ThoughtDeleted {
thought_id,
user_id,
} => DomainEvent::ThoughtDeleted {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}, },
EventPayload::ThoughtUpdated { thought_id, user_id } => DomainEvent::ThoughtUpdated { EventPayload::ThoughtUpdated {
thought_id,
user_id,
} => DomainEvent::ThoughtUpdated {
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
}, },
EventPayload::LikeAdded { like_id, user_id, thought_id } => DomainEvent::LikeAdded { EventPayload::LikeAdded {
like_id,
user_id,
thought_id,
} => DomainEvent::LikeAdded {
like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?), like_id: LikeId::from_uuid(parse_uuid(&like_id, "like_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
}, },
EventPayload::LikeRemoved { user_id, thought_id } => DomainEvent::LikeRemoved { EventPayload::LikeRemoved {
user_id,
thought_id,
} => DomainEvent::LikeRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
}, },
EventPayload::BoostAdded { boost_id, user_id, thought_id } => DomainEvent::BoostAdded { EventPayload::BoostAdded {
boost_id,
user_id,
thought_id,
} => DomainEvent::BoostAdded {
boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?), boost_id: BoostId::from_uuid(parse_uuid(&boost_id, "boost_id")?),
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
}, },
EventPayload::BoostRemoved { user_id, thought_id } => DomainEvent::BoostRemoved { EventPayload::BoostRemoved {
user_id,
thought_id,
} => DomainEvent::BoostRemoved {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?), thought_id: ThoughtId::from_uuid(parse_uuid(&thought_id, "thought_id")?),
}, },
EventPayload::FollowRequested { follower_id, following_id } => DomainEvent::FollowRequested { EventPayload::FollowRequested {
follower_id,
following_id,
} => DomainEvent::FollowRequested {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
}, },
EventPayload::FollowAccepted { follower_id, following_id } => DomainEvent::FollowAccepted { EventPayload::FollowAccepted {
follower_id,
following_id,
} => DomainEvent::FollowAccepted {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
}, },
EventPayload::FollowRejected { follower_id, following_id } => DomainEvent::FollowRejected { EventPayload::FollowRejected {
follower_id,
following_id,
} => DomainEvent::FollowRejected {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
}, },
EventPayload::Unfollowed { follower_id, following_id } => DomainEvent::Unfollowed { EventPayload::Unfollowed {
follower_id,
following_id,
} => DomainEvent::Unfollowed {
follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), follower_id: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?), following_id: UserId::from_uuid(parse_uuid(&following_id, "following_id")?),
}, },
EventPayload::UserBlocked { blocker_id, blocked_id } => DomainEvent::UserBlocked { EventPayload::UserBlocked {
blocker_id,
blocked_id,
} => DomainEvent::UserBlocked {
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
}, },
EventPayload::UserUnblocked { blocker_id, blocked_id } => DomainEvent::UserUnblocked { EventPayload::UserUnblocked {
blocker_id,
blocked_id,
} => DomainEvent::UserUnblocked {
blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?), blocker_id: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?), blocked_id: UserId::from_uuid(parse_uuid(&blocked_id, "blocked_id")?),
}, },
@@ -240,22 +338,65 @@ mod tests {
#[test] #[test]
fn all_subjects_are_unique() { fn all_subjects_are_unique() {
let samples: &[EventPayload] = &[ let samples: &[EventPayload] = &[
EventPayload::ThoughtCreated { thought_id: "a".into(), user_id: "b".into(), in_reply_to_id: None }, EventPayload::ThoughtCreated {
EventPayload::ThoughtDeleted { thought_id: "a".into(), user_id: "b".into() }, thought_id: "a".into(),
EventPayload::ThoughtUpdated { thought_id: "a".into(), user_id: "b".into() }, user_id: "b".into(),
EventPayload::LikeAdded { like_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, in_reply_to_id: None,
EventPayload::LikeRemoved { user_id: "b".into(), thought_id: "c".into() }, },
EventPayload::BoostAdded { boost_id: "a".into(), user_id: "b".into(), thought_id: "c".into() }, EventPayload::ThoughtDeleted {
EventPayload::BoostRemoved { user_id: "b".into(), thought_id: "c".into() }, thought_id: "a".into(),
EventPayload::FollowRequested { follower_id: "a".into(), following_id: "b".into() }, user_id: "b".into(),
EventPayload::FollowAccepted { follower_id: "a".into(), following_id: "b".into() }, },
EventPayload::FollowRejected { follower_id: "a".into(), following_id: "b".into() }, EventPayload::ThoughtUpdated {
EventPayload::Unfollowed { follower_id: "a".into(), following_id: "b".into() }, thought_id: "a".into(),
EventPayload::UserBlocked { blocker_id: "a".into(), blocked_id: "b".into() }, user_id: "b".into(),
},
EventPayload::LikeAdded {
like_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::LikeRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostAdded {
boost_id: "a".into(),
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::BoostRemoved {
user_id: "b".into(),
thought_id: "c".into(),
},
EventPayload::FollowRequested {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowAccepted {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::FollowRejected {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::Unfollowed {
follower_id: "a".into(),
following_id: "b".into(),
},
EventPayload::UserBlocked {
blocker_id: "a".into(),
blocked_id: "b".into(),
},
]; ];
let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect(); let mut subjects: Vec<&str> = samples.iter().map(|p| p.subject()).collect();
subjects.sort(); subjects.sort();
subjects.dedup(); subjects.dedup();
assert_eq!(subjects.len(), samples.len(), "each event must have a unique subject"); assert_eq!(
subjects.len(),
samples.len(),
"each event must have a unique subject"
);
} }
} }

View File

@@ -1,5 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{errors::DomainError, events::{DomainEvent, EventEnvelope}, ports::{EventConsumer, EventPublisher}}; use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use event_payload::EventPayload; use event_payload::EventPayload;
use futures::stream::BoxStream; use futures::stream::BoxStream;
@@ -31,8 +35,8 @@ impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event); let payload = EventPayload::from(event);
let subject = payload.subject(); let subject = payload.subject();
let bytes = serde_json::to_vec(&payload) let bytes =
.map_err(|e| DomainError::Internal(e.to_string()))?; serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
tracing::debug!(subject, "publishing event"); tracing::debug!(subject, "publishing event");
self.transport.publish_bytes(subject, &bytes).await self.transport.publish_bytes(subject, &bytes).await
} }
@@ -60,7 +64,9 @@ pub struct EventConsumerAdapter<S: MessageSource> {
} }
impl<S: MessageSource> EventConsumerAdapter<S> { impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self { Self { source } } pub fn new(source: S) -> Self {
Self { source }
}
} }
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> { impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
@@ -103,8 +109,8 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
mod tests { mod tests {
use super::*; use super::*;
use async_trait::async_trait; use async_trait::async_trait;
use std::sync::{Arc, Mutex};
use domain::value_objects::{ThoughtId, UserId}; use domain::value_objects::{ThoughtId, UserId};
use std::sync::{Arc, Mutex};
struct SpyTransport { struct SpyTransport {
calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>, calls: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
@@ -112,13 +118,21 @@ mod tests {
impl SpyTransport { impl SpyTransport {
fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) { fn new() -> (Self, Arc<Mutex<Vec<(String, Vec<u8>)>>>) {
let calls = Arc::new(Mutex::new(vec![])); let calls = Arc::new(Mutex::new(vec![]));
(Self { calls: calls.clone() }, calls) (
Self {
calls: calls.clone(),
},
calls,
)
} }
} }
#[async_trait] #[async_trait]
impl Transport for SpyTransport { impl Transport for SpyTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> { async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.calls.lock().unwrap().push((subject.to_string(), bytes.to_vec())); self.calls
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(()) Ok(())
} }
} }
@@ -127,11 +141,14 @@ mod tests {
async fn thought_created_routes_to_correct_subject() { async fn thought_created_routes_to_correct_subject() {
let (spy, calls) = SpyTransport::new(); let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy); let publisher = EventPublisherAdapter::new(spy);
publisher.publish(&DomainEvent::ThoughtCreated { publisher
.publish(&DomainEvent::ThoughtCreated {
thought_id: ThoughtId::new(), thought_id: ThoughtId::new(),
user_id: UserId::new(), user_id: UserId::new(),
in_reply_to_id: None, in_reply_to_id: None,
}).await.unwrap(); })
.await
.unwrap();
let calls = calls.lock().unwrap(); let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1); assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "thoughts.created"); assert_eq!(calls[0].0, "thoughts.created");
@@ -141,10 +158,13 @@ mod tests {
async fn serialized_payload_is_valid_json() { async fn serialized_payload_is_valid_json() {
let (spy, calls) = SpyTransport::new(); let (spy, calls) = SpyTransport::new();
let publisher = EventPublisherAdapter::new(spy); let publisher = EventPublisherAdapter::new(spy);
publisher.publish(&DomainEvent::UserBlocked { publisher
.publish(&DomainEvent::UserBlocked {
blocker_id: UserId::new(), blocker_id: UserId::new(),
blocked_id: UserId::new(), blocked_id: UserId::new(),
}).await.unwrap(); })
.await
.unwrap();
let bytes = calls.lock().unwrap()[0].1.clone(); let bytes = calls.lock().unwrap()[0].1.clone();
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON"); let json: serde_json::Value = serde_json::from_slice(&bytes).expect("valid JSON");
assert_eq!(json["type"], "UserBlocked"); assert_eq!(json["type"], "UserBlocked");
@@ -163,7 +183,9 @@ mod tests {
let payload = EventPayload::from(&event); let payload = EventPayload::from(&event);
let bytes = serde_json::to_vec(&payload).unwrap(); let bytes = serde_json::to_vec(&payload).unwrap();
struct OneMessageSource { bytes: Vec<u8> } struct OneMessageSource {
bytes: Vec<u8>,
}
#[async_trait::async_trait] #[async_trait::async_trait]
impl MessageSource for OneMessageSource { impl MessageSource for OneMessageSource {
fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> { fn messages(&self) -> futures::stream::BoxStream<'_, Result<RawMessage, DomainError>> {

View File

@@ -10,7 +10,9 @@ pub struct NatsTransport {
} }
impl NatsTransport { impl NatsTransport {
pub fn new(client: async_nats::Client) -> Self { Self { client } } pub fn new(client: async_nats::Client) -> Self {
Self { client }
}
} }
#[async_trait] #[async_trait]
@@ -30,7 +32,9 @@ pub struct NatsMessageSource {
} }
impl NatsMessageSource { impl NatsMessageSource {
pub fn new(client: async_nats::Client) -> Self { Self { client } } pub fn new(client: async_nats::Client) -> Self {
Self { client }
}
} }
impl MessageSource for NatsMessageSource { impl MessageSource for NatsMessageSource {
@@ -61,7 +65,10 @@ impl MessageSource for NatsMessageSource {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::{events::DomainEvent, value_objects::{LikeId, ThoughtId, UserId}}; use domain::{
events::DomainEvent,
value_objects::{LikeId, ThoughtId, UserId},
};
use event_payload::EventPayload; use event_payload::EventPayload;
#[test] #[test]
@@ -86,7 +93,12 @@ mod tests {
}; };
let payload = EventPayload::from(&event); let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap(); let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::LikeAdded { user_id, thought_id, .. } = back { if let DomainEvent::LikeAdded {
user_id,
thought_id,
..
} = back
{
assert_eq!(user_id, uid); assert_eq!(user_id, uid);
assert_eq!(thought_id, tid); assert_eq!(thought_id, tid);
} else { } else {

View File

@@ -4,8 +4,8 @@ use chrono::{DateTime, Utc};
use sqlx::PgPool; use sqlx::PgPool;
use activitypub_base::{ use activitypub_base::{
ApUser, ApUserRepository, ApUser, ApUserRepository, BlockedDomain, FederationRepository, Follower, FollowerStatus,
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, FollowingStatus, RemoteActor,
}; };
// ── PostgresFederationRepository ───────────────────────────────────────────── // ── PostgresFederationRepository ─────────────────────────────────────────────
@@ -15,29 +15,54 @@ pub struct PostgresFederationRepository {
} }
impl PostgresFederationRepository { impl PostgresFederationRepository {
pub fn new(pool: PgPool) -> Self { Self { pool } } pub fn new(pool: PgPool) -> Self {
Self { pool }
}
} }
fn status_str(s: &FollowerStatus) -> &'static str { fn status_str(s: &FollowerStatus) -> &'static str {
match s { FollowerStatus::Pending => "pending", FollowerStatus::Accepted => "accepted", FollowerStatus::Rejected => "rejected" } match s {
FollowerStatus::Pending => "pending",
FollowerStatus::Accepted => "accepted",
FollowerStatus::Rejected => "rejected",
}
} }
fn str_status(s: &str) -> FollowerStatus { fn str_status(s: &str) -> FollowerStatus {
match s { "accepted" => FollowerStatus::Accepted, "rejected" => FollowerStatus::Rejected, _ => FollowerStatus::Pending } match s {
"accepted" => FollowerStatus::Accepted,
"rejected" => FollowerStatus::Rejected,
_ => FollowerStatus::Pending,
}
} }
fn map_remote_actor( fn map_remote_actor(
url: String, handle: String, inbox_url: String, url: String,
shared_inbox_url: Option<String>, display_name: Option<String>, handle: String,
avatar_url: Option<String>, outbox_url: Option<String>, inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
) -> RemoteActor { ) -> RemoteActor {
RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url } RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url,
}
} }
#[async_trait] #[async_trait]
impl FederationRepository for PostgresFederationRepository { impl FederationRepository for PostgresFederationRepository {
async fn add_follower( async fn add_follower(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, &self,
status: FollowerStatus, follow_activity_id: &str, local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()> { ) -> Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id) "INSERT INTO federation_followers(local_user_id,remote_actor_url,status,follow_activity_id)
@@ -50,22 +75,43 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn get_follower_follow_activity_id( async fn get_follower_follow_activity_id(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, &self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2" "SELECT follow_activity_id FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2"
).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)) ).bind(local_user_id).bind(remote_actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))
} }
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> { async fn remove_follower(
sqlx::query("DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2") &self,
.bind(local_user_id).bind(remote_actor_url) local_user_id: uuid::Uuid,
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) remote_actor_url: &str,
) -> Result<()> {
sqlx::query(
"DELETE FROM federation_followers WHERE local_user_id=$1 AND remote_actor_url=$2",
)
.bind(local_user_id)
.bind(remote_actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> { async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
remote_actor_url: String,
status: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
@@ -79,10 +125,22 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn get_followers_page( async fn get_followers_page(
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize, &self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>> { ) -> Result<Vec<Follower>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, status: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
remote_actor_url: String,
status: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle, "SELECT f.remote_actor_url, f.status, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
@@ -105,7 +163,15 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
@@ -118,7 +184,10 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn update_follower_status( async fn update_follower_status(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, status: FollowerStatus, &self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> { ) -> Result<()> {
sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2") sqlx::query("UPDATE federation_followers SET status=$3 WHERE local_user_id=$1 AND remote_actor_url=$2")
.bind(local_user_id).bind(remote_actor_url).bind(status_str(&status)) .bind(local_user_id).bind(remote_actor_url).bind(status_str(&status))
@@ -126,7 +195,10 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn add_following( async fn add_following(
&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str, &self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> { ) -> Result<()> {
self.upsert_remote_actor(actor.clone()).await?; self.upsert_remote_actor(actor.clone()).await?;
sqlx::query( sqlx::query(
@@ -140,7 +212,9 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn get_follow_activity_id( async fn get_follow_activity_id(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, &self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" "SELECT follow_activity_id FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
@@ -148,14 +222,28 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2") sqlx::query(
.bind(local_user_id).bind(actor_url) "DELETE FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2",
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) )
.bind(local_user_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
@@ -168,10 +256,21 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn get_following_page( async fn get_following_page(
&self, local_user_id: uuid::Uuid, offset: u32, limit: usize, &self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> { ) -> Result<Vec<RemoteActor>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { remote_actor_url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
remote_actor_url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle, "SELECT f.remote_actor_url, COALESCE(r.handle,'') AS handle,
COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url COALESCE(r.inbox_url,'') AS inbox_url, r.shared_inbox_url, r.display_name, r.avatar_url, r.outbox_url
@@ -185,20 +284,28 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> { async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let n: i64 = sqlx::query_scalar( let n: i64 =
"SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1" sqlx::query_scalar("SELECT COUNT(*) FROM federation_following WHERE local_user_id=$1")
).bind(local_user_id).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; .bind(local_user_id)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize) Ok(n as usize)
} }
async fn update_following_status( async fn update_following_status(
&self, _local_user_id: uuid::Uuid, _remote_actor_url: &str, _status: FollowingStatus, &self,
_local_user_id: uuid::Uuid,
_remote_actor_url: &str,
_status: FollowingStatus,
) -> Result<()> { ) -> Result<()> {
Ok(()) Ok(())
} }
async fn get_following_outbox_url( async fn get_following_outbox_url(
&self, local_user_id: uuid::Uuid, remote_actor_url: &str, &self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>( sqlx::query_scalar::<_, String>(
"SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2" "SELECT outbox_url FROM federation_following WHERE local_user_id=$1 AND remote_actor_url=$2"
@@ -221,7 +328,15 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> { async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { url: String, handle: String, inbox_url: String, shared_inbox_url: Option<String>, display_name: Option<String>, avatar_url: Option<String>, outbox_url: Option<String> } struct Row {
url: String,
handle: String,
inbox_url: String,
shared_inbox_url: Option<String>,
display_name: Option<String>,
avatar_url: Option<String>,
outbox_url: Option<String>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1" "SELECT url,handle,inbox_url,shared_inbox_url,display_name,avatar_url,outbox_url FROM remote_actors WHERE url=$1"
).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r| ).bind(actor_url).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e)).map(|o| o.map(|r|
@@ -229,12 +344,22 @@ impl FederationRepository for PostgresFederationRepository {
)) ))
} }
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<(String, String)>> { async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { public_key: Option<String>, private_key: Option<String> } struct Row {
public_key: Option<String>,
private_key: Option<String>,
}
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT public_key, private_key FROM users WHERE id=$1 AND local=true" "SELECT public_key, private_key FROM users WHERE id=$1 AND local=true",
).bind(user_id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; )
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.and_then(|r| match (r.public_key, r.private_key) { Ok(row.and_then(|r| match (r.public_key, r.private_key) {
(Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)), (Some(pub_k), Some(priv_k)) => Some((pub_k, priv_k)),
_ => None, _ => None,
@@ -242,27 +367,49 @@ impl FederationRepository for PostgresFederationRepository {
} }
async fn save_local_actor_keypair( async fn save_local_actor_keypair(
&self, user_id: uuid::Uuid, public_key: String, private_key: String, &self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> { ) -> Result<()> {
sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1") sqlx::query("UPDATE users SET public_key=$2, private_key=$3, updated_at=NOW() WHERE id=$1")
.bind(user_id).bind(&public_key).bind(&private_key) .bind(user_id)
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) .bind(&public_key)
.bind(&private_key)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn add_announce( async fn add_announce(
&self, activity_id: &str, object_url: &str, actor_url: &str, announced_at: DateTime<Utc>, &self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: DateTime<Utc>,
) -> Result<()> { ) -> Result<()> {
sqlx::query( sqlx::query(
"INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at) "INSERT INTO federation_announces(activity_id,object_url,actor_url,announced_at)
VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING" VALUES($1,$2,$3,$4) ON CONFLICT(activity_id) DO NOTHING",
).bind(activity_id).bind(object_url).bind(actor_url).bind(announced_at) )
.execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) .bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(announced_at)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn count_announces(&self, object_url: &str) -> Result<usize> { async fn count_announces(&self, object_url: &str) -> Result<usize> {
let n: i64 = sqlx::query_scalar( let n: i64 =
"SELECT COUNT(*) FROM federation_announces WHERE object_url=$1" sqlx::query_scalar("SELECT COUNT(*) FROM federation_announces WHERE object_url=$1")
).bind(object_url).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; .bind(object_url)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize) Ok(n as usize)
} }
@@ -274,21 +421,44 @@ impl FederationRepository for PostgresFederationRepository {
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1") sqlx::query("DELETE FROM federation_blocked_domains WHERE domain=$1")
.bind(domain).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) .bind(domain)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { domain: String, reason: Option<String>, blocked_at: DateTime<Utc> } struct Row {
sqlx::query_as::<_, Row>("SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain") domain: String,
.fetch_all(&self.pool).await.map_err(|e| anyhow!(e)).map(|rows| rows.into_iter().map(|r| reason: Option<String>,
BlockedDomain { domain: r.domain, reason: r.reason, blocked_at: r.blocked_at.to_rfc3339() } blocked_at: DateTime<Utc>,
).collect()) }
sqlx::query_as::<_, Row>(
"SELECT domain,reason,blocked_at FROM federation_blocked_domains ORDER BY domain",
)
.fetch_all(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|rows| {
rows.into_iter()
.map(|r| BlockedDomain {
domain: r.domain,
reason: r.reason,
blocked_at: r.blocked_at.to_rfc3339(),
})
.collect()
})
} }
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> { async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1") let n: i64 =
.bind(domain).fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; sqlx::query_scalar("SELECT COUNT(*) FROM federation_blocked_domains WHERE domain=$1")
.bind(domain)
.fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n > 0) Ok(n > 0)
} }
@@ -300,7 +470,12 @@ impl FederationRepository for PostgresFederationRepository {
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2") sqlx::query("DELETE FROM federation_blocked_actors WHERE local_user_id=$1 AND actor_url=$2")
.bind(local_user_id).bind(actor_url).execute(&self.pool).await.map_err(|e| anyhow!(e)).map(|_| ()) .bind(local_user_id)
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(|e| anyhow!(e))
.map(|_| ())
} }
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> { async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
@@ -325,12 +500,29 @@ pub struct PostgresApUserRepository {
} }
impl PostgresApUserRepository { impl PostgresApUserRepository {
pub fn new(pool: PgPool, base_url: String) -> Self { Self { pool, base_url } } pub fn new(pool: PgPool, base_url: String) -> Self {
Self { pool, base_url }
}
fn row_to_ap_user(&self, id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String>) -> ApUser { fn row_to_ap_user(
&self,
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
) -> ApUser {
let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok(); let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, username)).ok();
let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok()); let avatar_url = avatar_url.and_then(|u| url::Url::parse(&u).ok());
ApUser { id, username, bio, avatar_url, banner_url: None, also_known_as: None, profile_url, attachment: vec![] } ApUser {
id,
username,
bio,
avatar_url,
banner_url: None,
also_known_as: None,
profile_url,
attachment: vec![],
}
} }
} }
@@ -338,25 +530,45 @@ impl PostgresApUserRepository {
impl ApUserRepository for PostgresApUserRepository { impl ApUserRepository for PostgresApUserRepository {
async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> { async fn find_by_id(&self, id: uuid::Uuid) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> } struct Row {
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
}
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true" "SELECT id,username,bio,avatar_url FROM users WHERE id=$1 AND local=true",
).bind(id).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; )
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
} }
async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> Result<Option<ApUser>> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, username: String, bio: Option<String>, avatar_url: Option<String> } struct Row {
id: uuid::Uuid,
username: String,
bio: Option<String>,
avatar_url: Option<String>,
}
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true" "SELECT id,username,bio,avatar_url FROM users WHERE username=$1 AND local=true",
).bind(username).fetch_optional(&self.pool).await.map_err(|e| anyhow!(e))?; )
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url))) Ok(row.map(|r| self.row_to_ap_user(r.id, r.username, r.bio, r.avatar_url)))
} }
async fn count_users(&self) -> Result<usize> { async fn count_users(&self) -> Result<usize> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true") let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE local=true")
.fetch_one(&self.pool).await.map_err(|e| anyhow!(e))?; .fetch_one(&self.pool)
.await
.map_err(|e| anyhow!(e))?;
Ok(n as usize) Ok(n as usize)
} }
} }

View File

@@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use domain::models::thought::Visibility;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
@@ -11,10 +11,16 @@ use domain::{
ports::SearchPort, ports::SearchPort,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
use domain::models::thought::Visibility; use sqlx::PgPool;
pub struct PgSearchRepository { pool: PgPool } pub struct PgSearchRepository {
impl PgSearchRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgSearchRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct FeedRow { struct FeedRow {
@@ -87,13 +93,28 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
username: Username::from_trusted(r.username), username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email), email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash), password_hash: PasswordHash(r.password_hash),
display_name: r.display_name, bio: r.bio, display_name: r.display_name,
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, bio: r.bio,
local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url, avatar_url: r.avatar_url,
public_key: r.public_key, private_key: r.private_key, header_url: r.header_url,
created_at: r.author_created_at, updated_at: r.author_updated_at, custom_css: r.custom_css,
local: r.author_local,
ap_id: r.u_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 } 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,
}
} }
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -123,11 +144,18 @@ impl From<UserRow> for User {
username: Username::from_trusted(r.username), username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email), email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash), password_hash: PasswordHash(r.password_hash),
display_name: r.display_name, bio: r.bio, display_name: r.display_name,
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, bio: r.bio,
local: r.local, ap_id: r.ap_id, inbox_url: r.inbox_url, avatar_url: r.avatar_url,
public_key: r.public_key, private_key: r.private_key, header_url: r.header_url,
created_at: r.created_at, updated_at: r.updated_at, 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,
} }
} }
} }
@@ -146,7 +174,7 @@ impl SearchPort for PgSearchRepository {
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t "SELECT COUNT(*) FROM thoughts t
WHERE t.content % $1 AND t.visibility='public'" WHERE t.content % $1 AND t.visibility='public'",
) )
.bind(query) .bind(query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -182,7 +210,7 @@ impl SearchPort for PgSearchRepository {
) -> Result<Paginated<User>, DomainError> { ) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM users u "SELECT COUNT(*) FROM users u
WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)" WHERE u.local=true AND (u.username % $1 OR u.display_name % $1)",
) )
.bind(query) .bind(query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -216,7 +244,10 @@ impl SearchPort for PgSearchRepository {
mod tests { mod tests {
use super::*; use super::*;
use domain::{ use domain::{
models::{thought::{Thought, Visibility}, user::User}, models::{
thought::{Thought, Visibility},
user::User,
},
ports::{SearchPort, ThoughtRepository, UserRepository}, ports::{SearchPort, ThoughtRepository, UserRepository},
value_objects::*, value_objects::*,
}; };
@@ -233,9 +264,13 @@ mod tests {
); );
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local( let t = Thought::new_local(
ThoughtId::new(), u.id.clone(), ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(), Content::new_local(content).unwrap(),
None, Visibility::Public, None, false, None,
Visibility::Public,
None,
false,
); );
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
@@ -246,7 +281,17 @@ mod tests {
seed_thought(&pool, "alice", "hello world").await; seed_thought(&pool, "alice", "hello world").await;
seed_thought(&pool, "bob", "goodbye universe").await; seed_thought(&pool, "bob", "goodbye universe").await;
let repo = PgSearchRepository::new(pool); let repo = PgSearchRepository::new(pool);
let result = repo.search_thoughts("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); let result = repo
.search_thoughts(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello world"); assert_eq!(result.items[0].thought.content.as_str(), "hello world");
} }
@@ -255,19 +300,46 @@ mod tests {
async fn search_users_finds_by_username(pool: sqlx::PgPool) { async fn search_users_finds_by_username(pool: sqlx::PgPool) {
use postgres::user::PgUserRepository; use postgres::user::PgUserRepository;
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let alice = User::new_local(UserId::new(), Username::new("alice_search").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let alice = User::new_local(
UserId::new(),
Username::new("alice_search").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&alice).await.unwrap(); urepo.save(&alice).await.unwrap();
let repo = PgSearchRepository::new(pool); let repo = PgSearchRepository::new(pool);
let result = repo.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap(); let result = repo
.search_users(
"alice",
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(!result.items.is_empty()); assert!(!result.items.is_empty());
assert!(result.items.iter().any(|u| u.username.as_str() == "alice_search")); assert!(result
.items
.iter()
.any(|u| u.username.as_str() == "alice_search"));
} }
#[sqlx::test(migrations = "../postgres/migrations")] #[sqlx::test(migrations = "../postgres/migrations")]
async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) { async fn search_thoughts_returns_empty_for_no_match(pool: sqlx::PgPool) {
seed_thought(&pool, "alice", "hello world").await; seed_thought(&pool, "alice", "hello world").await;
let repo = PgSearchRepository::new(pool); let repo = PgSearchRepository::new(pool);
let result = repo.search_thoughts("zzzzzzzzz", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); let result = repo
.search_thoughts(
"zzzzzzzzz",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 0); assert_eq!(result.total, 0);
} }
} }

View File

@@ -22,7 +22,10 @@ impl PgActivityPubRepository {
#[async_trait] #[async_trait]
impl ActivityPubRepository for PgActivityPubRepository { impl ActivityPubRepository for PgActivityPubRepository {
async fn outbox_entries_for_actor(&self, user_id: &UserId) -> Result<Vec<OutboxEntry>, DomainError> { async fn outbox_entries_for_actor(
&self,
user_id: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
id: uuid::Uuid, id: uuid::Uuid,
@@ -134,7 +137,10 @@ impl ActivityPubRepository for PgActivityPubRepository {
.collect()) .collect())
} }
async fn find_remote_actor_id(&self, actor_ap_url: &Url) -> Result<Option<UserId>, DomainError> { async fn find_remote_actor_id(
&self,
actor_ap_url: &Url,
) -> Result<Option<UserId>, DomainError> {
sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1") sqlx::query_scalar::<_, uuid::Uuid>("SELECT id FROM users WHERE ap_id=$1")
.bind(actor_ap_url.as_str()) .bind(actor_ap_url.as_str())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -148,7 +154,10 @@ impl ActivityPubRepository for PgActivityPubRepository {
return Ok(id); return Ok(id);
} }
let new_id = uuid::Uuid::new_v4(); let new_id = uuid::Uuid::new_v4();
let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_"); let handle = actor_ap_url
.path()
.trim_start_matches('/')
.replace('/', "_");
sqlx::query( sqlx::query(
"INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at) "INSERT INTO users(id,username,email,password_hash,local,ap_id,created_at,updated_at)
VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING", VALUES($1,$2,$3,'',false,$4,NOW(),NOW()) ON CONFLICT(ap_id) DO NOTHING",
@@ -163,7 +172,11 @@ impl ActivityPubRepository for PgActivityPubRepository {
// Re-fetch to get whichever id won the race // Re-fetch to get whichever id won the race
self.find_remote_actor_id(actor_ap_url) self.find_remote_actor_id(actor_ap_url)
.await? .await?
.ok_or_else(|| DomainError::Internal("intern_remote_actor: insert succeeded but row not found".into())) .ok_or_else(|| {
DomainError::Internal(
"intern_remote_actor: insert succeeded but row not found".into(),
)
})
} }
async fn accept_note( async fn accept_note(
@@ -195,7 +208,9 @@ impl ActivityPubRepository for PgActivityPubRepository {
async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> { async fn apply_note_update(&self, ap_id: &Url, new_content: &str) -> Result<(), DomainError> {
let capped: String = new_content.chars().take(500).collect(); let capped: String = new_content.chars().take(500).collect();
sqlx::query("UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false") sqlx::query(
"UPDATE thoughts SET content=$2,updated_at=NOW() WHERE ap_id=$1 AND local=false",
)
.bind(ap_id.as_str()) .bind(ap_id.as_str())
.bind(&capped) .bind(&capped)
.execute(&self.pool) .execute(&self.pool)
@@ -253,7 +268,14 @@ mod tests {
let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap(); let actor_url = url::Url::parse("https://remote.example/users/bob").unwrap();
let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap(); let ap_id = url::Url::parse("https://remote.example/notes/1").unwrap();
let author = repo.intern_remote_actor(&actor_url).await.unwrap(); let author = repo.intern_remote_actor(&actor_url).await.unwrap();
repo.accept_note(&ap_id, &author, "hello from remote", chrono::Utc::now(), false, None) repo.accept_note(
&ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
false,
None,
)
.await .await
.unwrap(); .unwrap();
repo.retract_note(&ap_id).await.unwrap(); repo.retract_note(&ap_id).await.unwrap();

View File

@@ -1,29 +1,75 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::api_key::ApiKey,
ports::ApiKeyRepository,
value_objects::{ApiKeyId, UserId},
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::api_key::ApiKey, ports::ApiKeyRepository, value_objects::{ApiKeyId, UserId}};
pub struct PgApiKeyRepository { pool: PgPool } pub struct PgApiKeyRepository {
impl PgApiKeyRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgApiKeyRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl ApiKeyRepository for PgApiKeyRepository { impl ApiKeyRepository for PgApiKeyRepository {
async fn save(&self, k: &ApiKey) -> Result<(), DomainError> { 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)") sqlx::query(
.bind(k.id.as_uuid()).bind(k.user_id.as_uuid()).bind(&k.key_hash).bind(&k.name).bind(k.created_at) "INSERT INTO api_keys(id,user_id,key_hash,name,created_at) VALUES($1,$2,$3,$4,$5)",
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) )
.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<Option<ApiKey>, DomainError> { async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> } #[derive(sqlx::FromRow)]
sqlx::query_as::<_, Row>("SELECT id,user_id,key_hash,name,created_at FROM api_keys WHERE key_hash=$1") struct Row {
.bind(hash).fetch_optional(&self.pool).await id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
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_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 })) .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<Vec<ApiKey>, DomainError> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ApiKey>, DomainError> {
#[derive(sqlx::FromRow)] struct Row { id: uuid::Uuid, user_id: uuid::Uuid, key_hash: String, name: String, created_at: DateTime<Utc> } #[derive(sqlx::FromRow)]
struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
key_hash: String,
name: String,
created_at: DateTime<Utc>,
}
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") 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 .bind(user_id.as_uuid()).fetch_all(&self.pool).await
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
@@ -32,30 +78,46 @@ impl ApiKeyRepository for PgApiKeyRepository {
async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> { async fn delete(&self, id: &ApiKeyId, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2") sqlx::query("DELETE FROM api_keys WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid()).bind(user_id.as_uuid()) .bind(id.as_uuid())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) .bind(user_id.as_uuid())
.execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::user::User, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User { async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let u = User::new_local(
repo.save(&u).await.unwrap(); u UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn save_and_find_by_hash(pool: sqlx::PgPool) { async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "abc123".into(), name: "test".into(), created_at: Utc::now() }; let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "abc123".into(),
name: "test".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap(); let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test"); assert_eq!(found.name, "test");
@@ -65,7 +127,13 @@ mod tests {
async fn delete_key(pool: sqlx::PgPool) { async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { id: ApiKeyId::new(), user_id: user.id.clone(), key_hash: "def456".into(), name: "key2".into(), created_at: Utc::now() }; let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap(); repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none()); assert!(repo.find_by_hash("def456").await.unwrap().is_none());

View File

@@ -1,9 +1,17 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{
errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId,
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::social::Block, ports::BlockRepository, value_objects::UserId};
pub struct PgBlockRepository { pool: PgPool } pub struct PgBlockRepository {
impl PgBlockRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgBlockRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl BlockRepository for PgBlockRepository { impl BlockRepository for PgBlockRepository {
@@ -31,9 +39,8 @@ impl BlockRepository for PgBlockRepository {
} }
async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> { async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<bool, DomainError> {
let count: i64 = sqlx::query_scalar( let count: i64 =
"SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2" sqlx::query_scalar("SELECT COUNT(*) FROM blocks WHERE blocker_id=$1 AND blocked_id=$2")
)
.bind(blocker_id.as_uuid()) .bind(blocker_id.as_uuid())
.bind(blocked_id.as_uuid()) .bind(blocked_id.as_uuid())
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -46,15 +53,21 @@ impl BlockRepository for PgBlockRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::user::User, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); let u = User::new_local(
repo.save(&u).await.unwrap(); u UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
@@ -62,7 +75,11 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() }; let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
@@ -73,7 +90,11 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
let block = Block { blocker_id: alice.id.clone(), blocked_id: bob.id.clone(), created_at: Utc::now() }; let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap(); repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());

View File

@@ -1,10 +1,21 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Boost,
ports::BoostRepository,
value_objects::{BoostId, ThoughtId, UserId},
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::social::Boost, ports::BoostRepository, value_objects::{BoostId, ThoughtId, UserId}};
pub struct PgBoostRepository { pool: PgPool } pub struct PgBoostRepository {
impl PgBoostRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgBoostRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl BoostRepository for PgBoostRepository { impl BoostRepository for PgBoostRepository {
@@ -18,15 +29,30 @@ impl BoostRepository for PgBoostRepository {
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> {
let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2") let r = sqlx::query("DELETE FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid()) .bind(user_id.as_uuid())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .bind(thought_id.as_uuid())
if r.rows_affected() == 0 { return Err(DomainError::NotFound); } .execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(()) Ok(())
} }
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Boost>, DomainError> { async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Boost>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> } struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2") sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM boosts WHERE user_id=$1 AND thought_id=$2")
.bind(user_id.as_uuid()).bind(thought_id.as_uuid()) .bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await .fetch_optional(&self.pool).await
@@ -36,7 +62,9 @@ impl BoostRepository for PgBoostRepository {
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> { async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1") sqlx::query_scalar("SELECT COUNT(*) FROM boosts WHERE thought_id=$1")
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await .bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
} }
} }
@@ -44,17 +72,36 @@ impl BoostRepository for PgBoostRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use chrono::Utc;
use domain::ports::{ThoughtRepository, UserRepository}; use domain::ports::{ThoughtRepository, UserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }
@@ -63,7 +110,13 @@ mod tests {
async fn boost_and_count(pool: sqlx::PgPool) { async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await; let (user, thought) = seed(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
@@ -72,7 +125,13 @@ mod tests {
async fn unboost(pool: sqlx::PgPool) { async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await; let (user, thought) = seed(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { id: BoostId::new(), user_id: user.id.clone(), thought_id: thought.id.clone(), ap_id: None, created_at: Utc::now() }; let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);

View File

@@ -1,16 +1,26 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool; use domain::models::thought::Visibility;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{feed::{FeedEntry, PageParams, Paginated}, thought::Thought, user::User}, models::{
feed::{FeedEntry, PageParams, Paginated},
thought::Thought,
user::User,
},
ports::FeedRepository, ports::FeedRepository,
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
}; };
use domain::models::thought::Visibility; use sqlx::PgPool;
pub struct PgFeedRepository { pool: PgPool } pub struct PgFeedRepository {
impl PgFeedRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgFeedRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct FeedRow { struct FeedRow {
@@ -57,7 +67,8 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
), ),
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(), None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
}; };
format!(" format!(
"
SELECT SELECT
t.id AS thought_id, t.user_id AS t_user_id, t.content, t.id AS thought_id, t.user_id AS t_user_id, t.content,
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id, t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
@@ -72,7 +83,8 @@ fn feed_select(viewer: Option<uuid::Uuid>) -> String {
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_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, (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
{viewer_checks} {viewer_checks}
FROM thoughts t JOIN users u ON u.id=t.user_id") FROM thoughts t JOIN users u ON u.id=t.user_id"
)
} }
fn row_to_entry(r: FeedRow) -> FeedEntry { fn row_to_entry(r: FeedRow) -> FeedEntry {
@@ -95,52 +107,105 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
username: Username::from_trusted(r.username), username: Username::from_trusted(r.username),
email: Email::from_trusted(r.email), email: Email::from_trusted(r.email),
password_hash: PasswordHash(r.password_hash), password_hash: PasswordHash(r.password_hash),
display_name: r.display_name, bio: r.bio, display_name: r.display_name,
avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, bio: r.bio,
local: r.author_local, ap_id: r.u_ap_id, inbox_url: r.inbox_url, avatar_url: r.avatar_url,
public_key: r.public_key, private_key: r.private_key, header_url: r.header_url,
created_at: r.author_created_at, updated_at: r.author_updated_at, custom_css: r.custom_css,
local: r.author_local,
ap_id: r.u_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: r.liked_by_viewer, boosted_by_viewer: r.boosted_by_viewer } FeedEntry {
thought,
author,
like_count: r.like_count,
boost_count: r.boost_count,
reply_count: r.reply_count,
liked_by_viewer: r.liked_by_viewer,
boosted_by_viewer: r.boosted_by_viewer,
}
} }
#[async_trait] #[async_trait]
impl FeedRepository for PgFeedRepository { impl FeedRepository for PgFeedRepository {
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> { async fn home_feed(
&self,
following_ids: &[UserId],
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect(); let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
let viewer = viewer_id.map(|v| v.as_uuid()); let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'" "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()))?; )
.bind(&ids)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer); let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3"); let sql = format!("{sel} 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) let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(&ids).bind(page.limit()).bind(page.offset()) .bind(&ids)
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .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(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> { async fn public_feed(
&self,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid()); let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'" "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()))?; )
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let sel = feed_select(viewer); let sel = feed_select(viewer);
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2"); let sql = format!("{sel} 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) let rows = sqlx::query_as::<_, FeedRow>(&sql)
.bind(page.limit()).bind(page.offset()) .bind(page.limit())
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .bind(page.offset())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> { async fn search(
&self,
query: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid()); let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'" "SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'",
) )
.bind(query) .bind(query)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -157,16 +222,26 @@ impl FeedRepository for PgFeedRepository {
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page }) Ok(Paginated {
items: rows.into_iter().map(row_to_entry).collect(),
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> { async fn tag_feed(
&self,
tag_name: &str,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid()); let viewer = viewer_id.map(|v| v.as_uuid());
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t "SELECT COUNT(*) FROM thoughts t
JOIN thought_tags tt ON tt.thought_id = t.id JOIN thought_tags tt ON tt.thought_id = t.id
JOIN tags tg ON tg.id = tt.tag_id JOIN tags tg ON tg.id = tt.tag_id
WHERE tg.name = $1 AND t.visibility = 'public'" WHERE tg.name = $1 AND t.visibility = 'public'",
) )
.bind(tag_name) .bind(tag_name)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -197,12 +272,17 @@ impl FeedRepository for PgFeedRepository {
}) })
} }
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> { async fn user_feed(
&self,
user_id: &UserId,
page: &PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
let viewer = viewer_id.map(|v| v.as_uuid()); let viewer = viewer_id.map(|v| v.as_uuid());
let uid = user_id.as_uuid(); let uid = user_id.as_uuid();
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'" "SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'",
) )
.bind(uid) .bind(uid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -231,15 +311,35 @@ impl FeedRepository for PgFeedRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::{models::{thought::{Thought, Visibility}, user::User}, ports::{ThoughtRepository, UserRepository}, value_objects::*};
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
ports::{ThoughtRepository, UserRepository},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(format!("{username}@ex.com")).unwrap(), PasswordHash("h".into())); let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local(content).unwrap(), None, Visibility::Public, None, false); let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }
@@ -248,7 +348,16 @@ mod tests {
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await; let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo.public_feed(&PageParams { page: 1, per_page: 20 }, None).await.unwrap(); let result = repo
.public_feed(
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert_eq!(result.total, 1); assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello"); assert_eq!(result.items[0].thought.content.as_str(), "hello");
} }
@@ -258,8 +367,21 @@ mod tests {
let (_, _) = seed(&pool, "alice", "hello world").await; let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await; let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool); let repo = PgFeedRepository::new(pool);
let result = repo.search("hello world", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); let result = repo
.search(
"hello world",
&PageParams {
page: 1,
per_page: 20,
},
None,
)
.await
.unwrap();
assert!(result.total >= 1); assert!(result.total >= 1);
assert!(result.items.iter().any(|e| e.thought.content.as_str() == "hello world")); assert!(result
.items
.iter()
.any(|e| e.thought.content.as_str() == "hello world"));
} }
} }

View File

@@ -1,15 +1,25 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{feed::{PageParams, Paginated}, social::{Follow, FollowState}, user::User}, models::{
feed::{PageParams, Paginated},
social::{Follow, FollowState},
user::User,
},
ports::FollowRepository, ports::FollowRepository,
value_objects::UserId, value_objects::UserId,
}; };
use sqlx::PgPool;
pub struct PgFollowRepository { pool: PgPool } pub struct PgFollowRepository {
impl PgFollowRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgFollowRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl FollowRepository for PgFollowRepository { impl FollowRepository for PgFollowRepository {
@@ -37,13 +47,25 @@ impl FollowRepository for PgFollowRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 { return Err(DomainError::NotFound); } if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(()) Ok(())
} }
async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result<Option<Follow>, DomainError> { async fn find(
&self,
follower_id: &UserId,
following_id: &UserId,
) -> Result<Option<Follow>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { follower_id: uuid::Uuid, following_id: uuid::Uuid, state: String, ap_id: Option<String>, created_at: DateTime<Utc> } struct Row {
follower_id: uuid::Uuid,
following_id: uuid::Uuid,
state: String,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>( sqlx::query_as::<_, Row>(
"SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2" "SELECT follower_id,following_id,state,ap_id,created_at FROM follows WHERE follower_id=$1 AND following_id=$2"
) )
@@ -61,7 +83,12 @@ impl FollowRepository for PgFollowRepository {
})) }))
} }
async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { 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") sqlx::query("UPDATE follows SET state=$3 WHERE follower_id=$1 AND following_id=$2")
.bind(follower_id.as_uuid()) .bind(follower_id.as_uuid())
.bind(following_id.as_uuid()) .bind(following_id.as_uuid())
@@ -72,9 +99,13 @@ impl FollowRepository for PgFollowRepository {
.map(|_| ()) .map(|_| ())
} }
async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> { async fn list_followers(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'" "SELECT COUNT(*) FROM follows WHERE following_id=$1 AND state='accepted'",
) )
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -102,9 +133,13 @@ impl FollowRepository for PgFollowRepository {
}) })
} }
async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<User>, DomainError> { async fn list_following(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<User>, DomainError> {
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'" "SELECT COUNT(*) FROM follows WHERE follower_id=$1 AND state='accepted'",
) )
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.fetch_one(&self.pool) .fetch_one(&self.pool)
@@ -132,9 +167,12 @@ impl FollowRepository for PgFollowRepository {
}) })
} }
async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result<Vec<UserId>, DomainError> { async fn get_accepted_following_ids(
&self,
user_id: &UserId,
) -> Result<Vec<UserId>, DomainError> {
let ids: Vec<uuid::Uuid> = sqlx::query_scalar( let ids: Vec<uuid::Uuid> = sqlx::query_scalar(
"SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'" "SELECT following_id FROM follows WHERE follower_id=$1 AND state='accepted'",
) )
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
@@ -147,15 +185,21 @@ impl FollowRepository for PgFollowRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::user::User, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); let u = User::new_local(
repo.save(&u).await.unwrap(); u UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
@@ -163,7 +207,13 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); 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: Utc::now() }; let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
@@ -174,9 +224,17 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
let follow = Follow { follower_id: alice.id.clone(), following_id: bob.id.clone(), state: FollowState::Pending, ap_id: None, created_at: Utc::now() }; let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Pending,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted).await.unwrap(); repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
.await
.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap(); let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted); assert_eq!(found.state, FollowState::Accepted);
} }
@@ -186,7 +244,13 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); 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: Utc::now() }; let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]); assert_eq!(ids, vec![bob.id]);

View File

@@ -1,10 +1,21 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::social::Like,
ports::LikeRepository,
value_objects::{LikeId, ThoughtId, UserId},
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::social::Like, ports::LikeRepository, value_objects::{LikeId, ThoughtId, UserId}};
pub struct PgLikeRepository { pool: PgPool } pub struct PgLikeRepository {
impl PgLikeRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgLikeRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl LikeRepository for PgLikeRepository { impl LikeRepository for PgLikeRepository {
@@ -18,15 +29,30 @@ impl LikeRepository for PgLikeRepository {
async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { 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") 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()) .bind(user_id.as_uuid())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .bind(thought_id.as_uuid())
if r.rows_affected() == 0 { return Err(DomainError::NotFound); } .execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(()) Ok(())
} }
async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<Option<Like>, DomainError> { async fn find(
&self,
user_id: &UserId,
thought_id: &ThoughtId,
) -> Result<Option<Like>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, thought_id: uuid::Uuid, ap_id: Option<String>, created_at: DateTime<Utc> } struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
thought_id: uuid::Uuid,
ap_id: Option<String>,
created_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>("SELECT id,user_id,thought_id,ap_id,created_at FROM likes WHERE user_id=$1 AND thought_id=$2") 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()) .bind(user_id.as_uuid()).bind(thought_id.as_uuid())
.fetch_optional(&self.pool).await .fetch_optional(&self.pool).await
@@ -36,7 +62,9 @@ impl LikeRepository for PgLikeRepository {
async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> { async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result<i64, DomainError> {
sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1") sqlx::query_scalar("SELECT COUNT(*) FROM likes WHERE thought_id=$1")
.bind(thought_id.as_uuid()).fetch_one(&self.pool).await .bind(thought_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
} }
} }
@@ -44,17 +72,36 @@ impl LikeRepository for PgLikeRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use chrono::Utc;
use domain::ports::{ThoughtRepository, UserRepository}; use domain::ports::{ThoughtRepository, UserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool) -> (User, Thought) { async fn seed(pool: &sqlx::PgPool) -> (User, Thought) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
(u, t) (u, t)
} }
@@ -63,7 +110,13 @@ mod tests {
async fn like_and_count(pool: sqlx::PgPool) { async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await; let (user, thought) = seed(&pool).await;
let repo = PgLikeRepository::new(pool); 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() }; 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(); repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
} }
@@ -72,7 +125,13 @@ mod tests {
async fn unlike(pool: sqlx::PgPool) { async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed(&pool).await; let (user, thought) = seed(&pool).await;
let repo = PgLikeRepository::new(pool); 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() }; 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(); repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);

View File

@@ -1,10 +1,24 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
notification::{Notification, NotificationType},
},
ports::NotificationRepository,
value_objects::{NotificationId, ThoughtId, UserId},
};
use sqlx::PgPool; 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 } pub struct PgNotificationRepository {
impl PgNotificationRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgNotificationRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl NotificationRepository for PgNotificationRepository { impl NotificationRepository for PgNotificationRepository {
@@ -19,50 +33,91 @@ impl NotificationRepository for PgNotificationRepository {
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) .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<Paginated<Notification>, DomainError> { async fn list_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Notification>, DomainError> {
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE user_id=$1") 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()))?; .bind(user_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { id: uuid::Uuid, user_id: uuid::Uuid, r#type: String, from_user_id: Option<uuid::Uuid>, thought_id: Option<uuid::Uuid>, read: bool, created_at: DateTime<Utc> } struct Row {
id: uuid::Uuid,
user_id: uuid::Uuid,
r#type: String,
from_user_id: Option<uuid::Uuid>,
thought_id: Option<uuid::Uuid>,
read: bool,
created_at: DateTime<Utc>,
}
let rows = sqlx::query_as::<_, Row>( 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" "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()) ).bind(user_id.as_uuid()).bind(page.limit()).bind(page.offset())
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
let items = rows.into_iter().map(|r| Notification { let items = rows
id: NotificationId::from_uuid(r.id), user_id: UserId::from_uuid(r.user_id), .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), notification_type: NotificationType::from_str(&r.r#type),
from_user_id: r.from_user_id.map(UserId::from_uuid), from_user_id: r.from_user_id.map(UserId::from_uuid),
thought_id: r.thought_id.map(ThoughtId::from_uuid), thought_id: r.thought_id.map(ThoughtId::from_uuid),
read: r.read, created_at: r.created_at, read: r.read,
}).collect(); created_at: r.created_at,
Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) })
.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> { 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") sqlx::query("UPDATE notifications SET read=true WHERE id=$1 AND user_id=$2")
.bind(id.as_uuid()).bind(user_id.as_uuid()) .bind(id.as_uuid())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) .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> { async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1") sqlx::query("UPDATE notifications SET read=true WHERE user_id=$1")
.bind(user_id.as_uuid()) .bind(user_id.as_uuid())
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) .execute(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))
.map(|_| ())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::{models::{notification::NotificationType, user::User}, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{
models::{notification::NotificationType, user::User},
value_objects::*,
};
async fn seed_user(pool: &sqlx::PgPool) -> User { async fn seed_user(pool: &sqlx::PgPool) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let u = User::new_local(
repo.save(&u).await.unwrap(); u UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
@@ -70,9 +125,26 @@ mod tests {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams; use domain::models::feed::PageParams;
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Like, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() }; let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
notification_type: NotificationType::Like,
from_user_id: None,
thought_id: None,
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap(); repo.save(&n).await.unwrap();
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap(); let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert_eq!(page.total, 1); assert_eq!(page.total, 1);
assert!(!page.items[0].read); assert!(!page.items[0].read);
} }
@@ -82,10 +154,27 @@ mod tests {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams; use domain::models::feed::PageParams;
let n = Notification { id: NotificationId::new(), user_id: user.id.clone(), notification_type: NotificationType::Follow, from_user_id: None, thought_id: None, read: false, created_at: Utc::now() }; let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
notification_type: NotificationType::Follow,
from_user_id: None,
thought_id: None,
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap(); repo.save(&n).await.unwrap();
repo.mark_all_read(&user.id).await.unwrap(); repo.mark_all_read(&user.id).await.unwrap();
let page = repo.list_for_user(&user.id, &PageParams { page: 1, per_page: 20 }).await.unwrap(); let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(page.items[0].read); assert!(page.items[0].read);
} }
} }

View File

@@ -1,10 +1,18 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{
errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository,
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::remote_actor::RemoteActor, ports::RemoteActorRepository};
pub struct PgRemoteActorRepository { pool: PgPool } pub struct PgRemoteActorRepository {
impl PgRemoteActorRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgRemoteActorRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl RemoteActorRepository for PgRemoteActorRepository { impl RemoteActorRepository for PgRemoteActorRepository {
@@ -23,7 +31,15 @@ impl RemoteActorRepository for PgRemoteActorRepository {
async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> { async fn find_by_url(&self, url: &str) -> Result<Option<RemoteActor>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { url: String, handle: String, display_name: Option<String>, inbox_url: String, shared_inbox_url: Option<String>, public_key: String, last_fetched_at: DateTime<Utc> } struct Row {
url: String,
handle: String,
display_name: Option<String>,
inbox_url: String,
shared_inbox_url: Option<String>,
public_key: String,
last_fetched_at: DateTime<Utc>,
}
sqlx::query_as::<_, Row>( 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" "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 ).bind(url).fetch_optional(&self.pool).await

View File

@@ -1,35 +1,81 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
feed::{PageParams, Paginated},
tag::Tag,
thought::Thought,
},
ports::TagRepository,
value_objects::ThoughtId,
};
use sqlx::PgPool; 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 } pub struct PgTagRepository {
impl PgTagRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgTagRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl TagRepository for PgTagRepository { impl TagRepository for PgTagRepository {
async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> { async fn find_or_create(&self, name: &str) -> Result<Tag, DomainError> {
let name = name.to_lowercase(); let name = name.to_lowercase();
sqlx::query("INSERT INTO tags(name) VALUES($1) ON CONFLICT(name) DO NOTHING") 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()))?; .bind(&name)
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String } .execute(&self.pool)
let row = sqlx::query_as::<_, Row>("SELECT id,name FROM tags WHERE name=$1").bind(&name) .await
.fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(Tag { id: row.id, name: row.name }) #[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> { async fn attach_to_thought(
sqlx::query("INSERT INTO thought_tags(thought_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING") &self,
.bind(thought_id.as_uuid()).bind(tag_id) thought_id: &ThoughtId,
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) 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> { 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()) sqlx::query("DELETE FROM thought_tags WHERE thought_id=$1")
.execute(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string())).map(|_| ()) .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<Vec<Tag>, DomainError> { async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result<Vec<Tag>, DomainError> {
#[derive(sqlx::FromRow)] struct Row { id: i32, name: String } #[derive(sqlx::FromRow)]
struct Row {
id: i32,
name: String,
}
sqlx::query_as::<_, Row>( 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" "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 ).bind(thought_id.as_uuid()).fetch_all(&self.pool).await
@@ -37,10 +83,18 @@ impl TagRepository for PgTagRepository {
.map(|rows| rows.into_iter().map(|r| Tag { id: r.id, name: r.name }).collect()) .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<Paginated<Thought>, DomainError> { async fn list_thoughts_by_tag(
&self,
tag_name: &str,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError> {
let total: i64 = sqlx::query_scalar( 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" "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()))?; )
.bind(tag_name)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
let rows = sqlx::query_as::<_, crate::thought::ThoughtRow>( 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 "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
@@ -49,7 +103,12 @@ impl TagRepository for PgTagRepository {
).bind(tag_name).bind(page.limit()).bind(page.offset()) ).bind(tag_name).bind(page.limit()).bind(page.offset())
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?; .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 }) Ok(Paginated {
items: rows.into_iter().map(Thought::from).collect(),
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> { async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
@@ -59,7 +118,7 @@ impl TagRepository for PgTagRepository {
JOIN thought_tags tt ON t.id = tt.tag_id JOIN thought_tags tt ON t.id = tt.tag_id
GROUP BY t.id, t.name GROUP BY t.id, t.name
ORDER BY thought_count DESC ORDER BY thought_count DESC
LIMIT $1" LIMIT $1",
) )
.bind(limit as i64) .bind(limit as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
@@ -71,9 +130,15 @@ impl TagRepository for PgTagRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
use crate::{thought::PgThoughtRepository, user::PgUserRepository}; use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserRepository}; use domain::ports::{ThoughtRepository, UserRepository};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_or_create_tag(pool: sqlx::PgPool) { async fn find_or_create_tag(pool: sqlx::PgPool) {
@@ -88,9 +153,22 @@ mod tests {
async fn attach_and_list(pool: sqlx::PgPool) { async fn attach_and_list(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone()); let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone()); let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())); let u = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap(); urepo.save(&u).await.unwrap();
let t = Thought::new_local(ThoughtId::new(), u.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false); let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local("hi").unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap(); trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool); let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap(); let tag = repo.find_or_create("greetings").await.unwrap();

View File

@@ -1,6 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
@@ -10,9 +9,16 @@ use domain::{
ports::ThoughtRepository, ports::ThoughtRepository,
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
use sqlx::PgPool;
pub struct PgThoughtRepository { pool: PgPool } pub struct PgThoughtRepository {
impl PgThoughtRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgThoughtRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct ThoughtRow { pub(crate) struct ThoughtRow {
@@ -93,7 +99,9 @@ impl ThoughtRepository for PgThoughtRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
if r.rows_affected() == 0 { return Err(DomainError::NotFound); } if r.rows_affected() == 0 {
return Err(DomainError::NotFound);
}
Ok(()) Ok(())
} }
@@ -108,9 +116,9 @@ impl ThoughtRepository for PgThoughtRepository {
} }
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> { async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError> {
sqlx::query_as::<_, ThoughtRow>( sqlx::query_as::<_, ThoughtRow>(&format!(
&format!("{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC") "{THOUGHT_SELECT} WHERE id=$1 OR in_reply_to_id=$1 ORDER BY created_at ASC"
) ))
.bind(id.as_uuid()) .bind(id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
@@ -118,19 +126,21 @@ impl ThoughtRepository for PgThoughtRepository {
.map(|rows| rows.into_iter().map(Thought::from).collect()) .map(|rows| rows.into_iter().map(Thought::from).collect())
} }
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError> { async fn list_by_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<Thought>, DomainError> {
let uid = user_id.as_uuid(); let uid = user_id.as_uuid();
let total: i64 = sqlx::query_scalar( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id = $1")
"SELECT COUNT(*) FROM thoughts WHERE user_id = $1"
)
.bind(uid) .bind(uid)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
let rows = sqlx::query_as::<_, ThoughtRow>( let rows = sqlx::query_as::<_, ThoughtRow>(&format!(
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3") "{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
) ))
.bind(uid) .bind(uid)
.bind(page.limit()) .bind(page.limit())
.bind(page.offset()) .bind(page.offset())
@@ -150,9 +160,15 @@ impl ThoughtRepository for PgThoughtRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::{models::{thought::{Thought, Visibility}, user::User}, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
@@ -189,7 +205,15 @@ mod tests {
async fn delete_thought(pool: sqlx::PgPool) { async fn delete_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await; let user = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); 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); 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.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
@@ -200,7 +224,15 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local(ThoughtId::new(), alice.id.clone(), Content::new_local("secret").unwrap(), None, Visibility::Public, None, false); let t = Thought::new_local(
ThoughtId::new(),
alice.id.clone(),
Content::new_local("secret").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
assert!(matches!(err, DomainError::NotFound)); assert!(matches!(err, DomainError::NotFound));
@@ -210,8 +242,24 @@ mod tests {
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await; let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let root = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("root").unwrap(), None, Visibility::Public, None, false); let root = Thought::new_local(
let reply = Thought::new_local(ThoughtId::new(), user.id.clone(), Content::new_local("reply").unwrap(), Some(root.id.clone()), Visibility::Public, None, false); ThoughtId::new(),
user.id.clone(),
Content::new_local("root").unwrap(),
None,
Visibility::Public,
None,
false,
);
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("reply").unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
repo.save(&root).await.unwrap(); repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap(); repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap(); let thread = repo.get_thread(&root.id).await.unwrap();

View File

@@ -1,34 +1,74 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{top_friend::TopFriend, user::User},
ports::TopFriendRepository,
value_objects::UserId,
};
use sqlx::PgPool; use sqlx::PgPool;
use domain::{errors::DomainError, models::{top_friend::TopFriend, user::User}, ports::TopFriendRepository, value_objects::UserId};
pub struct PgTopFriendRepository { pool: PgPool } pub struct PgTopFriendRepository {
impl PgTopFriendRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgTopFriendRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl TopFriendRepository for PgTopFriendRepository { impl TopFriendRepository for PgTopFriendRepository {
async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { async fn set_top_friends(
let mut tx = self.pool.begin().await.map_err(|e| DomainError::Internal(e.to_string()))?; &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") 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()))?; .bind(user_id.as_uuid())
.execute(&mut *tx)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
for (friend_id, pos) in friends { for (friend_id, pos) in friends {
sqlx::query("INSERT INTO top_friends(user_id,friend_id,position) VALUES($1,$2,$3)") 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) .bind(user_id.as_uuid())
.execute(&mut *tx).await.map_err(|e| DomainError::Internal(e.to_string()))?; .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())) tx.commit()
.await
.map_err(|e| DomainError::Internal(e.to_string()))
} }
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> { async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<(TopFriend, User)>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
tf_user_id: uuid::Uuid, friend_id: uuid::Uuid, position: i16, tf_user_id: uuid::Uuid,
id: uuid::Uuid, username: String, email: String, password_hash: String, friend_id: uuid::Uuid,
display_name: Option<String>, bio: Option<String>, avatar_url: Option<String>, position: i16,
header_url: Option<String>, custom_css: Option<String>, local: bool, id: uuid::Uuid,
ap_id: Option<String>, inbox_url: Option<String>, public_key: Option<String>, username: String,
email: String,
password_hash: String,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
local: bool,
ap_id: Option<String>,
inbox_url: Option<String>,
public_key: Option<String>,
private_key: Option<String>, private_key: Option<String>,
created_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
} }
let rows = sqlx::query_as::<_, Row>( let rows = sqlx::query_as::<_, Row>(
"SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, "SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position,
@@ -36,36 +76,63 @@ impl TopFriendRepository for PgTopFriendRepository {
u.avatar_url, u.header_url, u.custom_css, u.local, u.ap_id, u.inbox_url, 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 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 FROM top_friends tf JOIN users u ON u.id=tf.friend_id
WHERE tf.user_id=$1 ORDER BY tf.position" 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()))?; )
.bind(user_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| { Ok(rows
.into_iter()
.map(|r| {
use domain::value_objects::{Email, PasswordHash, Username}; use domain::value_objects::{Email, PasswordHash, Username};
let tf = TopFriend { user_id: UserId::from_uuid(r.tf_user_id), friend_id: UserId::from_uuid(r.friend_id), position: r.position }; let tf = TopFriend {
user_id: UserId::from_uuid(r.tf_user_id),
friend_id: UserId::from_uuid(r.friend_id),
position: r.position,
};
let u = User { let u = User {
id: UserId::from_uuid(r.id), username: Username::from_trusted(r.username), id: UserId::from_uuid(r.id),
email: Email::from_trusted(r.email), password_hash: PasswordHash(r.password_hash), username: Username::from_trusted(r.username),
display_name: r.display_name, bio: r.bio, avatar_url: r.avatar_url, email: Email::from_trusted(r.email),
header_url: r.header_url, custom_css: r.custom_css, local: r.local, password_hash: PasswordHash(r.password_hash),
ap_id: r.ap_id, inbox_url: r.inbox_url, public_key: r.public_key, display_name: r.display_name,
private_key: r.private_key, created_at: r.created_at, updated_at: r.updated_at, 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, u) (tf, u)
}).collect()) })
.collect())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::{models::user::User, value_objects::*};
use crate::user::PgUserRepository; use crate::user::PgUserRepository;
use domain::ports::UserRepository; use domain::ports::UserRepository;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
let repo = PgUserRepository::new(pool.clone()); let repo = PgUserRepository::new(pool.clone());
let u = User::new_local(UserId::new(), Username::new(username).unwrap(), Email::new(email).unwrap(), PasswordHash("h".into())); let u = User::new_local(
repo.save(&u).await.unwrap(); u UserId::new(),
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
} }
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
@@ -73,7 +140,9 @@ mod tests {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgTopFriendRepository::new(pool); let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap(); repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].0.position, 1); assert_eq!(friends[0].0.position, 1);
@@ -86,8 +155,12 @@ mod tests {
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await; let carol = seed_user(&pool, "carol", "carol@ex.com").await;
let repo = PgTopFriendRepository::new(pool); let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]).await.unwrap(); repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]).await.unwrap(); .await
.unwrap();
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1); assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol"); assert_eq!(friends[0].1.username.as_str(), "carol");

View File

@@ -1,15 +1,21 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::PgPool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{feed::UserSummary, user::User}, models::{feed::UserSummary, user::User},
ports::UserRepository, ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
use sqlx::PgPool;
pub struct PgUserRepository { pool: PgPool } pub struct PgUserRepository {
impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } pool: PgPool,
}
impl PgUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
pub(crate) struct UserRow { pub(crate) struct UserRow {
@@ -120,7 +126,15 @@ impl UserRepository for PgUserRepository {
.map(|_| ()) .map(|_| ())
} }
async fn update_profile(&self, user_id: &UserId, display_name: Option<String>, bio: Option<String>, avatar_url: Option<String>, header_url: Option<String>, custom_css: Option<String>) -> Result<(), DomainError> { async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> {
sqlx::query( 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" "UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1"
) )
@@ -159,13 +173,15 @@ impl UserRepository for PgUserRepository {
LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted' LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted'
WHERE u.local=true WHERE u.local=true
GROUP BY u.id GROUP BY u.id
ORDER BY u.username" ORDER BY u.username",
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| UserSummary { Ok(rows
.into_iter()
.map(|r| UserSummary {
id: UserId::from_uuid(r.id), id: UserId::from_uuid(r.id),
username: r.username, username: r.username,
display_name: r.display_name, display_name: r.display_name,
@@ -174,7 +190,8 @@ impl UserRepository for PgUserRepository {
thought_count: r.thought_count, thought_count: r.thought_count,
follower_count: r.follower_count, follower_count: r.follower_count,
following_count: r.following_count, following_count: r.following_count,
}).collect()) })
.collect())
} }
async fn count(&self) -> Result<i64, DomainError> { async fn count(&self) -> Result<i64, DomainError> {
@@ -208,7 +225,10 @@ mod tests {
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap(); let result = repo
.find_by_username(&Username::new("ghost").unwrap())
.await
.unwrap();
assert!(result.is_none()); assert!(result.is_none());
} }
@@ -222,7 +242,10 @@ mod tests {
PasswordHash("hash".into()), PasswordHash("hash".into()),
); );
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
let found = repo.find_by_email(&Email::new("bob@ex.com").unwrap()).await.unwrap(); let found = repo
.find_by_email(&Email::new("bob@ex.com").unwrap())
.await
.unwrap();
assert!(found.is_some()); assert!(found.is_some());
} }
@@ -236,7 +259,16 @@ mod tests {
PasswordHash("hash".into()), PasswordHash("hash".into()),
); );
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
repo.update_profile(&user.id, Some("Charlie".into()), Some("bio".into()), None, None, None).await.unwrap(); repo.update_profile(
&user.id,
Some("Charlie".into()),
Some("bio".into()),
None,
None,
None,
)
.await
.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie")); assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio")); assert_eq!(found.bio.as_deref(), Some("bio"));

View File

@@ -0,0 +1,350 @@
# Federation Follow-ups Plan
> **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:** Two targeted follow-ups from the federation handler implementation: (1) handle `BoostRemoved``Undo(Announce)` fan-out, which was a known missing feature; (2) extract the repeated follower-filtering block in `ActivityPubService` into a private helper to eliminate duplication across 6 broadcast methods.
**Architecture:** Both changes are additive and self-contained. Task 1 touches `domain/ports.rs`, `activitypub-base/src/service.rs`, and `application/src/services/federation_event.rs`. Task 2 touches only `activitypub-base/src/service.rs`.
---
## File Map
```
Task 1:
Modify: crates/domain/src/ports.rs ← add broadcast_undo_announce to OutboundFederationPort
Modify: crates/adapters/activitypub-base/src/service.rs ← broadcast_undo_announce_to_followers + impl
Modify: crates/application/src/services/federation_event.rs ← handle BoostRemoved + tests
Task 2:
Modify: crates/adapters/activitypub-base/src/service.rs ← extract accepted_follower_inboxes helper
```
---
### Task 1: BoostRemoved → Undo(Announce)
**Files:**
- Modify: `crates/domain/src/ports.rs`
- Modify: `crates/adapters/activitypub-base/src/service.rs`
- Modify: `crates/application/src/services/federation_event.rs`
#### Step A: Add `broadcast_undo_announce` to `OutboundFederationPort`
- [ ] In `crates/domain/src/ports.rs`, add one method to `OutboundFederationPort` after `broadcast_announce`:
```rust
/// Fan out an Undo(Announce) to followers when a boost is removed.
async fn broadcast_undo_announce(
&self,
booster_user_id: &UserId,
object_ap_id: &str,
) -> Result<(), DomainError>;
```
- [ ] **Run:** `cargo check -p domain` — Expected: error in activitypub-base (trait impl missing method). This is expected.
#### Step B: Add `broadcast_undo_announce_to_followers` to `ActivityPubService` and implement the port method
- [ ] In `crates/adapters/activitypub-base/src/service.rs`, add `broadcast_undo_announce_to_followers` to `impl ActivityPubService` — insert after `broadcast_announce_to_followers`:
```rust
/// Fan out an Undo(Announce) activity to all accepted followers.
pub async fn broadcast_undo_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(());
}
let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let undo = crate::activities::UndoActivity {
id: undo_id,
kind: Default::default(),
actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({
"type": "Announce",
"actor": local_actor.ap_id.to_string(),
"object": object_ap_id.to_string(),
}),
};
let inboxes = collect_inboxes(&accepted);
let sends = activitypub_federation::activity_sending::SendActivityTask::prepare(
&activitypub_federation::protocol::context::WithContext::new_default(undo),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "some Undo(Announce) deliveries failed");
}
Ok(())
}
```
- [ ] Add `broadcast_undo_announce` to the `impl domain::ports::OutboundFederationPort for ActivityPubService` block:
```rust
async fn broadcast_undo_announce(
&self,
booster_user_id: &domain::value_objects::UserId,
object_ap_id: &str,
) -> Result<(), domain::errors::DomainError> {
let user_uuid = booster_user_id.as_uuid();
let ap_id = url::Url::parse(object_ap_id)
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))?;
self.broadcast_undo_announce_to_followers(user_uuid, ap_id)
.await
.map_err(|e| domain::errors::DomainError::Internal(e.to_string()))
}
```
- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors.
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
#### Step C: Handle `BoostRemoved` in `FederationEventService`
- [ ] **Write failing test** first — add to the `#[cfg(test)] mod tests` block in `crates/application/src/services/federation_event.rs`:
```rust
#[tokio::test]
async fn boost_removed_sends_undo_announce_for_local_thought() {
let store = TestStore::default();
let alice = alice();
let thought = local_thought(alice.id.clone()); // ap_id = None → constructed URL
store.thoughts.lock().unwrap().push(thought.clone());
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
.process(&DomainEvent::BoostRemoved {
user_id: alice.id.clone(),
thought_id: thought.id.clone(),
})
.await
.unwrap();
let announced = spy.announced.lock().unwrap();
assert_eq!(announced.len(), 1);
assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id));
}
#[tokio::test]
async fn boost_removed_sends_undo_announce_for_remote_thought() {
let store = TestStore::default();
let alice = alice();
let mut thought = local_thought(alice.id.clone());
thought.local = false;
thought.ap_id = Some("https://mastodon.social/users/bob/statuses/456".into());
store.thoughts.lock().unwrap().push(thought.clone());
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
.process(&DomainEvent::BoostRemoved {
user_id: alice.id.clone(),
thought_id: thought.id.clone(),
})
.await
.unwrap();
let announced = spy.announced.lock().unwrap();
assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/456");
}
```
NOTE: The `SpyPort` tracks `broadcast_undo_announce` calls in the same `announced` vec as `broadcast_announce` (or a new `undo_announced` vec — your choice, but be consistent in both the spy and the assertions).
Actually, use a separate `undo_announced` vec for clarity:
```rust
#[derive(Default)]
struct SpyPort {
created: Mutex<Vec<ThoughtId>>,
deleted: Mutex<Vec<String>>,
updated: Mutex<Vec<ThoughtId>>,
announced: Mutex<Vec<String>>,
undo_announced: Mutex<Vec<String>>,
}
```
And add the impl method:
```rust
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
```
Update the test assertions to use `spy.undo_announced`.
- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: 2 new tests FAIL (not implemented).
- [ ] **Add `BoostRemoved` arm** to `FederationEventService::process` — insert after the `BoostAdded` arm:
```rust
DomainEvent::BoostRemoved { user_id, thought_id } => {
let thought = match self.thoughts.find_by_id(thought_id).await? {
Some(t) => t,
None => return Ok(()),
};
let object_ap_id = thought.ap_id.clone().unwrap_or_else(|| {
format!("{}/thoughts/{}", self.base_url, thought_id)
});
self.ap.broadcast_undo_announce(user_id, &object_ap_id).await
}
```
- [ ] **Run:** `cargo test -p application -- services::federation_event` — Expected: all tests pass (now 13).
- [ ] **Run:** `cargo test --workspace` — Expected: only pre-existing postgres DB failures (require live database).
- [ ] **Commit:**
```bash
git add crates/domain/src/ports.rs crates/adapters/activitypub-base/src/service.rs crates/application/src/services/federation_event.rs
git commit -m "feat: BoostRemoved → Undo(Announce) fan-out via OutboundFederationPort"
```
---
### Task 2: Follower-filtering DRY extraction in activitypub-base
**Files:**
- Modify: `crates/adapters/activitypub-base/src/service.rs`
The repeated 20-line follower-filtering block appears in 7 methods. Extract it into a private async helper, then call it from the 6 content-broadcast methods. Leave `broadcast_actor_update` alone — it uses different filtering (no blocked-actor/domain check).
**Methods to update:** `broadcast_to_followers`, `broadcast_delete_to_followers`, `broadcast_update_to_followers`, `broadcast_add_to_followers`, `broadcast_undo_add_to_followers`, `broadcast_announce_to_followers`, `broadcast_undo_announce_to_followers`.
**Leave unchanged:** `broadcast_actor_update` (filters only on `FollowerStatus::Accepted`, no blocked checks).
- [ ] **Add private helper** to `impl ActivityPubService` — insert near the top of the impl block, after `request_data`:
```rust
/// Returns `(local_actor, deduplicated_inboxes)` for all accepted followers,
/// excluding blocked actors and blocked domains. Returns `None` if there are
/// no eligible followers (caller should early-return `Ok(())`).
async fn accepted_follower_inboxes(
&self,
data: &activitypub_federation::config::Data<FederationData>,
local_user_id: uuid::Uuid,
) -> anyhow::Result<Option<(crate::actors::DbActor, Vec<Url>)>> {
let local_actor = get_local_actor(local_user_id, data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect();
if accepted.is_empty() {
return Ok(None);
}
Ok(Some((local_actor, collect_inboxes(&accepted))))
}
```
- [ ] **Refactor each of the 7 methods** to use `accepted_follower_inboxes`.
For each method, replace the block that:
1. Gets `local_actor`
2. Gets followers + filtered inboxes
with:
```rust
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else {
return Ok(());
};
```
Then use `local_actor` and `inboxes` directly in the activity construction (same as before).
The 7 methods are at these line numbers (before refactor — check actual lines in the file):
- `broadcast_announce_to_followers`
- `broadcast_undo_announce_to_followers` (just added in Task 1)
- `broadcast_to_followers`
- `broadcast_delete_to_followers`
- `broadcast_update_to_followers`
- `broadcast_add_to_followers`
- `broadcast_undo_add_to_followers`
- [ ] **Run:** `cargo check -p activitypub-base` — Expected: no errors.
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
- [ ] **Run:** `cargo test --workspace` — Expected: same result as before (pre-existing postgres failures only).
- [ ] **Commit:**
```bash
git add crates/adapters/activitypub-base/src/service.rs
git commit -m "refactor(activitypub-base): extract accepted_follower_inboxes helper — eliminate 7x duplicated filtering block"
```
---
## Self-Review
**Spec coverage:**
-`broadcast_undo_announce` added to `OutboundFederationPort` (Task 1)
-`broadcast_undo_announce_to_followers` sends `Undo { object: { type: "Announce", actor, object } }` to accepted, non-blocked followers (Task 1)
-`FederationEventService` handles `BoostRemoved` with same ap_id construction as `BoostAdded` (Task 1)
- ✅ 2 tests: local thought URL constructed, remote thought uses ap_id (Task 1)
-`SpyPort` has separate `undo_announced` vec (Task 1)
-`accepted_follower_inboxes` helper extracts the 20-line filtering block (Task 2)
- ✅ Helper used in 7 content-broadcast methods (Task 2)
-`broadcast_actor_update` NOT touched — it uses different filtering (Task 2)
**Placeholder scan:** None.
**Type consistency:**
- `UndoActivity` is already defined in `activities.rs` with `object: serde_json::Value` — no new activity type needed
- `broadcast_undo_announce_to_followers(uuid::Uuid, url::Url)` — same signature pattern as `broadcast_announce_to_followers`
- `accepted_follower_inboxes` returns `Option<(DbActor, Vec<Url>)>` — caller destructures with `let Some(...) = ... else { return Ok(()) }`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,562 @@
# Merge Readiness Plan
> **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:** Close the remaining gaps between v2 and v1 so the new Rust backend can replace the old one. Five tasks: fix feed response hydration, wire missing follower/following routes, add user listing endpoints, add popular tags, harden config (HOST, CORS, rate limiting).
**Architecture:** All changes are in `presentation`, `domain/ports`, `adapters/postgres`, and `bootstrap`. No changes to `application` or `worker`.
---
## File Map
```
Task 1 — Feed hydration:
Modify: crates/presentation/src/handlers/feed.rs ← add to_thought_response helper, fix 4 handlers
Modify: crates/presentation/src/handlers/auth.rs ← move/export to_feed_entry helper if needed
Task 2 — Wire follower/following routes:
Modify: crates/presentation/src/routes.rs ← add 2 routes
Task 3 — User listing + count:
Modify: crates/domain/src/ports.rs ← add count() to UserRepository
Modify: crates/adapters/postgres/src/user.rs ← implement count()
Modify: crates/domain/src/testing.rs ← add count() to TestStore
Modify: crates/presentation/src/handlers/users.rs ← add get_users, get_user_count handlers
Modify: crates/presentation/src/routes.rs ← add 2 routes
Task 4 — Popular tags:
Modify: crates/domain/src/ports.rs ← add popular_tags() to TagRepository
Modify: crates/adapters/postgres/src/tag.rs ← implement popular_tags()
Modify: crates/domain/src/testing.rs ← add popular_tags() to TestStore
Modify: crates/presentation/src/handlers/feed.rs ← add get_popular_tags handler
Modify: crates/presentation/src/routes.rs ← add 1 route (before /tags/{name})
Task 5 — Config: HOST, CORS_ORIGINS, RATE_LIMIT:
Modify: crates/bootstrap/src/config.rs ← 3 new fields
Modify: crates/bootstrap/src/main.rs ← use HOST, CORS layer, rate limit layer
Modify: crates/bootstrap/Cargo.toml ← add tower-governor
Modify: .env.example ← document new vars
```
---
### Task 1: Fix feed response hydration
**Files:**
- Modify: `crates/presentation/src/handlers/feed.rs`
**Problem:** `home_feed` and `public_feed` return only UUIDs. `user_thoughts_handler` and `tag_thoughts_handler` are missing `author`, `in_reply_to_id`, `sensitive`, `content_warning`, viewer flags. All four need to use `ThoughtResponse`.
The `ThoughtResponse` DTO in `api-types` already has every needed field. `FeedEntry` in domain already carries `like_count`, `boost_count`, `reply_count`, `liked_by_viewer`, `boosted_by_viewer`. The conversion is straightforward.
- [ ] **Add `to_thought_response` helper** at the top of `feed.rs` (after existing imports). This is a private free function:
```rust
use api_types::responses::ThoughtResponse;
fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse {
ThoughtResponse {
id: e.thought.id.as_uuid(),
content: e.thought.content.as_str().to_string(),
author: crate::handlers::auth::to_user_response(&e.author),
in_reply_to_id: e.thought.in_reply_to_id.as_ref().map(|id| id.as_uuid()),
visibility: e.thought.visibility.as_str().to_string(),
content_warning: e.thought.content_warning.clone(),
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,
}
}
```
- [ ] **Fix `home_feed`** — replace the UUID-only mapping:
```rust
pub async fn home_feed(
State(s): State<AppState>,
AuthUser(uid): AuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
```
- [ ] **Fix `public_feed`** — same pattern:
```rust
pub async fn public_feed(
State(s): State<AppState>,
OptionalAuthUser(viewer): OptionalAuthUser,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, 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.iter().map(to_thought_response).collect::<Vec<_>>(),
"total": result.total,
"page": result.page,
"per_page": result.per_page,
})))
}
```
- [ ] **Fix `user_thoughts_handler`** — replace the partial mapping with `to_thought_response`:
```rust
pub async fn user_thoughts_handler(
State(s): State<AppState>,
Path(username): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let user = get_user_by_username(&*s.users, &username).await?;
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_user_feed(&*s.thoughts, &user.id, page).await?;
Ok(Json(serde_json::json!({
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
```
- [ ] **Fix `tag_thoughts_handler`** — same:
```rust
pub async fn tag_thoughts_handler(
State(s): State<AppState>,
Path(tag_name): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<serde_json::Value>, ApiError> {
let page = PageParams { page: q.page(), per_page: q.per_page() };
let result = get_by_tag(&*s.tags, &tag_name, page).await?;
Ok(Json(serde_json::json!({
"tag": tag_name,
"total": result.total,
"page": result.page,
"per_page": result.per_page,
"items": result.items.iter().map(to_thought_response).collect::<Vec<_>>(),
})))
}
```
NOTE: `get_by_tag` returns `Paginated<Thought>`, not `Paginated<FeedEntry>` — it won't have author or counts. Check the use case signature. If it returns `Paginated<Thought>`, map manually keeping available fields only (id, content, visibility, dates). If it returns `Paginated<FeedEntry>`, use `to_thought_response`.
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
- [ ] **Commit:**
```bash
git add crates/presentation/src/handlers/feed.rs
git commit -m "fix(presentation): hydrate feed responses with full ThoughtResponse — remove UUID-only stubs"
```
---
### Task 2: Wire follower/following REST routes
**Files:**
- Modify: `crates/presentation/src/routes.rs`
`get_followers_handler` and `get_following_handler` already exist in `feed.rs` (lines 7580). The AP routes own `/users/{username}/followers` and `/users/{username}/following`. Wire the REST handlers at non-conflicting paths:
- [ ] **Add two routes to `api_routes`** in `routes.rs`, in the users section (before `/thoughts`):
```rust
.route("/users/{username}/follower-list", get(feed::get_followers_handler))
.route("/users/{username}/following-list", get(feed::get_following_handler))
```
- [ ] **Run:** `cargo check -p presentation` — Expected: no errors.
- [ ] **Commit:**
```bash
git add crates/presentation/src/routes.rs
git commit -m "feat(presentation): wire GET /users/{username}/follower-list and /following-list"
```
---
### Task 3: User listing + count
**Files:**
- Modify: `crates/domain/src/ports.rs`
- Modify: `crates/adapters/postgres/src/user.rs`
- Modify: `crates/domain/src/testing.rs`
- Modify: `crates/presentation/src/handlers/users.rs`
- Modify: `crates/presentation/src/routes.rs`
- [ ] **Add `count()` to `UserRepository`** in `crates/domain/src/ports.rs`:
```rust
async fn count(&self) -> Result<i64, DomainError>;
```
- [ ] **Implement `count()` in postgres** — find `impl UserRepository for PgUserRepository` in `crates/adapters/postgres/src/user.rs` and add:
```rust
async fn count(&self) -> Result<i64, DomainError> {
let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE local = true")
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(row)
}
```
- [ ] **Implement `count()` in TestStore** in `crates/domain/src/testing.rs`:
```rust
async fn count(&self) -> Result<i64, DomainError> {
Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64)
}
```
- [ ] **Add handlers to `crates/presentation/src/handlers/users.rs`:**
```rust
use domain::models::feed::UserSummary;
#[utoipa::path(
get, path = "/users",
params(
("q" = Option<String>, Query, description = "Search query"),
PaginationQuery,
),
responses((status = 200, description = "User list"))
)]
pub async fn get_users(
State(s): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
use domain::models::feed::PageParams;
let page = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1u64);
let per_page = params.get("per_page").and_then(|v| v.parse().ok()).unwrap_or(20u64);
let page_params = PageParams { page, per_page };
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
let result = s.search.search_users(q, &page_params).await?;
let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page })));
}
let all = s.users.list_with_stats().await?;
let total = all.len() as i64;
let start = ((page - 1) * per_page) as usize;
let items: Vec<_> = all.into_iter().skip(start).take(per_page as usize)
.map(|u| serde_json::json!({
"id": u.id.as_uuid(),
"username": u.username,
"display_name": u.display_name,
"avatar_url": u.avatar_url,
"bio": u.bio,
"thought_count": u.thought_count,
"follower_count": u.follower_count,
"following_count": u.following_count,
}))
.collect();
Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page })))
}
#[utoipa::path(
get, path = "/users/count",
responses((status = 200, description = "Local user count"))
)]
pub async fn get_user_count(
State(s): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> {
let count = s.users.count().await?;
Ok(Json(serde_json::json!({ "count": count })))
}
```
Note: `get_users` needs `use api_types::requests::PaginationQuery;` added to imports if not already there. Check the file's existing imports.
- [ ] **Add routes to `routes.rs`** — add BEFORE `/users/me` (static paths must come before parameterised):
```rust
.route("/users", get(users::get_users))
.route("/users/count", get(users::get_user_count))
```
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
- [ ] **Commit:**
```bash
git add crates/domain/src/ports.rs \
crates/adapters/postgres/src/user.rs \
crates/domain/src/testing.rs \
crates/presentation/src/handlers/users.rs \
crates/presentation/src/routes.rs
git commit -m "feat: GET /users (search/list) and GET /users/count"
```
---
### Task 4: Popular tags
**Files:**
- Modify: `crates/domain/src/ports.rs`
- Modify: `crates/adapters/postgres/src/tag.rs`
- Modify: `crates/domain/src/testing.rs`
- Modify: `crates/presentation/src/handlers/feed.rs`
- Modify: `crates/presentation/src/routes.rs`
- [ ] **Add `popular_tags()` to `TagRepository`** in `crates/domain/src/ports.rs`:
```rust
/// Returns (tag_name, thought_count) pairs, most-used first.
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError>;
```
- [ ] **Implement `popular_tags()` in postgres** — find `impl TagRepository for PgTagRepository` in `crates/adapters/postgres/src/tag.rs` and add:
```rust
async fn popular_tags(&self, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
let rows = sqlx::query_as::<_, (String, i64)>(
"SELECT t.name, COUNT(tt.thought_id) AS thought_count
FROM tags t
JOIN thought_tags tt ON t.id = tt.tag_id
GROUP BY t.id, t.name
ORDER BY thought_count DESC
LIMIT $1"
)
.bind(limit as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(rows)
}
```
- [ ] **Implement `popular_tags()` in TestStore** in `crates/domain/src/testing.rs`:
```rust
async fn popular_tags(&self, _limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
Ok(vec![])
}
```
- [ ] **Add `get_popular_tags` handler** to `crates/presentation/src/handlers/feed.rs`:
```rust
pub async fn get_popular_tags(
State(s): State<AppState>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<serde_json::Value>, ApiError> {
let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
let tags = s.tags.popular_tags(limit.min(100)).await?;
Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name,
"thought_count": count,
})).collect::<Vec<_>>()
})))
}
```
- [ ] **Wire `GET /tags/popular` in `routes.rs`** — add BEFORE `/tags/{name}` (otherwise `popular` is captured as the `{name}` param):
```rust
.route("/tags/popular", get(feed::get_popular_tags))
.route("/tags/{name}", get(feed::tag_thoughts_handler))
```
The existing `.route("/tags/{name}", ...)` line can stay — just add the popular route immediately before it.
- [ ] **Run:** `cargo check --workspace` — Expected: no errors.
- [ ] **Run unit tests:** `cargo test --workspace --exclude postgres --exclude postgres-federation --exclude postgres-search` — Expected: all pass.
- [ ] **Commit:**
```bash
git add crates/domain/src/ports.rs \
crates/adapters/postgres/src/tag.rs \
crates/domain/src/testing.rs \
crates/presentation/src/handlers/feed.rs \
crates/presentation/src/routes.rs
git commit -m "feat: GET /tags/popular — top tags by usage count"
```
---
### Task 5: Config — HOST, CORS_ORIGINS, RATE_LIMIT
**Files:**
- Modify: `crates/bootstrap/src/config.rs`
- Modify: `crates/bootstrap/src/main.rs`
- Modify: `crates/bootstrap/Cargo.toml`
- Modify: `.env.example`
- [ ] **Add `tower-governor` to `crates/bootstrap/Cargo.toml`:**
```toml
tower-governor = "0.6"
```
- [ ] **Add three fields to `Config` in `crates/bootstrap/src/config.rs`:**
```rust
pub struct Config {
pub database_url: String,
pub jwt_secret: String,
pub base_url: String,
pub nats_url: Option<String>,
pub port: u16,
pub host: String,
pub allow_registration: bool,
pub debug: bool,
/// Comma-separated allowed origins, or "*" for permissive. Default: "*".
pub cors_origins: String,
/// Max requests per minute per IP. None = disabled.
pub rate_limit: Option<u32>,
}
```
In `from_env()` add:
```rust
host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()),
cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()),
rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()),
```
- [ ] **Update `crates/bootstrap/src/main.rs`:**
```rust
mod config;
mod factory;
use std::sync::Arc;
use http::HeaderValue;
use tower_http::cors::{AllowOrigin, CorsLayer};
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() {
let cfg = config::Config::from_env();
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let infra = factory::build(&cfg).await;
// CORS
let cors = if cfg.cors_origins.trim() == "*" {
CorsLayer::permissive()
} else {
let origins: Vec<HeaderValue> = cfg.cors_origins
.split(',')
.map(|o| o.trim())
.filter_map(|o| o.parse().ok())
.collect();
CorsLayer::new()
.allow_origin(AllowOrigin::list(origins))
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
};
let app = presentation::routes::router(&infra.fed_config)
.with_state(infra.state)
.layer(cors);
// Rate limiting (optional)
let app = if let Some(rate_limit) = cfg.rate_limit {
use tower_governor::{GovernorLayer, GovernorConfigBuilder};
let governor_config = Arc::new(
GovernorConfigBuilder::default()
.per_millisecond(60_000 / rate_limit as u64)
.burst_size(rate_limit)
.use_headers()
.finish()
.expect("valid rate limit config"),
);
let limiter = governor_config.limiter().clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
loop {
interval.tick().await;
limiter.retain_recent();
}
});
app.layer(GovernorLayer { config: governor_config })
} else {
app
};
let addr = format!("{}:{}", cfg.host, cfg.port);
tracing::info!("Listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
```
Note: `tower-governor`'s `GovernorLayer` API may differ slightly — check the actual 0.6.x docs and adjust. The `GovernorConfigBuilder` might use `.per_second()` instead of `.per_millisecond()`. Verify and use whichever method produces the desired requests-per-minute rate.
Note 2: Axum `Router::layer` returns the same type when adding a standard layer. `GovernorLayer` returns a different type. If the type system complains, wrap the app in `tower::ServiceBuilder` or use `.layer(tower::ServiceBuilder::new().layer(GovernorLayer { ... }).into_inner())`.
- [ ] **Update `.env.example`** — add the three new vars:
```env
# Optional
HOST=0.0.0.0
PORT=3000
ALLOW_REGISTRATION=true
RUST_ENV=development
# CORS — comma-separated origins, or * for permissive (default: *)
CORS_ORIGINS=*
# CORS_ORIGINS=https://your-nextjs-app.example.com
# Rate limiting — max requests per minute per IP (disabled by default)
# RATE_LIMIT=60
```
- [ ] **Run:** `cargo check -p bootstrap` — Expected: no errors (fix tower-governor API if needed).
- [ ] **Commit:**
```bash
git add crates/bootstrap/ .env.example
git commit -m "feat(bootstrap): configurable HOST, CORS_ORIGINS, and optional rate limiting"
```
---
## Self-Review
**Spec coverage:**
-`home_feed` / `public_feed` return full `ThoughtResponse` (Task 1)
-`user_thoughts_handler` / `tag_thoughts_handler` use `to_thought_response` (Task 1)
-`GET /users/{username}/follower-list` and `/following-list` wired (Task 2)
-`GET /users` (search + list) + `GET /users/count` (Task 3)
-`UserRepository::count()` in port + postgres + TestStore (Task 3)
-`GET /tags/popular` wired before `/tags/{name}` (Task 4)
-`TagRepository::popular_tags()` in port + postgres + TestStore (Task 4)
-`HOST`, `CORS_ORIGINS`, `RATE_LIMIT` in Config (Task 5)
- ✅ CORS layer uses configured origins (Task 5)
- ✅ Rate limiting via tower-governor, disabled by default (Task 5)
**Placeholder scan:** None.
**Type consistency:**
- `to_thought_response` maps `FeedEntry``ThoughtResponse` — both types confirmed in source
- `tag_thoughts_handler` uses `get_by_tag` which returns `Paginated<Thought>` — verify whether it returns `Thought` or `FeedEntry` and adjust the mapping accordingly
- `popular_tags()` returns `Vec<(String, i64)>` — matches the SQL query's two columns
- `GovernorLayer` API — implementer must verify against installed tower-governor version