feat: extract and save hashtags on thought creation
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 9m18s
test / unit (pull_request) Successful in 16m4s
test / integration (pull_request) Failing after 17m16s

This commit is contained in:
2026-05-14 18:01:07 +02:00
parent 5c9acdecc1
commit 24bfda8458
2 changed files with 67 additions and 14 deletions

View File

@@ -2,10 +2,34 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::thought::{Thought, Visibility}, models::thought::{Thought, Visibility},
ports::{EventPublisher, ThoughtRepository, UserRepository}, ports::{EventPublisher, TagRepository, ThoughtRepository, UserRepository},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
fn extract_hashtags(content: &str) -> Vec<String> {
let mut tags = Vec::new();
let mut chars = content.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c == '#'
&& chars
.peek()
.map(|(_, nc)| nc.is_alphanumeric())
.unwrap_or(false)
{
let tag: String = chars
.by_ref()
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
.map(|(_, nc)| nc)
.collect();
if !tag.is_empty() {
tags.push(tag.to_lowercase());
}
}
}
tags.dedup();
tags
}
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> { fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
if thought.user_id != *user_id { if thought.user_id != *user_id {
return Err(DomainError::NotFound); return Err(DomainError::NotFound);
@@ -28,6 +52,7 @@ pub struct CreateThoughtOutput {
pub async fn create_thought( pub async fn create_thought(
thoughts: &dyn ThoughtRepository, thoughts: &dyn ThoughtRepository,
_users: &dyn UserRepository, _users: &dyn UserRepository,
tags: &dyn TagRepository,
events: &dyn EventPublisher, events: &dyn EventPublisher,
input: CreateThoughtInput, input: CreateThoughtInput,
) -> Result<CreateThoughtOutput, DomainError> { ) -> Result<CreateThoughtOutput, DomainError> {
@@ -40,13 +65,21 @@ pub async fn create_thought(
let thought = Thought::new_local( let thought = Thought::new_local(
ThoughtId::new(), ThoughtId::new(),
input.user_id, input.user_id,
content, content.clone(),
input.in_reply_to_id.clone(), input.in_reply_to_id.clone(),
visibility, visibility,
input.content_warning, input.content_warning,
input.sensitive, input.sensitive,
); );
thoughts.save(&thought).await?; thoughts.save(&thought).await?;
// Extract and attach hashtags from content.
for tag_name in extract_hashtags(content.as_str()) {
if let Ok(tag) = tags.find_or_create(&tag_name).await {
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
}
}
events events
.publish(&DomainEvent::ThoughtCreated { .publish(&DomainEvent::ThoughtCreated {
thought_id: thought.id.clone(), thought_id: thought.id.clone(),
@@ -149,7 +182,7 @@ mod tests {
let store = TestStore::default(); let store = TestStore::default();
let u = user(); let u = user();
store.users.lock().unwrap().push(u.clone()); store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, input(u.id.clone())) let out = create_thought(&store, &store, &store, &store, input(u.id.clone()))
.await .await
.unwrap(); .unwrap();
assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(out.thought.content.as_str(), "hello");
@@ -161,7 +194,13 @@ mod tests {
let store = TestStore::default(); let store = TestStore::default();
let u = user(); let u = user();
store.users.lock().unwrap().push(u.clone()); store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
input(u.id.clone()),
)
.await .await
.unwrap(); .unwrap();
delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id) delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id)
@@ -185,7 +224,13 @@ mod tests {
.lock() .lock()
.unwrap() .unwrap()
.extend([alice.clone(), bob.clone()]); .extend([alice.clone(), bob.clone()]);
let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
input(alice.id.clone()),
)
.await .await
.unwrap(); .unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id) let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id)
@@ -199,7 +244,7 @@ mod tests {
let store = TestStore::default(); let store = TestStore::default();
let alice = user(); let alice = user();
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, input(alice.id.clone())) let out = create_thought(&store, &store, &store, &store, input(alice.id.clone()))
.await .await
.unwrap(); .unwrap();
let tid = out.thought.id.clone(); let tid = out.thought.id.clone();
@@ -229,12 +274,19 @@ mod tests {
let store = TestStore::default(); let store = TestStore::default();
let alice = user(); let alice = user();
store.users.lock().unwrap().push(alice.clone()); store.users.lock().unwrap().push(alice.clone());
let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) let original = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
input(alice.id.clone()),
)
.await .await
.unwrap() .unwrap()
.thought; .thought;
create_thought( create_thought(
&store,
&store, &store,
&store, &store,
&NoOpEventPublisher, &NoOpEventPublisher,

View File

@@ -64,6 +64,7 @@ pub async fn post_thought(
let out = create_thought( let out = create_thought(
&*s.thoughts, &*s.thoughts,
&*s.users, &*s.users,
&*s.tags,
&*s.events, &*s.events,
CreateThoughtInput { CreateThoughtInput {
user_id: uid.clone(), user_id: uid.clone(),