Skip to content
Clone
951a522
262 lines 8.2 KB Rust
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use config::AppConfig;
use export::AppState;
use export::database::AppDatabase;
use i18n;
use models::issue::issue_event_line::{self, IssueEventType};
use models::space::space_members::{self, MemberStatus};
use models::space::spaces;
use models::users::user;
use models::users::user_notifications::{self, NotificationType};
use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
use space::services::spaces::UserDto;
use time::OffsetDateTime;
use uuid::Uuid;
use crate::errors::IssueError;
#[derive(Clone)]
pub struct IssueService {
pub state: AppState,
}
/// The optional "what changed" columns of an issue activity row. Grouped into a
/// struct so `write_event` does not take four positionally-interchangeable
/// `Option` arguments.
#[derive(Default)]
pub(crate) struct IssueEventDetail {
pub comment_id: Option<Uuid>,
pub old_title: Option<String>,
pub new_title: Option<String>,
pub content: Option<String>,
}
impl IssueService {
pub fn new(state: AppState) -> Self {
Self { state }
}
pub fn config(&self) -> &AppConfig {
&self.state.config
}
pub fn paginate(&self, limit: Option<u64>, offset: Option<u64>) -> (u64, u64) {
let max_pagination = self.config().issues.max_pagination;
let default_pagination = self.config().issues.default_pagination;
(
limit.unwrap_or(default_pagination).clamp(1, max_pagination),
offset.unwrap_or(0).clamp(0, 5000),
)
}
pub fn db(&self) -> &Arc<AppDatabase> {
&self.state.database
}
pub async fn load_user_briefs(
&self,
uids: &[i64],
) -> Result<HashMap<i64, UserDto>, IssueError> {
if uids.is_empty() {
return Ok(HashMap::new());
}
let mut set: Vec<i64> = uids.to_vec();
set.sort();
set.dedup();
let users = user::Entity::find()
.filter(user::Column::Uid.is_in(set))
.all(self.db().read())
.await?;
let map = users
.into_iter()
.map(|u| (u.uid, UserDto::from(u)))
.collect();
Ok(map)
}
#[allow(clippy::too_many_arguments)]
pub async fn resolve_issue_ref(
&self,
caller: i64,
space_name: &str,
board_id: Uuid,
issue_ref: &str,
) -> Result<Uuid, IssueError> {
let space = crate::share::lookup::lookup_space_by_name(self, space_name).await?;
crate::share::lookup::require_member(self, &space, caller).await?;
let board = crate::share::lookup::lookup_board(self, &space, board_id, caller).await?;
Ok(
crate::share::lookup::lookup_issue_by_ref(self, &board, issue_ref)
.await?
.id,
)
}
/// Record one activity row. The optional columns all describe "what changed"
/// and are grouped so the call sites stay readable.
pub(crate) async fn write_event(
&self,
txn: &impl ConnectionTrait,
issue_id: Uuid,
user_id: i64,
event_type: IssueEventType,
detail: IssueEventDetail,
) -> Result<(), IssueError> {
let IssueEventDetail {
comment_id,
old_title,
new_title,
content,
} = detail;
issue_event_line::ActiveModel {
id: Set(Uuid::now_v7()),
issue_id: Set(Some(issue_id)),
project_id: Set(None),
user_id: Set(user_id),
event_type: Set(event_type),
comment_id: Set(comment_id),
commit_sha: Set(None),
old_title: Set(old_title),
new_title: Set(new_title),
old_ref: Set(None),
new_ref: Set(None),
content: Set(content),
created_at: Set(OffsetDateTime::now_utc()),
}
.insert(txn)
.await?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn notify_user(
&self,
txn: &impl ConnectionTrait,
pending: &export::notify::PendingNotifications,
recipient: i64,
ntype: NotificationType,
title: String,
body: Option<String>,
data: Option<serde_json::Value>,
actor_uid: Option<i64>,
) -> Result<(), IssueError> {
let row = user_notifications::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(recipient),
notification_type: Set(ntype),
title: Set(title),
body: Set(body),
data_json: Set(data),
actor_uid: Set(actor_uid),
read_at: Set(None),
created_at: Set(OffsetDateTime::now_utc()),
}
.insert(txn)
.await?;
pending.push(recipient, row.to_event_value());
Ok(())
}
pub(crate) async fn collect_mentioned_uids(
&self,
space: &spaces::Model,
text: &str,
exclude: i64,
) -> Result<Vec<i64>, IssueError> {
let members = space_members::Entity::find()
.filter(space_members::Column::SpaceId.eq(space.id))
.filter(space_members::Column::Status.eq(MemberStatus::Active))
.all(self.db().read())
.await?;
let mut member_uids: Vec<i64> = members.into_iter().map(|m| m.user_id).collect();
if !member_uids.contains(&space.owner_id) {
member_uids.push(space.owner_id);
}
if member_uids.is_empty() {
return Ok(Vec::new());
}
let users = user::Entity::find()
.filter(user::Column::Uid.is_in(member_uids))
.all(self.db().read())
.await?;
let mut name_to_uid: HashMap<String, i64> = HashMap::new();
for u in users {
name_to_uid.insert(u.username.to_lowercase(), u.uid);
name_to_uid.insert(u.lower_name.to_lowercase(), u.uid);
name_to_uid.insert(u.uid.to_string(), u.uid);
}
let mut result_set: HashSet<i64> = HashSet::new();
let punctuation = [
',', '.', ';', ':', '!', '?', '\'', '"', '“', '”', '`', ')', ']', '}', '>',
];
for token in text.split_whitespace() {
if let Some(rest) = token.strip_prefix('@') {
let clean = rest.trim_end_matches(&punctuation[..]);
if clean.is_empty() {
continue;
}
let clean_lower = clean.to_lowercase();
if let Some(&uid) = name_to_uid.get(&clean_lower)
&& uid != exclude
{
result_set.insert(uid);
}
}
}
let mut uids: Vec<i64> = result_set.into_iter().collect();
uids.sort();
let max_mentions = self.config().issues.max_mentions_per_message;
if uids.len() > max_mentions {
uids.truncate(max_mentions);
}
Ok(uids)
}
/// Language for one recipient's issue mail.
///
/// Account preference only: an issue event fans out to watchers, the
/// assignee and mentioned users, each of whom may read a different language.
pub(crate) async fn recipient_locale(&self, uid: i64) -> i18n::UiLocale {
let fallback = i18n::config_fallback(self.config().email.default_locale());
i18n::resolve_user_locale(self.db().read(), uid, fallback).await
}
pub(crate) async fn primary_emails(
&self,
uids: &[i64],
) -> Result<HashMap<i64, String>, IssueError> {
if uids.is_empty() {
return Ok(HashMap::new());
}
models::users::queries::primary_emails(self.db().read(), uids)
.await
.map_err(IssueError::from)
}
pub(crate) async fn send_email(
&self,
to: &str,
subject: &str,
text: &str,
html: &str,
) -> Result<(), IssueError> {
let Some(client) = self.state.email_rpc.as_ref() else {
tracing::warn!(to, "email rpc disabled; skipping issue mail");
return Ok(());
};
client
.send_mail(to, subject, text, html)
.await
.map_err(|error| IssueError::Internal(format!("email send failed: {error}")))
}
}