use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::sea_query::OnConflict;
use sea_orm::{ActiveValue::Set, ColumnTrait, Condition, EntityTrait};
use time::OffsetDateTime;
use toolkit_db::secure::{
AccessScope, SecureDeleteExt, SecureEntityExt, SecureInsertExt, TxConfig,
};
use uuid::Uuid;
use crate::domain::error::ChatEngineError;
use crate::domain::ports::{ReactionDeleteOutcome, ReactionRepo, ReactionUpsertOutcome};
use crate::domain::reaction::{MessageReaction, ReactionType};
use crate::infra::db::entity::message_reaction::{
self as reaction_entity, Column as ReactionColumn, Entity as ReactionEntity,
};
use crate::infra::db::repo::ChatEngineDb;
pub struct SeaReactionRepo {
db: Arc<ChatEngineDb>,
}
impl SeaReactionRepo {
#[must_use]
pub fn new(db: Arc<ChatEngineDb>) -> Self {
Self { db }
}
}
#[async_trait]
impl ReactionRepo for SeaReactionRepo {
async fn get_by_pk(
&self,
message_id: Uuid,
user_id: &str,
) -> Result<Option<MessageReaction>, ChatEngineError> {
let conn = self.db.conn()?;
let scope = AccessScope::allow_all();
let row = ReactionEntity::find_by_id((message_id, user_id.to_owned()))
.secure()
.scope_with(&scope)
.one(&conn)
.await?;
Ok(row.map(MessageReaction::from))
}
async fn upsert(
&self,
message_id: Uuid,
user_id: &str,
reaction_type: ReactionType,
) -> Result<ReactionUpsertOutcome, ChatEngineError> {
debug_assert!(
reaction_type.is_persisted(),
"upsert called with ReactionType::None \u{2014} caller must route to delete"
);
let user_id_owned = user_id.to_owned();
let stored_value = reaction_type.as_str().to_owned();
let now = OffsetDateTime::now_utc();
let (prev_row, after_row) = self
.db
.transaction_with_config::<(Option<reaction_entity::Model>, reaction_entity::Model), _>(
TxConfig::serializable(),
move |tx| {
Box::pin(async move {
let scope = AccessScope::allow_all();
let previous =
ReactionEntity::find_by_id((message_id, user_id_owned.clone()))
.secure()
.scope_with(&scope)
.one(tx)
.await?;
let am = reaction_entity::ActiveModel {
message_id: Set(message_id),
user_id: Set(user_id_owned.clone()),
reaction_type: Set(stored_value),
created_at: Set(now),
updated_at: Set(now),
};
let on_conflict = OnConflict::columns([
ReactionColumn::MessageId,
ReactionColumn::UserId,
])
.update_columns([ReactionColumn::ReactionType, ReactionColumn::UpdatedAt])
.to_owned();
ReactionEntity::insert(am)
.secure()
.scope_unchecked(&scope)?
.on_conflict_raw(on_conflict)
.exec(tx)
.await?;
let after = ReactionEntity::find_by_id((message_id, user_id_owned))
.secure()
.scope_with(&scope)
.one(tx)
.await?
.ok_or_else(|| {
ChatEngineError::internal(
"post-upsert read returned no row (race?)",
)
})?;
Ok((previous, after))
})
},
)
.await?;
let previous_reaction_type = prev_row
.as_ref()
.and_then(|m| ReactionType::from_str_value(&m.reaction_type));
Ok(ReactionUpsertOutcome {
reaction: MessageReaction::from(after_row),
previous_reaction_type,
})
}
async fn delete(
&self,
message_id: Uuid,
user_id: &str,
) -> Result<ReactionDeleteOutcome, ChatEngineError> {
let conn = self.db.conn()?;
let scope = AccessScope::allow_all();
let prior = ReactionEntity::find_by_id((message_id, user_id.to_owned()))
.secure()
.scope_with(&scope)
.one(&conn)
.await?;
let previous_reaction_type = prior
.as_ref()
.and_then(|m| ReactionType::from_str_value(&m.reaction_type));
let res = ReactionEntity::delete_many()
.secure()
.scope_with(&scope)
.filter(
Condition::all()
.add(ReactionColumn::MessageId.eq(message_id))
.add(ReactionColumn::UserId.eq(user_id.to_owned())),
)
.exec(&conn)
.await?;
Ok(ReactionDeleteOutcome {
applied: res.rows_affected > 0,
previous_reaction_type,
})
}
async fn list_by_message(
&self,
message_id: Uuid,
) -> Result<Vec<MessageReaction>, ChatEngineError> {
let conn = self.db.conn()?;
let scope = AccessScope::allow_all();
let rows = ReactionEntity::find()
.secure()
.scope_with(&scope)
.filter(Condition::all().add(ReactionColumn::MessageId.eq(message_id)))
.all(&conn)
.await?;
Ok(rows.into_iter().map(MessageReaction::from).collect())
}
}
#[cfg(test)]
#[path = "reaction_repo_tests.rs"]
mod reaction_repo_tests;