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, pub old_title: Option, pub new_title: Option, pub content: Option, } impl IssueService { pub fn new(state: AppState) -> Self { Self { state } } pub fn config(&self) -> &AppConfig { &self.state.config } pub fn paginate(&self, limit: Option, offset: Option) -> (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 { &self.state.database } pub async fn load_user_briefs( &self, uids: &[i64], ) -> Result, IssueError> { if uids.is_empty() { return Ok(HashMap::new()); } let mut set: Vec = 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 { 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; let payload = serde_json::json!({ "issue_id": issue_id, "user_id": user_id, "event_type": event_type, "comment_id": &comment_id, "old_title": &old_title, "new_title": &new_title, "content": &content, }); 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?; let mut labels = queue::Labels::new() .with("issue_id", issue_id) .with("user_id", user_id); if let Some(request_id) = export::events::request_id() { labels.insert("request_id", request_id); } export::events::enqueue( txn, self.state.events_enabled, event_kind(event_type), labels, payload, format!("issue:{issue_id}"), ) .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, data: Option, actor_uid: Option, ) -> 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, 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 = 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 = 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 = 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 = 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, 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}"))) } } fn event_kind(event_type: IssueEventType) -> &'static str { match event_type { IssueEventType::Created => "issue.issue.create", IssueEventType::TitleUpdated => "issue.title.update", IssueEventType::DescriptionUpdated => "issue.description.update", IssueEventType::ContentUpdated => "issue.content.update", IssueEventType::MilestoneUpdated => "issue.milestone.update", IssueEventType::PriorityUpdated => "issue.priority.update", IssueEventType::DueUpdated => "issue.due.update", IssueEventType::Closed => "issue.issue.close", IssueEventType::Reopened => "issue.issue.reopen", IssueEventType::CommentCreated => "issue.comment.create", IssueEventType::CommentUpdated => "issue.comment.update", IssueEventType::CommentDeleted => "issue.comment.delete", IssueEventType::MemberAdded => "issue.member.create", IssueEventType::MemberRemoved => "issue.member.delete", IssueEventType::LabelAdded => "issue.label.create", IssueEventType::LabelRemoved => "issue.label.delete", IssueEventType::DependencyAdded => "issue.dependency.create", IssueEventType::DependencyRemoved => "issue.dependency.delete", IssueEventType::Pinned => "issue.pin.create", IssueEventType::Unpinned => "issue.pin.delete", IssueEventType::RepoRelationAdded => "issue.repo-relation.create", IssueEventType::RepoRelationRemoved => "issue.repo-relation.delete", IssueEventType::PullLinked => "issue.pull-link.create", IssueEventType::PullUnlinked => "issue.pull-link.delete", } }