+ notifications

This commit is contained in:
xds
2025-11-20 14:54:00 +03:00
parent 195bdd83f0
commit c84f6a3988
9 changed files with 266 additions and 30 deletions

View File

@@ -38,7 +38,8 @@ class SpaceRepoImpl(
owner = User( owner = User(
rs.getInt("s_owner_id"), rs.getInt("s_owner_id"),
rs.getString("s_owner_username"), 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")), participant = User(rs.getInt("sp_uid"), rs.getString("sp_username"), rs.getString("sp_first_name")),
createdAt = rs.getTimestamp("s_created_at").toInstant(), createdAt = rs.getTimestamp("s_created_at").toInstant(),
@@ -93,6 +94,7 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id, s.owner_id as s_owner_id,
ou.username as s_owner_username, ou.username as s_owner_username,
ou.first_name as s_owner_firstname, ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid, sp.participants_id as sp_uid,
u.username as sp_username, u.username as sp_username,
u.first_name as sp_first_name, u.first_name as sp_first_name,
@@ -114,7 +116,7 @@ class SpaceRepoImpl(
where (s.owner_id = :user_id where (s.owner_id = :user_id
or sp.participants_id = :user_id) or sp.participants_id = :user_id)
and s.is_deleted = false 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; uau.username, uau.first_name;
""".trimMargin() """.trimMargin()
val params = mapOf( val params = mapOf(
@@ -133,6 +135,7 @@ class SpaceRepoImpl(
s.owner_id as s_owner_id, s.owner_id as s_owner_id,
ou.username as s_owner_username, ou.username as s_owner_username,
ou.first_name as s_owner_firstname, ou.first_name as s_owner_firstname,
ou.tg_id as s_owner_tg_id,
sp.participants_id as sp_uid, sp.participants_id as sp_uid,
u.username as sp_username, u.username as sp_username,
u.first_name as sp_first_name, u.first_name as sp_first_name,
@@ -154,7 +157,7 @@ from finance.spaces s
where (s.owner_id = :user_id where (s.owner_id = :user_id
or sp.participants_id = :user_id) or sp.participants_id = :user_id)
and s.is_deleted = false and s.id = :spaceId 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; uau.username, uau.first_name;
""".trimMargin() """.trimMargin()
val params = mapOf( val params = mapOf(

View File

@@ -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,
}

View File

@@ -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<String>()
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")
}
}

View File

@@ -8,7 +8,8 @@ import org.springframework.stereotype.Service
@EnableScheduling @EnableScheduling
@Service @Service
class Scheduler( class Scheduler(
private val recurrentOperationService: RecurrentOperationService private val recurrentOperationService: RecurrentOperationService,
private val notificationService: NotificationService
) { ) {
private val log = LoggerFactory.getLogger(Scheduler::class.java) private val log = LoggerFactory.getLogger(Scheduler::class.java)
@@ -17,4 +18,10 @@ class Scheduler(
log.info("Creating recurrent after 13 month") log.info("Creating recurrent after 13 month")
recurrentOperationService.createRecurrentTransactions() recurrentOperationService.createRecurrentTransactions()
} }
@Scheduled(cron = "0 30 19 * * *")
fun sendDailyReminders() {
log.info("Sending daily reminders")
notificationService.sendDailyReminder()
}
} }

View File

@@ -1,11 +1,17 @@
package space.luminic.finance.services 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 org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.NotFoundException import space.luminic.finance.models.NotFoundException
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.gpt.CategorizeService import space.luminic.finance.services.gpt.CategorizeService
import java.time.format.DateTimeFormatter
@Service @Service
class TransactionServiceImpl( class TransactionServiceImpl(
@@ -14,7 +20,11 @@ class TransactionServiceImpl(
private val transactionRepo: TransactionRepo, private val transactionRepo: TransactionRepo,
private val authService: AuthService, private val authService: AuthService,
private val categorizeService: CategorizeService, private val categorizeService: CategorizeService,
private val notificationService: NotificationService,
) : TransactionService { ) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun getTransactions( override fun getTransactions(
spaceId: Int, spaceId: Int,
filter: TransactionService.TransactionsFilter, filter: TransactionService.TransactionsFilter,
@@ -53,7 +63,17 @@ class TransactionServiceImpl(
date = transaction.date, date = transaction.date,
recurrentId = transaction.recurrentId, 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<TransactionDTO.CreateTransactionDTO>, createdById: Int?) { override fun batchCreate(spaceId: Int, transactions: List<TransactionDTO.CreateTransactionDTO>, createdById: Int?) {
@@ -85,6 +105,7 @@ class TransactionServiceImpl(
transactionId: Int, transactionId: Int,
transaction: TransactionDTO.UpdateTransactionDTO transaction: TransactionDTO.UpdateTransactionDTO
): Int { ): Int {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null) val space = spaceService.getSpace(spaceId, null)
val existingTransaction = getTransaction(space.id!!, transactionId) val existingTransaction = getTransaction(space.id!!, transactionId)
val newCategory = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } 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)) { if ((existingTransaction.category == null && updatedTransaction.category != null) || (existingTransaction.category?.id != updatedTransaction.category?.id)) {
categorizeService.notifyThatCategorySelected(updatedTransaction) 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) { override fun deleteTransaction(spaceId: Int, transactionId: Int) {
val userId = authService.getSecurityUserId()
val space = spaceService.getSpace(spaceId, null) val space = spaceService.getSpace(spaceId, null)
getTransaction(space.id!!, transactionId) val tx = getTransaction(space.id!!, transactionId)
transactionRepo.delete(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) { override fun deleteByRecurrentId(spaceId: Int, recurrentId: Int) {

View File

@@ -16,6 +16,7 @@ import com.github.kotlintelegrambot.logging.LogLevel
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
@@ -30,10 +31,11 @@ import java.time.LocalDate
@Service @Service
class BotService( class BotService(
@Value("\${telegram.bot.token}") private val botToken: String, @Value("\${telegram.bot.token}") private val botToken: String,
@Value("\${spring.profiles.active}") private val profile: String,
private val userService: UserService, private val userService: UserService,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val botRepo: BotRepo, 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 @Bean
fun bot(): Bot { fun bot(): Bot {
val bot = com.github.kotlintelegrambot.bot { val bot = com.github.kotlintelegrambot.bot {
logLevel = LogLevel.All() logLevel = if (profile == "proc") LogLevel.None else LogLevel.All()
token = botToken token = botToken
dispatch { dispatch {
message(Filter.Text) { message(Filter.Text) {

View File

@@ -4,5 +4,5 @@ import space.luminic.finance.models.Space
interface SpaceService { interface SpaceService {
fun getSpaces(userId: Int): List<Space> fun getSpaces(userId: Int): List<Space>
fun getSpace(spaceId: Int, userId: Int): Space? fun getSpace(spaceId: Int, userId: Int): Space
} }

View File

@@ -14,7 +14,7 @@ class SpaceServiceImpl(
return spaces return spaces
} }
override fun getSpace(spaceId: Int, userId: Int): Space? { override fun getSpace(spaceId: Int, userId: Int): Space {
val space = val space =
spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found") spaceRepo.findSpaceById(spaceId, userId) ?: throw NotFoundException("Space with id $spaceId not found")
return space return space

View File

@@ -1,18 +1,29 @@
package space.luminic.finance.services.telegram 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.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import space.luminic.finance.dtos.TransactionDTO import space.luminic.finance.dtos.TransactionDTO
import space.luminic.finance.models.Transaction import space.luminic.finance.models.Transaction
import space.luminic.finance.repos.TransactionRepo import space.luminic.finance.repos.TransactionRepo
import space.luminic.finance.services.CategoryServiceImpl import space.luminic.finance.services.CategoryServiceImpl
import space.luminic.finance.services.NotificationService
import space.luminic.finance.services.TxActionType
@Service("transactionsServiceTelegram") @Service("transactionsServiceTelegram")
class TransactionsServiceImpl( class TransactionsServiceImpl(
private val transactionRepo: TransactionRepo, private val transactionRepo: TransactionRepo,
@Qualifier("spaceServiceTelegram") private val spaceService: SpaceService, @Qualifier("spaceServiceTelegram") private val spaceService: SpaceService,
private val categoryService: CategoryServiceImpl private val categoryService: CategoryServiceImpl,
): TransactionService { private val notificationService: NotificationService
) : TransactionService {
private val logger = LoggerFactory.getLogger(this.javaClass)
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun createTransaction( override fun createTransaction(
spaceId: Int, spaceId: Int,
@@ -21,23 +32,31 @@ class TransactionsServiceImpl(
chatId: Long, chatId: Long,
messageId: Long messageId: Long
): Int { ): Int {
val space = spaceService.getSpace(spaceId, userId) val space = spaceService.getSpace(spaceId, userId)
val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) } val category = transaction.categoryId?.let { categoryService.getCategory(spaceId, it) }
val transaction = Transaction( val transaction = Transaction(
space = space, space = space,
type = transaction.type, type = transaction.type,
kind = transaction.kind, kind = transaction.kind,
category = category, category = category,
comment = transaction.comment, comment = transaction.comment,
amount = transaction.amount, amount = transaction.amount,
fees = transaction.fees, fees = transaction.fees,
date = transaction.date, date = transaction.date,
tgChatId = chatId, tgChatId = chatId,
tgMessageId = messageId, tgMessageId = messageId,
) )
return transactionRepo.create(transaction, userId) 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)
} }
} }