//! The comment store: the rows behind a ticket's or patch proposal's
//! comments, and the lookups the webui makes of them.
//!
//! Split from [`super`], which holds the rules that do not need a database.
use std::collections::HashMap;
use sea_orm::ActiveValue::Set;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, DbErr, EntityTrait, QueryFilter, QueryOrder,
};
use uuid::Uuid;
use crate::entities::{comments, users};
/// The comments on a ticket, in the order they were posted.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn list_for_ticket(
db: &DatabaseConnection,
ticket_id: i64,
) -> Result<Vec<comments::Model>, DbErr> {
comments::Entity::find()
.filter(comments::Column::TicketId.eq(ticket_id))
.order_by_asc(comments::Column::CreatedAt)
.all(db)
.await
}
/// The comments on a patch proposal, in the order they were posted.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn list_for_patch(
db: &DatabaseConnection,
patch_proposal_id: i64,
) -> Result<Vec<comments::Model>, DbErr> {
comments::Entity::find()
.filter(comments::Column::PatchProposalId.eq(patch_proposal_id))
.order_by_asc(comments::Column::CreatedAt)
.all(db)
.await
}
/// One comment, addressed by the UUID it was given.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn find_by_uuid(
db: &DatabaseConnection,
uuid: Uuid,
) -> Result<Option<comments::Model>, DbErr> {
comments::Entity::find()
.filter(comments::Column::Uuid.eq(uuid))
.one(db)
.await
}
/// A new comment on `ticket_id` by `author_id`.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn create_on_ticket(
db: &DatabaseConnection,
author_id: i64,
ticket_id: i64,
content: &str,
) -> Result<comments::Model, DbErr> {
comments::ActiveModel {
uuid: Set(Uuid::new_v4()),
author_id: Set(Some(author_id)),
remote_actor_id: Set(None),
remote_object_id: Set(None),
ticket_id: Set(Some(ticket_id)),
patch_proposal_id: Set(None),
content: Set(content.to_string()),
content_format: Set("markdown".to_string()),
..Default::default()
}
.insert(db)
.await
}
/// A new comment on `patch_proposal_id` by `author_id`.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn create_on_patch(
db: &DatabaseConnection,
author_id: i64,
patch_proposal_id: i64,
content: &str,
) -> Result<comments::Model, DbErr> {
comments::ActiveModel {
uuid: Set(Uuid::new_v4()),
author_id: Set(Some(author_id)),
remote_actor_id: Set(None),
remote_object_id: Set(None),
ticket_id: Set(None),
patch_proposal_id: Set(Some(patch_proposal_id)),
content: Set(content.to_string()),
content_format: Set("markdown".to_string()),
..Default::default()
}
.insert(db)
.await
}
/// A new comment on a ticket from a remote actor (inbound federation).
///
/// The `remote_object_id` is the Note activity's id, used for idempotency.
/// `content_format` is either "markdown" or "html" depending on the source.
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(level = "debug", skip(db))]
pub async fn create_on_ticket_from_remote(
db: &DatabaseConnection,
remote_actor_id: i64,
ticket_id: i64,
content: &str,
content_format: &str,
remote_object_id: &str,
) -> Result<comments::Model, DbErr> {
comments::ActiveModel {
uuid: Set(Uuid::new_v4()),
author_id: Set(None),
remote_actor_id: Set(Some(remote_actor_id)),
remote_object_id: Set(Some(remote_object_id.to_string())),
ticket_id: Set(Some(ticket_id)),
patch_proposal_id: Set(None),
content: Set(content.to_string()),
content_format: Set(content_format.to_string()),
..Default::default()
}
.insert(db)
.await
}
/// A new comment on a patch proposal from a remote actor (inbound federation).
///
/// The `remote_object_id` is the Note activity's id, used for idempotency.
/// `content_format` is either "markdown" or "html" depending on the source.
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(level = "debug", skip(db))]
pub async fn create_on_patch_from_remote(
db: &DatabaseConnection,
remote_actor_id: i64,
patch_proposal_id: i64,
content: &str,
content_format: &str,
remote_object_id: &str,
) -> Result<comments::Model, DbErr> {
comments::ActiveModel {
uuid: Set(Uuid::new_v4()),
author_id: Set(None),
remote_actor_id: Set(Some(remote_actor_id)),
remote_object_id: Set(Some(remote_object_id.to_string())),
ticket_id: Set(None),
patch_proposal_id: Set(Some(patch_proposal_id)),
content: Set(content.to_string()),
content_format: Set(content_format.to_string()),
..Default::default()
}
.insert(db)
.await
}
/// A comment as it was edited to be, said in the words of the columns rather
/// than in SQLite's.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn update(db: &DatabaseConnection, comment_id: i64, content: &str) -> Result<(), DbErr> {
comments::Entity::update_many()
.filter(comments::Column::Id.eq(comment_id))
.set(comments::ActiveModel {
content: Set(content.to_string()),
updated_at: Set(Some(chrono::Utc::now().fixed_offset())),
..Default::default()
})
.exec(db)
.await?;
Ok(())
}
/// A comment taken down, gone without a trace.
#[tracing::instrument(level = "debug", skip(db))]
pub async fn delete(db: &DatabaseConnection, comment_id: i64) -> Result<(), DbErr> {
comments::Entity::delete_many()
.filter(comments::Column::Id.eq(comment_id))
.exec(db)
.await?;
Ok(())
}
/// Information about a comment author, either local or remote.
#[derive(Clone, Debug)]
pub struct CommentAuthor {
/// Display name (local username or remote display name)
pub name: String,
/// Remote actor URL (None for local users)
pub actor_url: Option<String>,
/// Whether this is a remote author
pub is_remote: bool,
}
/// The authors who wrote comments, looking up both local users and remote actors.
#[tracing::instrument(level = "debug", skip(db, author_ids, remote_actor_ids))]
pub async fn comment_authors(
db: &DatabaseConnection,
author_ids: &[i64],
remote_actor_ids: &[i64],
) -> Result<HashMap<i64, CommentAuthor>, DbErr> {
let mut authors = HashMap::new();
// Look up local users
if !author_ids.is_empty() {
for user in users::Entity::find()
.filter(users::Column::Id.is_in(author_ids.to_vec()))
.all(db)
.await?
{
authors.insert(
user.id,
CommentAuthor {
name: user.name,
actor_url: None,
is_remote: false,
},
);
}
}
// Look up remote actors
if !remote_actor_ids.is_empty() {
use crate::entities::ap_remote_actors;
for actor in ap_remote_actors::Entity::find()
.filter(ap_remote_actors::Column::Id.is_in(remote_actor_ids.to_vec()))
.all(db)
.await?
{
// Use preferred_username if available, otherwise extract from actor_url
let name = actor
.preferred_username
.or_else(|| actor.actor_url.rsplit('/').next().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
authors.insert(
actor.id,
CommentAuthor {
name,
actor_url: Some(actor.actor_url),
is_remote: true,
},
);
}
}
Ok(authors)
}
/// The names of the users who wrote `author_ids`, for saying on a comment who
/// wrote it. This is the legacy function for backward compatibility.
#[tracing::instrument(level = "debug", skip(db, author_ids))]
pub async fn author_names(
db: &DatabaseConnection,
author_ids: &[i64],
) -> Result<HashMap<i64, String>, DbErr> {
let mut names = HashMap::new();
if author_ids.is_empty() {
return Ok(names);
}
for user in users::Entity::find()
.filter(users::Column::Id.is_in(author_ids.to_vec()))
.all(db)
.await?
{
names.insert(user.id, user.name);
}
Ok(names)
}