//! Бизнес-логика тикетов техподдержки. Единственный канал создания //! тикета/сообщения — бот (`bot.rs`), см. `handle_user_message`; ответ //! саппорта приходит из веб-админки (`support::controller::reply`) и //! доставляется юзеру обратно в тот же Telegram-чат через `reply_as_staff`. use crate::{ db::models::{SupportMessage, SupportTicket, SupportTicketSummary}, db::repository::AppRepository, error::AppError, }; use std::sync::Arc; use teloxide::{payloads::SendMessageSetters, prelude::Requester, types::ChatId, Bot}; use uuid::Uuid; #[derive(Clone)] pub struct SupportService { repo: Arc, } impl SupportService { pub fn new(repo: Arc) -> Self { Self { repo } } /// Кнопка "Поддержка" в боте — переводит юзера в разовый режим ожидания /// сообщения. Сбрасывается сам в `handle_user_message`. pub async fn set_awaiting(&self, user_id: Uuid) -> Result<(), AppError> { self.repo .set_awaiting_support(user_id, true) .await .map_err(AppError::Database) } /// Вызывается ботом ТОЛЬКО когда `user.awaiting_support_message == true` /// (проверка — на вызывающей стороне, см. `bot.rs`). Переиспользует уже /// открытый тикет юзера, если есть, иначе создаёт новый. pub async fn handle_user_message(&self, user_id: Uuid, text: &str) -> Result<(), AppError> { let ticket = match self .repo .get_open_ticket_for_user(user_id) .await .map_err(AppError::Database)? { Some(t) => t, None => { let t = self .repo .create_support_ticket(user_id) .await .map_err(AppError::Database)?; metrics::counter!("support_tickets_total", "status" => "created").increment(1); t } }; self.repo .add_support_message(ticket.id, "user", Some(user_id), text) .await .map_err(AppError::Database)?; // Сбрасываем флаг только после успешной записи сообщения — если БД // недоступна, следующее сообщение юзера не потеряется молча. self.repo .set_awaiting_support(user_id, false) .await .map_err(AppError::Database) } pub async fn list_tickets( &self, status_filter: Option<&str>, ) -> Result, AppError> { self.repo .list_tickets(status_filter) .await .map_err(AppError::Database) } /// Для кнопки "Поддержка" в боте — показать контекст уже открытого /// тикета юзера, если есть (последние сообщения треда). pub async fn get_open_ticket_thread_for_user( &self, user_id: Uuid, ) -> Result)>, AppError> { let Some(ticket) = self .repo .get_open_ticket_for_user(user_id) .await .map_err(AppError::Database)? else { return Ok(None); }; let messages = self .repo .get_ticket_messages(ticket.id) .await .map_err(AppError::Database)?; Ok(Some((ticket, messages))) } pub async fn get_ticket_with_messages( &self, ticket_id: Uuid, ) -> Result)>, AppError> { let Some(ticket) = self .repo .get_ticket(ticket_id) .await .map_err(AppError::Database)? else { return Ok(None); }; let messages = self .repo .get_ticket_messages(ticket_id) .await .map_err(AppError::Database)?; Ok(Some((ticket, messages))) } /// Саппорт отвечает из админки — сохраняет сообщение и пытается /// доставить его юзеру в Telegram. Ошибка отправки в Telegram (юзер /// заблокировал бота, сетевой сбой у Telegram API и т.п.) не роняет /// ответ целиком — сообщение уже сохранено в БД, саппорт увидит его в /// треде даже если push не дошёл (та же resilience, что у расчёта /// партнёрской комиссии в `BillingService::confirm_payment`). pub async fn reply_as_staff( &self, ticket_id: Uuid, staff_id: Uuid, text: &str, bot_token: &str, user_tg_id: Option, ) -> Result<(), AppError> { self.repo .add_support_message(ticket_id, "staff", Some(staff_id), text) .await .map_err(AppError::Database)?; self.repo .set_ticket_assigned(ticket_id, staff_id) .await .map_err(AppError::Database)?; match user_tg_id { Some(tg_id) => { let bot = Bot::new(bot_token); let message = format!("💬 Ответ поддержки:\n\n{}", text); if let Err(e) = bot .send_message(ChatId(tg_id), message) .parse_mode(teloxide::types::ParseMode::Html) .await { tracing::warn!(error = ?e, ticket_id = %ticket_id, "Не удалось доставить ответ поддержки в Telegram — сообщение сохранено в БД"); } } None => { tracing::warn!(ticket_id = %ticket_id, "У юзера тикета нет tg_id — ответ сохранён, но доставить в Telegram некуда"); } } Ok(()) } pub async fn close_ticket(&self, ticket_id: Uuid) -> Result { let ticket = self .repo .close_ticket(ticket_id) .await .map_err(AppError::Database)? .ok_or_else(|| AppError::NotFound("Тикет не найден".into()))?; metrics::counter!("support_tickets_total", "status" => "closed").increment(1); Ok(ticket) } } #[cfg(test)] mod tests { use super::*; use crate::db::repository::PgRepository; use sqlx::PgPool; #[sqlx::test] async fn test_handle_user_message_creates_ticket_and_resets_flag(pool: PgPool) { let repo: Arc = Arc::new(PgRepository::new(pool)); let svc = SupportService::new(repo.clone()); let user = repo.upsert_user(111, None).await.unwrap(); svc.set_awaiting(user.id).await.unwrap(); svc.handle_user_message(user.id, "не подключается на iOS") .await .unwrap(); let user_after = repo.get_user_by_id(user.id).await.unwrap().unwrap(); assert!( !user_after.awaiting_support_message, "флаг должен сброситься сразу после получения сообщения" ); let ticket = repo .get_open_ticket_for_user(user.id) .await .unwrap() .expect("должен появиться открытый тикет"); let messages = repo.get_ticket_messages(ticket.id).await.unwrap(); assert_eq!(messages.len(), 1); assert_eq!(messages[0].sender_role, "user"); assert_eq!(messages[0].body, "не подключается на iOS"); } #[sqlx::test] async fn test_handle_user_message_reuses_open_ticket(pool: PgPool) { let repo: Arc = Arc::new(PgRepository::new(pool)); let svc = SupportService::new(repo.clone()); let user = repo.upsert_user(222, None).await.unwrap(); svc.handle_user_message(user.id, "первое сообщение") .await .unwrap(); let first_ticket = repo .get_open_ticket_for_user(user.id) .await .unwrap() .unwrap(); svc.handle_user_message(user.id, "второе сообщение, тот же тикет") .await .unwrap(); let second_ticket = repo .get_open_ticket_for_user(user.id) .await .unwrap() .unwrap(); assert_eq!( first_ticket.id, second_ticket.id, "второе сообщение должно уйти в уже открытый тикет, а не создать новый" ); let messages = repo.get_ticket_messages(first_ticket.id).await.unwrap(); assert_eq!(messages.len(), 2); } #[sqlx::test] async fn test_handle_user_message_creates_new_ticket_if_previous_closed(pool: PgPool) { let repo: Arc = Arc::new(PgRepository::new(pool)); let svc = SupportService::new(repo.clone()); let user = repo.upsert_user(333, None).await.unwrap(); svc.handle_user_message(user.id, "первое обращение") .await .unwrap(); let first_ticket = repo .get_open_ticket_for_user(user.id) .await .unwrap() .unwrap(); repo.close_ticket(first_ticket.id).await.unwrap(); svc.handle_user_message(user.id, "новое обращение") .await .unwrap(); let second_ticket = repo .get_open_ticket_for_user(user.id) .await .unwrap() .expect("должен появиться новый открытый тикет"); assert_ne!(first_ticket.id, second_ticket.id); } }