From c84f6a3988886bf012a3c6201ef8a9b7f0b7be32 Mon Sep 17 00:00:00 2001 From: xds Date: Thu, 20 Nov 2025 14:54:00 +0300 Subject: [PATCH] + notifications --- .../luminic/finance/repos/SpaceRepoImpl.kt | 9 +- .../finance/services/NotificationService.kt | 19 +++ .../services/NotificationServiceImpl.kt | 145 ++++++++++++++++++ .../luminic/finance/services/Scheduler.kt | 9 +- .../services/TransactionServiceImpl.kt | 47 +++++- .../finance/services/telegram/BotService.kt | 8 +- .../finance/services/telegram/SpaceService.kt | 2 +- .../services/telegram/SpaceServiceImpl.kt | 2 +- .../telegram/TransactionsServiceImpl.kt | 55 ++++--- 9 files changed, 266 insertions(+), 30 deletions(-) create mode 100644 src/main/kotlin/space/luminic/finance/services/NotificationService.kt create mode 100644 src/main/kotlin/space/luminic/finance/services/NotificationServiceImpl.kt diff --git a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt index 3b0dbba..b7ae6aa 100644 --- a/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt +++ b/src/main/kotlin/space/luminic/finance/repos/SpaceRepoImpl.kt @@ -38,7 +38,8 @@ class SpaceRepoImpl( owner = User( rs.getInt("s_owner_id"), rs.getString("s_owner_username"), - rs.getString("s_owner_firstname") + rs.getString("s_owner_firstname"), + tgId = rs.getLong("s_owner_tg_id"), ), participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")), createdAt = rs.getTimestamp("s_created_at").toInstant(), @@ -93,6 +94,7 @@ class SpaceRepoImpl( s.owner_id as s_owner_id, ou.username as s_owner_username, ou.first_name as s_owner_firstname, + ou.tg_id as s_owner_tg_id, sp.participants_id as sp_uid, u.username as sp_username, u.first_name as sp_first_name, @@ -114,7 +116,7 @@ class SpaceRepoImpl( where (s.owner_id = :user_id or sp.participants_id = :user_id) and s.is_deleted = false - group by s.id, ou.username, ou.first_name, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, + group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, uau.username, uau.first_name; """.trimMargin() val params = mapOf( @@ -133,6 +135,7 @@ class SpaceRepoImpl( s.owner_id as s_owner_id, ou.username as s_owner_username, ou.first_name as s_owner_firstname, + ou.tg_id as s_owner_tg_id, sp.participants_id as sp_uid, u.username as sp_username, u.first_name as sp_first_name, @@ -154,7 +157,7 @@ from finance.spaces s where (s.owner_id = :user_id or sp.participants_id = :user_id) and s.is_deleted = false and s.id = :spaceId -group by s.id, ou.username, ou.first_name, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, +group by s.id, ou.username, ou.first_name, ou.tg_id, sp.participants_id, u.username, u.first_name, cau.username, cau.first_name, uau.username, uau.first_name; """.trimMargin() val params = mapOf( diff --git a/src/main/kotlin/space/luminic/finance/services/NotificationService.kt b/src/main/kotlin/space/luminic/finance/services/NotificationService.kt new file mode 100644 index 0000000..5b2e1c8 --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/NotificationService.kt @@ -0,0 +1,19 @@ +package space.luminic.finance.services + +import com.github.kotlintelegrambot.entities.ReplyMarkup +import com.github.kotlintelegrambot.entities.inputmedia.MediaGroup +import space.luminic.finance.models.Space +import space.luminic.finance.models.Transaction + +interface NotificationService { + fun sendDailyReminder() + fun sendTXNotification(action: TxActionType, space: Space, userId: Int, tx: Transaction, tx2: Transaction? = null) + fun sendTextMessage(chatId: Long, message: String, replyMarkup: ReplyMarkup? = null) + fun sendMediaGroup(chatId: Long, group: MediaGroup) +} + +enum class TxActionType { + CREATE, + UPDATE, + DELETE, +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/NotificationServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/NotificationServiceImpl.kt new file mode 100644 index 0000000..375258d --- /dev/null +++ b/src/main/kotlin/space/luminic/finance/services/NotificationServiceImpl.kt @@ -0,0 +1,145 @@ +package space.luminic.finance.services + +import com.github.kotlintelegrambot.Bot +import com.github.kotlintelegrambot.entities.ChatId +import com.github.kotlintelegrambot.entities.InlineKeyboardMarkup +import com.github.kotlintelegrambot.entities.ReplyMarkup +import com.github.kotlintelegrambot.entities.inputmedia.MediaGroup +import com.github.kotlintelegrambot.entities.keyboard.InlineKeyboardButton +import com.github.kotlintelegrambot.entities.keyboard.WebAppInfo +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.context.annotation.Lazy +import space.luminic.finance.models.Space +import space.luminic.finance.models.Transaction +import java.time.format.DateTimeFormatter + +@Service +class NotificationServiceImpl(private val userService: UserService, private val bot: Bot,) : NotificationService { + private val logger = LoggerFactory.getLogger(this.javaClass) + + + private fun createWebAppButton(spaceId: Int? = null, txId: Int? = null): InlineKeyboardMarkup = + spaceId?.let { spaceId -> + txId?.let { txId -> + InlineKeyboardMarkup.create( + listOf( + InlineKeyboardButton.WebApp( + "Открыть в WebApp", + WebAppInfo("https://app.luminic.space/transactions/${txId}/edit?mode=from_bot&space=${spaceId}") + ) + ) + ) + } ?: InlineKeyboardMarkup.create( + listOf( + InlineKeyboardButton.WebApp( + "Открыть в WebApp", + WebAppInfo("https://app.luminic.space/transactions?mode=from_bot&space=${spaceId}") + ) + ) + ) + } ?: InlineKeyboardMarkup.create( + listOf( + InlineKeyboardButton.WebApp( + "Открыть в WebApp", + WebAppInfo("https://app.luminic.space/transactions") + ) + ) + ) + + + override fun sendDailyReminder() { + val text = "🤑 Время заполнять траты!" + val users = userService.getUsers() + + for (user in users) { + user.tgId?.let { + sendTextMessage(it, text, createWebAppButton()) + } + } + } + + override fun sendTXNotification( + action: TxActionType, + space: Space, + userId: Int, + tx: Transaction, + tx2: Transaction? + ) { + val user = userService.getById(userId) + when (action) { + TxActionType.CREATE -> { + val text = "${user.firstName} создал транзакцию ${tx.comment} c суммой ${tx.amount} и датой ${tx.date}" + space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) } + + } + + TxActionType.UPDATE -> { + tx2?.let { tx2 -> + val changes = mutableListOf() + if (tx.type != tx2.type) { + changes.add("Тип: ${tx.type.name} → ${tx2.type.name}") + } + if (tx.kind != tx2.kind) { + changes.add("Вид: ${tx.kind.name} → ${tx2.kind.name}") + } + if (tx.category != tx2.category) { + tx.category?.let { oldCategory -> + tx2.category?.let { newCategory -> + if (oldCategory.id != newCategory.id) { + changes.add("Категория: ${oldCategory.name} → ${newCategory.name}") + } + } ?: changes.add("Удалена категория. Прежняя: ${oldCategory.name}") + } ?: { + tx2.category?.let { newCategory -> + changes.add("Установлена новая категория ${newCategory.name}") + } + } + } + if (tx.comment != tx2.comment) { + changes.add("Комментарий: ${tx.comment} → ${tx2.comment}") + } + if (tx.amount != tx2.amount) { + changes.add("Сумма: ${tx.amount} → ${tx2.amount}") + } + if (tx.date.toEpochDay() != tx2.date.toEpochDay()) { + changes.add( + "Сумма: ${ + tx.date.format(DateTimeFormatter.ofPattern("dd.MM.yyyy")) + } → ${ + tx2.date.format( + DateTimeFormatter.ofPattern("dd.MM.yyyy") + ) + }" + ) + } + var text = "${user.firstName} обновил транзакцию ${tx.comment}\n\n" + text += changes.joinToString("\n") { it } + space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) } + + } ?: logger.warn("No tx2 provided when update") + } + + TxActionType.DELETE -> { + val text = "${user.firstName} удалил транзакцию ${tx.comment} c суммой ${tx.amount} и датой ${tx.date}" + space.owner.tgId?.let { sendTextMessage(it, text, createWebAppButton(space.id, tx.id)) } + } + } + } + + + override fun sendTextMessage( + chatId: Long, + message: String, + replyMarkup: ReplyMarkup? + ) { + bot.sendMessage(ChatId.fromId(chatId), message, replyMarkup = replyMarkup) + } + + override fun sendMediaGroup( + chatId: Long, + group: MediaGroup + ) { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/Scheduler.kt b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt index e776005..36350ee 100644 --- a/src/main/kotlin/space/luminic/finance/services/Scheduler.kt +++ b/src/main/kotlin/space/luminic/finance/services/Scheduler.kt @@ -8,7 +8,8 @@ import org.springframework.stereotype.Service @EnableScheduling @Service class Scheduler( - private val recurrentOperationService: RecurrentOperationService + private val recurrentOperationService: RecurrentOperationService, + private val notificationService: NotificationService ) { private val log = LoggerFactory.getLogger(Scheduler::class.java) @@ -17,4 +18,10 @@ class Scheduler( log.info("Creating recurrent after 13 month") recurrentOperationService.createRecurrentTransactions() } + + @Scheduled(cron = "0 30 19 * * *") + fun sendDailyReminders() { + log.info("Sending daily reminders") + notificationService.sendDailyReminder() + } } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt index a85e9be..dbca062 100644 --- a/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/TransactionServiceImpl.kt @@ -1,11 +1,17 @@ package space.luminic.finance.services +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.Transaction import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.services.gpt.CategorizeService +import java.time.format.DateTimeFormatter @Service class TransactionServiceImpl( @@ -14,7 +20,11 @@ class TransactionServiceImpl( private val transactionRepo: TransactionRepo, private val authService: AuthService, private val categorizeService: CategorizeService, + private val notificationService: NotificationService, ) : TransactionService { + private val logger = LoggerFactory.getLogger(this.javaClass) + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + override fun getTransactions( spaceId: Int, filter: TransactionService.TransactionsFilter, @@ -53,7 +63,17 @@ class TransactionServiceImpl( date = transaction.date, recurrentId = transaction.recurrentId, ) - return transactionRepo.create(transaction, userId) + val createdTx = transactionRepo.create(transaction, userId) + serviceScope.launch { + runCatching { + if (space.owner.id != userId) { + notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction) + } + }.onFailure { + logger.error("Error while creating transaction", it) + } + } + return createdTx } override fun batchCreate(spaceId: Int, transactions: List, createdById: Int?) { @@ -85,6 +105,7 @@ class TransactionServiceImpl( transactionId: Int, transaction: TransactionDTO.UpdateTransactionDTO ): Int { + val userId = authService.getSecurityUserId() val space = spaceService.getSpace(spaceId, null) val existingTransaction = getTransaction(space.id!!, transactionId) val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } @@ -109,14 +130,34 @@ class TransactionServiceImpl( if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) { categorizeService.notifyThatCategorySelected(updatedTransaction) } + val updatedTx = transactionRepo.update(updatedTransaction) + serviceScope.launch { + runCatching { - return transactionRepo.update(updatedTransaction) + notificationService.sendTXNotification( + TxActionType.UPDATE, + space, + userId, + existingTransaction, + updatedTransaction + ) + }.onFailure { + logger.error("Error while send transaction update notification", it) + } + } + return updatedTx } override fun deleteTransaction(spaceId: Int, transactionId: Int) { + val userId = authService.getSecurityUserId() val space = spaceService.getSpace(spaceId, null) - getTransaction(space.id!!, transactionId) + val tx = getTransaction(space.id!!, transactionId) transactionRepo.delete(transactionId) + serviceScope.launch { + runCatching { + notificationService.sendTXNotification(TxActionType.DELETE, space, userId, tx) + }.onFailure { logger.error("Error while transaction delete notification", it) } + } } override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) { diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt index 4545b8d..d46306c 100644 --- a/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt +++ b/src/main/kotlin/space/luminic/finance/services/telegram/BotService.kt @@ -16,6 +16,7 @@ import com.github.kotlintelegrambot.logging.LogLevel import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import space.luminic.finance.dtos.TransactionDTO @@ -30,10 +31,11 @@ import java.time.LocalDate @Service class BotService( @Value("\${telegram.bot.token}") private val botToken: String, + @Value("\${spring.profiles.active}") private val profile: String, private val userService: UserService, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, private val botRepo: BotRepo, - @Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService + @Lazy @Qualifier("transactionsServiceTelegram") private val transactionService: TransactionService ) { @@ -113,13 +115,13 @@ class BotService( ) ) - return InlineKeyboardMarkup.Companion.create(keyboard) + return InlineKeyboardMarkup.create(keyboard) } @Bean fun bot(): Bot { val bot = com.github.kotlintelegrambot.bot { - logLevel = LogLevel.All() + logLevel = if (profile == "proc") LogLevel.None else LogLevel.All() token = botToken dispatch { message(Filter.Text) { diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt index 8487813..13f2ef4 100644 --- a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt +++ b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceService.kt @@ -4,5 +4,5 @@ import space.luminic.finance.models.Space interface SpaceService { fun getSpaces(userId: Int): List - fun getSpace(spaceId: Int, userId: Int): Space? + fun getSpace(spaceId: Int, userId: Int): Space } \ No newline at end of file diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt index f1d7c4c..6b050cd 100644 --- a/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/telegram/SpaceServiceImpl.kt @@ -14,7 +14,7 @@ class SpaceServiceImpl( return spaces } - override fun getSpace(spaceId: Int, userId: Int): Space? { + override fun getSpace(spaceId: Int, userId: Int): Space { val space = spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found") return space diff --git a/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt index 66dedbd..bc5c991 100644 --- a/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt +++ b/src/main/kotlin/space/luminic/finance/services/telegram/TransactionsServiceImpl.kt @@ -1,18 +1,29 @@ package space.luminic.finance.services.telegram +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier import org.springframework.stereotype.Service import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.models.Transaction import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.services.CategoryServiceImpl +import space.luminic.finance.services.NotificationService +import space.luminic.finance.services.TxActionType @Service("transactionsServiceTelegram") class TransactionsServiceImpl( private val transactionRepo: TransactionRepo, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, - private val categoryService: CategoryServiceImpl -): TransactionService { + private val categoryService: CategoryServiceImpl, + private val notificationService: NotificationService +) : TransactionService { + private val logger = LoggerFactory.getLogger(this.javaClass) + private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + override fun createTransaction( spaceId: Int, @@ -21,23 +32,31 @@ class TransactionsServiceImpl( chatId: Long, messageId: Long ): Int { - val space = spaceService.getSpace(spaceId, userId) - val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } - val transaction = Transaction( - space = space, - type = transaction.type, - kind = transaction.kind, - category = category, - comment = transaction.comment, - amount = transaction.amount, - fees = transaction.fees, - date = transaction.date, - tgChatId = chatId, - tgMessageId = messageId, - ) - return transactionRepo.create(transaction, userId) + val space = spaceService.getSpace(spaceId, userId) + val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } + val transaction = Transaction( + space = space, + type = transaction.type, + kind = transaction.kind, + category = category, + comment = transaction.comment, + amount = transaction.amount, + fees = transaction.fees, + date = transaction.date, + tgChatId = chatId, + tgMessageId = messageId, + ) + serviceScope.launch { + runCatching { + if (space.owner.id != userId) { + notificationService.sendTXNotification(TxActionType.CREATE, space, userId, transaction) + } + }.onFailure { + logger.error("Error while transaction notification", it) + } + } + return transactionRepo.create(transaction, userId) } - } \ No newline at end of file