13 Commits

Author SHA1 Message Date
xds
9575eb82a2 + fix 2025-07-17 13:16:03 +03:00
xds
6e8f5d112b + - bot enable 2025-07-17 13:14:15 +03:00
xds
2e054088ce + wishlist item is active 2025-07-17 13:07:34 +03:00
xds
1bcbf5e53a + fixes 2025-05-26 13:59:23 +03:00
xds
5d9cc167fc + nlp reatech 2025-04-08 12:13:20 +03:00
xds
af7577c65b + nlp 2025-04-07 18:26:23 +03:00
xds
a38d5068e0 + bot + notifications 2025-04-01 15:07:50 +03:00
xds
711348b386 fix category delete 2025-03-18 12:54:37 +03:00
xds
4d5b89b08c hot fix child transactiopns 2025-03-10 23:56:29 +03:00
xds
ed8965b055 hot fix child transactiopns 2025-03-10 13:24:32 +03:00
xds
94df5d72c3 hot fix 2025-03-10 12:55:44 +03:00
xds
98263732ca login fix and spaces creation 2025-03-06 18:55:12 +03:00
xds
3b9f0e566c wishlists + statics + some fixes 2025-03-03 10:38:07 +03:00
42 changed files with 1609 additions and 348 deletions

View File

@@ -56,6 +56,9 @@ dependencies {
implementation("com.google.code.gson:gson") implementation("com.google.code.gson:gson")
implementation("io.micrometer:micrometer-registry-prometheus") implementation("io.micrometer:micrometer-registry-prometheus")
implementation("org.telegram:telegrambots:6.9.7.1")
implementation("org.telegram:telegrambots-spring-boot-starter:6.9.7.1")
implementation("com.opencsv:opencsv:5.10")

View File

@@ -2,17 +2,23 @@ package space.luminic.budgerapp
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching import org.springframework.cache.annotation.EnableCaching
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
import org.springframework.scheduling.annotation.EnableAsync import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling import org.springframework.scheduling.annotation.EnableScheduling
import java.util.TimeZone import space.luminic.budgerapp.configs.NLPConfig
import space.luminic.budgerapp.configs.TelegramBotProperties
import java.util.*
@SpringBootApplication @SpringBootApplication(scanBasePackages = ["space.luminic.budgerapp"])
@EnableCaching @EnableCaching
@EnableAsync @EnableAsync
@EnableScheduling @EnableScheduling
//@EnableConfigurationProperties([TelegramBotProperties::class,)
@ConfigurationPropertiesScan(basePackages = ["space.luminic.budgerapp"])
@EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"]) @EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"])
class BudgerAppApplication class BudgerAppApplication

View File

@@ -1,7 +1,6 @@
package space.luminic.budgerapp.configs package space.luminic.budgerapp.configs
import kotlinx.coroutines.reactor.mono import kotlinx.coroutines.reactor.mono
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
@@ -16,8 +15,7 @@ import space.luminic.budgerapp.services.AuthService
@Component @Component
class BearerTokenFilter(private val authService: AuthService) : SecurityContextServerWebExchangeWebFilter() { class BearerTokenFilter(private val authService: AuthService) : SecurityContextServerWebExchangeWebFilter() {
private val logger = LoggerFactory.getLogger(BearerTokenFilter::class.java) // private val logger = LoggerFactory.getLogger(BearerTokenFilter::class.java)
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> { override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
@@ -27,7 +25,10 @@ class BearerTokenFilter(private val authService: AuthService) : SecurityContextS
"/api/auth/login", "/api/auth/login",
"/api/auth/register", "/api/auth/register",
"/api/auth/tgLogin" "/api/auth/tgLogin"
) || exchange.request.path.value().startsWith("/api/actuator") ) || exchange.request.path.value().startsWith("/api/actuator") || exchange.request.path.value()
.startsWith("/api/static/")
|| exchange.request.path.value()
.startsWith("/api/wishlistexternal/")
) { ) {
return chain.filter(exchange) return chain.filter(exchange)
} }

View File

@@ -1,12 +1,19 @@
package space.luminic.budgerapp.configs package space.luminic.budgerapp.configs
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
//@Configuration
//class CommonConfig { //class CommonConfig {
// @Bean // @Bean
// fun httpTraceRepository(): HttpTraceRepository { // fun httpTraceRepository(): HttpTraceRepository {
// return InMemoryHttpTraceRepository() // return InMemoryHttpTraceRepository()
// } // }
//} //}
@ConfigurationProperties(prefix = "nlp")
data class NLPConfig(
val address: String,
)

View File

@@ -26,7 +26,8 @@ class SecurityConfig(
.logout { it.disable() } .logout { it.disable() }
.authorizeExchange { .authorizeExchange {
it.pathMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tgLogin").permitAll() it.pathMatchers(HttpMethod.POST, "/auth/login", "/auth/register", "/auth/tgLogin").permitAll()
it.pathMatchers("/actuator/**").permitAll() it.pathMatchers("/actuator/**", "/static/**").permitAll()
it.pathMatchers("/wishlistexternal/**").permitAll()
it.anyExchange().authenticated() it.anyExchange().authenticated()
} }
.addFilterAt( .addFilterAt(

View File

@@ -0,0 +1,18 @@
package space.luminic.budgerapp.configs
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
@Configuration
class StorageConfig(@Value("\${storage.location}") location: String) {
val rootLocation: Path = Paths.get(location)
init {
Files.createDirectories(rootLocation) // Создаем папку, если её нет
}
}

View File

@@ -0,0 +1,25 @@
package space.luminic.budgerapp.configs
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.telegram.telegrambots.meta.TelegramBotsApi
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession
import space.luminic.budgerapp.services.BotService
@Configuration
class TelegramBotConfig {
@Bean
fun telegramBotsApi(myBot: BotService): TelegramBotsApi {
val botsApi = TelegramBotsApi(DefaultBotSession::class.java)
botsApi.registerBot(myBot)
return botsApi
}
}
@ConfigurationProperties(prefix = "telegram.bot")
data class TelegramBotProperties(
val username: String,
val token: String,
)

View File

@@ -30,18 +30,19 @@ class AuthController(
@PostMapping("/login") @PostMapping("/login")
suspend fun login(@RequestBody request: AuthRequest): Map<String, String> { suspend fun login(@RequestBody request: AuthRequest): Map<String, String> {
return authService.login(request.username, request.password) val token = authService.login(request.username, request.password)
.map { token -> mapOf("token" to token) }.awaitFirst() return mapOf("token" to token)
} }
@PostMapping("/register") @PostMapping("/register")
fun register(@RequestBody request: RegisterRequest): Mono<User> { suspend fun register(@RequestBody request: RegisterRequest): User {
return authService.register(request.username, request.password, request.firstName) return authService.register(request.username, request.password, request.firstName)
} }
@PostMapping("/tgLogin") @PostMapping("/tgLogin")
fun tgLogin(@RequestHeader("X-Tg-Id") tgId: String): Mono<Map<String, String>> { suspend fun tgLogin(@RequestHeader("X-Tg-Id") tgId: String): Map<String, String> {
return authService.tgLogin(tgId).map { token -> mapOf("token" to token) } val token = authService.tgLogin(tgId)
return mapOf("token" to token)
} }

View File

@@ -0,0 +1,64 @@
package space.luminic.budgerapp.controllers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.springframework.core.io.PathResource
import org.springframework.core.io.Resource
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.http.codec.multipart.FilePart
import org.springframework.web.bind.annotation.*
import space.luminic.budgerapp.configs.StorageConfig
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.services.StaticService
import java.nio.file.Files
import java.nio.file.Paths
@RestController
@RequestMapping("/static/{spaceId}/wishlists/{wishListItemId}")
class ImageController(private val staticService: StaticService, private val storageConfig: StorageConfig) {
@GetMapping("/{resourceId}")
suspend fun downloadFile(
@PathVariable spaceId: String,
@PathVariable wishListItemId: String,
@PathVariable resourceId: String
): ResponseEntity<Resource> {
return withContext(Dispatchers.IO) {
// val filePath = staticService.generatePathToFile(spaceId, wishListItemId, resourceId)
val filePath =
Paths.get(storageConfig.rootLocation.toString(), spaceId, "wishlists", wishListItemId, resourceId)
if (!Files.exists(filePath) || !Files.isReadable(filePath)) {
throw NotFoundException("File $filePath not found")
}
val resource = PathResource(filePath)
val contentType = Files.probeContentType(filePath) ?: "application/octet-stream"
ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.body(resource)
}
}
@PostMapping
suspend fun addImage(
@PathVariable spaceId: String,
@PathVariable wishListItemId: String,
@RequestPart("file") file: FilePart,
@RequestHeader("Content-Length") contentLength: Long,
): ResponseEntity<String> {
val maxSize = 5L * 1024 * 1024 // 5MB
println(file.headers().contentType)
if (contentLength > maxSize) {
return ResponseEntity
.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body("Размер файла превышает 5MB")
}
return ResponseEntity.ok(staticService.saveFile(spaceId, wishListItemId, file))
}
}

View File

@@ -1,22 +1,35 @@
package space.luminic.budgerapp.controllers package space.luminic.budgerapp.controllers
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.Recurrent import space.luminic.budgerapp.services.AuthService
import space.luminic.budgerapp.services.RecurrentService import space.luminic.budgerapp.services.RecurrentService
import space.luminic.budgerapp.services.SpaceService
@RestController @RestController
@RequestMapping("/recurrents") @RequestMapping("/spaces/{spaceId}")
class RecurrentController ( class RecurrentController(
private val recurrentService: RecurrentService private val recurrentService: RecurrentService,
private val authService: AuthService,
private val spaceService: SpaceService
){ ){
// @DeleteMapping("/recurrents/{recurrentId}")
// suspend fun delete(
// @PathVariable spaceId: String,
// @PathVariable recurrentId: String): String {
// val user = authService.getSecurityUser()
// val space = spaceService.isValidRequest(spaceId, user)
// if (space.owner?.id == user.id) {
// recurrentService.deleteRecurrent(recurrentId)
// return "Cool"
// } else {
// throw AuthException("Only owners allowed")
// }
// }
// //
// @GetMapping("/") // @GetMapping("/")
// fun getRecurrents(): Mono<List<Recurrent>> { // fun getRecurrents(): Mono<List<Recurrent>> {

View File

@@ -1,29 +1,38 @@
package space.luminic.budgerapp.controllers package space.luminic.budgerapp.controllers
import com.opencsv.CSVWriter
import kotlinx.coroutines.reactor.awaitSingle import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.apache.commons.io.IOUtils.writer
import org.bson.Document import org.bson.Document
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.controllers.BudgetController.LimitValue import space.luminic.budgerapp.controllers.BudgetController.LimitValue
import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO
import space.luminic.budgerapp.models.* import space.luminic.budgerapp.models.*
import space.luminic.budgerapp.services.CategoryService import space.luminic.budgerapp.services.*
import space.luminic.budgerapp.services.FinancialService import java.io.ByteArrayOutputStream
import space.luminic.budgerapp.services.RecurrentService import java.io.OutputStreamWriter
import space.luminic.budgerapp.services.SpaceService
import java.time.LocalDate import java.time.LocalDate
@RestController @RestController
@RequestMapping("/spaces") @RequestMapping("/spaces")
class SpaceController( class SpaceController(
private val spaceService: SpaceService, private val spaceService: SpaceService,
private val financialService: FinancialService, private val financialService: FinancialService,
private val categoryService: CategoryService, private val categoryService: CategoryService,
private val recurrentService: RecurrentService private val recurrentService: RecurrentService,
private val authService: AuthService
) { ) {
private val log = LoggerFactory.getLogger(SpaceController::class.java)
data class SpaceCreateDTO( data class SpaceCreateDTO(
val name: String, val name: String,
@@ -54,13 +63,16 @@ class SpaceController(
@DeleteMapping("/{spaceId}") @DeleteMapping("/{spaceId}")
suspend fun deleteSpace(@PathVariable spaceId: String) { suspend fun deleteSpace(@PathVariable spaceId: String) {
return spaceService.deleteSpace(spaceService.isValidRequest(spaceId)) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return spaceService.deleteSpace(space)
} }
@PostMapping("/{spaceId}/invite") @PostMapping("/{spaceId}/invite")
suspend fun inviteSpace(@PathVariable spaceId: String): SpaceInvite { suspend fun inviteSpace(@PathVariable spaceId: String): SpaceInvite {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return spaceService.createInviteSpace(spaceId) return spaceService.createInviteSpace(spaceId)
} }
@@ -71,13 +83,15 @@ class SpaceController(
@DeleteMapping("/{spaceId}/leave") @DeleteMapping("/{spaceId}/leave")
suspend fun leaveSpace(@PathVariable spaceId: String) { suspend fun leaveSpace(@PathVariable spaceId: String) {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return spaceService.leaveSpace(spaceId) return spaceService.leaveSpace(spaceId)
} }
@DeleteMapping("/{spaceId}/members/kick/{username}") @DeleteMapping("/{spaceId}/members/kick/{username}")
suspend fun kickMembers(@PathVariable spaceId: String, @PathVariable username: String) { suspend fun kickMembers(@PathVariable spaceId: String, @PathVariable username: String) {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return spaceService.kickMember(spaceId, username) return spaceService.kickMember(spaceId, username)
} }
@@ -87,14 +101,17 @@ class SpaceController(
// //
@GetMapping("/{spaceId}/budgets") @GetMapping("/{spaceId}/budgets")
suspend fun getBudgets(@PathVariable spaceId: String): List<Budget> { suspend fun getBudgets(@PathVariable spaceId: String): List<Budget> {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return financialService.getBudgets(spaceId).awaitSingleOrNull().orEmpty() return financialService.getBudgets(spaceId).awaitSingleOrNull().orEmpty()
} }
@GetMapping("/{spaceId}/budgets/{id}") @GetMapping("/{spaceId}/budgets/{id}")
suspend fun getBudget(@PathVariable spaceId: String, @PathVariable id: String): BudgetDTO? { suspend fun getBudget(@PathVariable spaceId: String, @PathVariable id: String): BudgetDTO? {
spaceService.isValidRequest(spaceId) log.info("Getting budget for spaceId=$spaceId, id=$id")
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return financialService.getBudget(spaceId, id) return financialService.getBudget(spaceId, id)
} }
@@ -103,8 +120,10 @@ class SpaceController(
@PathVariable spaceId: String, @PathVariable spaceId: String,
@RequestBody budgetCreationDTO: BudgetCreationDTO, @RequestBody budgetCreationDTO: BudgetCreationDTO,
): Budget? { ): Budget? {
val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return financialService.createBudget( return financialService.createBudget(
spaceService.isValidRequest(spaceId), space,
budgetCreationDTO.budget, budgetCreationDTO.budget,
budgetCreationDTO.createRecurrent budgetCreationDTO.createRecurrent
) )
@@ -112,7 +131,8 @@ class SpaceController(
@DeleteMapping("/{spaceId}/budgets/{id}") @DeleteMapping("/{spaceId}/budgets/{id}")
suspend fun deleteBudget(@PathVariable spaceId: String, @PathVariable id: String) { suspend fun deleteBudget(@PathVariable spaceId: String, @PathVariable id: String) {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
financialService.deleteBudget(spaceId, id) financialService.deleteBudget(spaceId, id)
} }
@@ -124,7 +144,8 @@ class SpaceController(
@PathVariable catId: String, @PathVariable catId: String,
@RequestBody limit: LimitValue, @RequestBody limit: LimitValue,
): BudgetCategory { ): BudgetCategory {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return financialService.setCategoryLimit(spaceId, budgetId, catId, limit.limit) return financialService.setCategoryLimit(spaceId, budgetId, catId, limit.limit)
} }
@@ -159,6 +180,51 @@ class SpaceController(
} }
} }
@GetMapping("/transactions/csv")
suspend fun getTransactionsCSV(
): ResponseEntity<Any> {
try {
val bos = ByteArrayOutputStream()
val writer = CSVWriter(OutputStreamWriter(bos))
val CSVHeaders = arrayOf("id", "comment", "category")
writer.writeNext(CSVHeaders)
financialService.getAllTransactions(
).map {
val data = arrayOf(it.id, it.comment, it.category.id)
writer.writeNext(data)
}
writer.close()
val csvData = bos.toByteArray()
val headers = HttpHeaders()
headers.contentType = MediaType.parseMediaType("text/csv")
headers.setContentDispositionFormData("attachment", "pojos.csv")
return ResponseEntity(csvData, headers, HttpStatus.OK)
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@GetMapping("/{spaceId}/category-predict")
suspend fun getTransactionCategoryPredict(
@PathVariable spaceId: String,
@RequestParam comment: String,
@RequestParam cloud: Int
): List<Category> {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return categoryService.getCategories(
"67af3c0f652da946a7dd9931",
"EXPENSE",
sortBy = "name",
direction = "ASC",
predict = comment,
cloud = cloud
)
}
@GetMapping("/{spaceId}/transactions/{id}") @GetMapping("/{spaceId}/transactions/{id}")
suspend fun getTransaction( suspend fun getTransaction(
@@ -168,9 +234,11 @@ class SpaceController(
return financialService.getTransactionById(id) return financialService.getTransactionById(id)
} }
@PostMapping("/{spaceId}/transactions") @PostMapping("/{spaceId}/transactions")
suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transaction: Transaction): Transaction { suspend fun createTransaction(@PathVariable spaceId: String, @RequestBody transaction: Transaction): Transaction {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return financialService.createTransaction(space, transaction) return financialService.createTransaction(space, transaction)
@@ -180,14 +248,16 @@ class SpaceController(
suspend fun editTransaction( suspend fun editTransaction(
@PathVariable spaceId: String, @PathVariable id: String, @RequestBody transaction: Transaction @PathVariable spaceId: String, @PathVariable id: String, @RequestBody transaction: Transaction
): Transaction { ): Transaction {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
transaction.space = space transaction.space = space
return financialService.editTransaction(transaction) return financialService.editTransaction(transaction, user)
} }
@DeleteMapping("/{spaceId}/transactions/{id}") @DeleteMapping("/{spaceId}/transactions/{id}")
suspend fun deleteTransaction(@PathVariable spaceId: String, @PathVariable id: String) { suspend fun deleteTransaction(@PathVariable spaceId: String, @PathVariable id: String) {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
val transaction = financialService.getTransactionById(id) val transaction = financialService.getTransactionById(id)
financialService.deleteTransaction(transaction) financialService.deleteTransaction(transaction)
} }
@@ -202,8 +272,9 @@ class SpaceController(
@RequestParam("sort") sortBy: String = "name", @RequestParam("sort") sortBy: String = "name",
@RequestParam("direction") direction: String = "ASC" @RequestParam("direction") direction: String = "ASC"
): List<Category> { ): List<Category> {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
return categoryService.getCategories(spaceId, type, sortBy, direction).awaitSingleOrNull().orEmpty() spaceService.isValidRequest(spaceId, user)
return categoryService.getCategories(spaceId, type, sortBy, direction)
} }
@GetMapping("/{spaceId}/categories/types") @GetMapping("/{spaceId}/categories/types")
@@ -215,7 +286,8 @@ class SpaceController(
suspend fun createCategory( suspend fun createCategory(
@PathVariable spaceId: String, @RequestBody category: Category @PathVariable spaceId: String, @RequestBody category: Category
): Category { ): Category {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return financialService.createCategory(space, category).awaitSingle() return financialService.createCategory(space, category).awaitSingle()
} }
@@ -225,33 +297,38 @@ class SpaceController(
@RequestBody category: Category, @RequestBody category: Category,
@PathVariable spaceId: String @PathVariable spaceId: String
): Category { ): Category {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return categoryService.editCategory(space, category) return categoryService.editCategory(space, category)
} }
@DeleteMapping("/{spaceId}/categories/{categoryId}") @DeleteMapping("/{spaceId}/categories/{categoryId}")
suspend fun deleteCategory(@PathVariable categoryId: String, @PathVariable spaceId: String) { suspend fun deleteCategory(@PathVariable categoryId: String, @PathVariable spaceId: String) {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
categoryService.deleteCategory(space, categoryId) val space = spaceService.isValidRequest(spaceId, user)
categoryService.deleteCategory(space, categoryId, user)
} }
@GetMapping("/{spaceId}/categories/tags") @GetMapping("/{spaceId}/categories/tags")
suspend fun getTags(@PathVariable spaceId: String): List<Tag> { suspend fun getTags(@PathVariable spaceId: String): List<Tag> {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return spaceService.getTags(space) return spaceService.getTags(space)
} }
@PostMapping("/{spaceId}/categories/tags") @PostMapping("/{spaceId}/categories/tags")
suspend fun createTags(@PathVariable spaceId: String, @RequestBody tag: Tag): Tag { suspend fun createTags(@PathVariable spaceId: String, @RequestBody tag: Tag): Tag {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return spaceService.createTag(space, tag) return spaceService.createTag(space, tag)
} }
@DeleteMapping("/{spaceId}/categories/tags/{tagId}") @DeleteMapping("/{spaceId}/categories/tags/{tagId}")
suspend fun deleteTags(@PathVariable spaceId: String, @PathVariable tagId: String) { suspend fun deleteTags(@PathVariable spaceId: String, @PathVariable tagId: String) {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return spaceService.deleteTag(space, tagId) return spaceService.deleteTag(space, tagId)
} }
@@ -267,39 +344,49 @@ class SpaceController(
@GetMapping("/{spaceId}/recurrents") @GetMapping("/{spaceId}/recurrents")
suspend fun getRecurrents(@PathVariable spaceId: String): List<Recurrent> { suspend fun getRecurrents(@PathVariable spaceId: String): List<Recurrent> {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
return recurrentService.getRecurrents(spaceId).awaitSingleOrNull().orEmpty() spaceService.isValidRequest(spaceId, user)
return recurrentService.getRecurrents(spaceId)
} }
@GetMapping("/{spaceId}/recurrents/{id}") @GetMapping("/{spaceId}/recurrents/{id}")
suspend fun getRecurrent(@PathVariable spaceId: String, @PathVariable id: String): Recurrent { suspend fun getRecurrent(@PathVariable spaceId: String, @PathVariable id: String): Recurrent {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return recurrentService.getRecurrentById(space, id).awaitSingle() return recurrentService.getRecurrentById(space, id).awaitSingle()
} }
@PostMapping("/{spaceId}/recurrent") @PostMapping("/{spaceId}/recurrents")
suspend fun createRecurrent(@PathVariable spaceId: String, @RequestBody recurrent: Recurrent): Recurrent { suspend fun createRecurrent(@PathVariable spaceId: String, @RequestBody recurrent: Recurrent): Recurrent {
val space = spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return recurrentService.createRecurrent(space, recurrent).awaitSingle() return recurrentService.createRecurrent(space, recurrent).awaitSingle()
} }
@PutMapping("/{spaceId}/recurrent/{id}") @PutMapping("/{spaceId}/recurrents/{id}")
suspend fun editRecurrent( suspend fun editRecurrent(
@PathVariable spaceId: String, @PathVariable spaceId: String,
@PathVariable id: String, @PathVariable id: String,
@RequestBody recurrent: Recurrent @RequestBody recurrent: Recurrent
): Recurrent { ): Recurrent {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return recurrentService.editRecurrent(recurrent).awaitSingle() return recurrentService.editRecurrent(recurrent).awaitSingle()
} }
@DeleteMapping("/{spaceId}/recurrent/{id}") @DeleteMapping("/{spaceId}/recurrents/{id}")
suspend fun deleteRecurrent(@PathVariable spaceId: String, @PathVariable id: String) { suspend fun deleteRecurrent(@PathVariable spaceId: String, @PathVariable id: String) {
spaceService.isValidRequest(spaceId) val user = authService.getSecurityUser()
recurrentService.deleteRecurrent(id).awaitSingle() val space = spaceService.isValidRequest(spaceId, user)
if (space.owner?.id == user.id) {
recurrentService.deleteRecurrent(id)
} else {
throw AuthException("Only owners allowed")
}
} }
// @GetMapping("/regen") // @GetMapping("/regen")

View File

@@ -35,7 +35,7 @@ class SubscriptionController(
} }
@PostMapping("/notifyAll") @PostMapping("/notifyAll")
fun notifyAll(@RequestBody payload: PushMessage): ResponseEntity<Any> { suspend fun notifyAll(@RequestBody payload: PushMessage): ResponseEntity<Any> {
return try { return try {
ResponseEntity.ok(subscriptionService.sendToAll(payload)) ResponseEntity.ok(subscriptionService.sendToAll(payload))
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -0,0 +1,101 @@
package space.luminic.budgerapp.controllers
import org.springframework.web.bind.annotation.*
import space.luminic.budgerapp.models.WishList
import space.luminic.budgerapp.models.WishListItem
import space.luminic.budgerapp.services.AuthService
import space.luminic.budgerapp.services.SpaceService
import space.luminic.budgerapp.services.WishListService
@RestController
@RequestMapping("/spaces/{spaceId}/wishlists")
class WishListController(
private val wishListService: WishListService,
private val spaceService: SpaceService,
private val authService: AuthService
) {
@GetMapping
suspend fun findWishList(@PathVariable spaceId: String): List<WishList> {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.findWishLists(spaceId)
}
@GetMapping("/{wishListId}")
suspend fun getWishList(@PathVariable spaceId: String, @PathVariable wishListId: String): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.getList(wishListId)
}
@PostMapping
suspend fun createWishList(@PathVariable spaceId: String, @RequestBody wishList: WishList): WishList {
val user = authService.getSecurityUser()
val space = spaceService.isValidRequest(spaceId, user)
return wishListService.createWishList(space, wishList)
}
@PatchMapping("/{wishListId}")
suspend fun updateWishList(
@PathVariable spaceId: String,
@PathVariable wishListId: String,
@RequestBody wishList: WishList
): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.updateWishListInfo(wishList)
}
@PatchMapping("/{wishListId}/items/{itemId}")
suspend fun updateWishListItem(
@PathVariable spaceId: String,
@PathVariable wishListId: String,
@PathVariable itemId: String,
@RequestBody wishListItem: WishListItem
): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.updateWishListItemInfo(wishListId, wishListItem)
}
@PostMapping("/{wishListId}/items")
suspend fun addItemToWishList(
@PathVariable spaceId: String,
@PathVariable wishListId: String,
@RequestBody wishListItem: WishListItem
): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.addItemToWishList(wishListId, wishListItem)
}
@DeleteMapping("/{wishListId}/items/{wishListItemId}")
suspend fun removeItemFromWishList(
@PathVariable spaceId: String,
@PathVariable wishListId: String,
@PathVariable wishListItemId: String
): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.removeItemFromWishList(wishListId, wishListItemId)
}
@DeleteMapping("/{wishListId}")
suspend fun deleteWishList(@PathVariable spaceId: String, @PathVariable wishListId: String) {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
wishListService.deleteWishList(wishListId)
}
@PostMapping("/{wishListId}/{wishlistItemId}/reserve/_cancel")
suspend fun cancelReserve(
@PathVariable spaceId: String, @PathVariable wishListId: String,
@PathVariable wishlistItemId: String
): WishList {
val user = authService.getSecurityUser()
spaceService.isValidRequest(spaceId, user)
return wishListService.cancelReserve(wishListId, wishlistItemId)
}
}

View File

@@ -0,0 +1,35 @@
package space.luminic.budgerapp.controllers
import org.springframework.web.bind.annotation.*
import space.luminic.budgerapp.models.Reserve
import space.luminic.budgerapp.models.WishList
import space.luminic.budgerapp.models.WishListItem
import space.luminic.budgerapp.services.WishlistExternalService
@RestController
@RequestMapping("/wishlistexternal/{wishListId}")
class WishlistExternalController(private val wishlistExternalService: WishlistExternalService) {
@GetMapping
suspend fun getWishListInfo(@PathVariable wishListId: String): WishList {
return wishlistExternalService.getWishListInfo(wishListId)
}
@PostMapping("/{wishlistItemId}/reserve/_create")
suspend fun reserveItem(
@PathVariable wishListId: String,
@PathVariable wishlistItemId: String,
@RequestBody reservedBy: Reserve
): WishListItem {
return wishlistExternalService.reserveWishlistItem(wishListId, wishlistItemId, reservedBy)
}
@PostMapping("/{wishlistItemId}/reserve/_cancel")
suspend fun cancelReserve(
@PathVariable wishListId: String,
@PathVariable wishlistItemId: String,
@RequestBody reservedBy: Reserve
): WishListItem {
return wishlistExternalService.cancelReserve(wishListId, wishlistItemId, reservedBy)
}
}

View File

@@ -8,8 +8,6 @@ import java.time.ZoneId
@Component @Component
class TransactionsMapper : FromDocumentMapper { class TransactionsMapper : FromDocumentMapper {
override fun fromDocument(document: Document): Transaction { override fun fromDocument(document: Document): Transaction {
val categoryDocument = document.get("categoryDetails", Document::class.java) val categoryDocument = document.get("categoryDetails", Document::class.java)
val categoryTypeDocument = categoryDocument["type"] as Document val categoryTypeDocument = categoryDocument["type"] as Document

View File

@@ -0,0 +1,47 @@
package space.luminic.budgerapp.mappers
import org.bson.Document
import org.bson.types.ObjectId
import org.springframework.stereotype.Component
import space.luminic.budgerapp.models.*
import java.time.ZoneId
@Component
class WishListMapper : FromDocumentMapper {
override fun fromDocument(document: Document): WishList {
val spaceDoc = document.get("spaceDetails", Document::class.java)
val ownerDoc = document.get("ownerDetails", Document::class.java)
val itemsDocList = document.getList("itemsDetails", Document::class.java).orEmpty()
return WishList(
id = document.get("_id", ObjectId::class.java).toString(),
name = document.get("name", String::class.java),
description = document.get("description", String::class.java),
space = Space(id = spaceDoc.getObjectId("_id").toString()),
isPrivate = document.getBoolean("isPrivate"),
owner = User(
ownerDoc.getObjectId("_id").toString(),
firstName = ownerDoc.getString("firstName").toString()
),
items = itemsDocList.map {
val reserveDoc = it.get("reservedBy", Document::class.java)
WishListItem(
id = it.getObjectId("_id").toString(),
name = it.getString("name"),
description = it.getString("description"),
price = it.getDouble("price"),
link = it.getString("link"),
images = it.getList("images", String::class.java),
reservedBy = if (reserveDoc != null) Reserve(
reserveDoc.getString("aid"),
reserveDoc.getString("name")
) else null,
updatedAt = it.getDate("updatedAt").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(),
createdAt = it.getDate("createdAt").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(),
isActive = it.getBoolean("isActive")
)
}.toMutableList(),
updatedAt = document.getDate("updatedAt").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(),
createdAt = document.getDate("createdAt").toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(),
)
}
}

View File

@@ -0,0 +1,22 @@
package space.luminic.budgerapp.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
enum class BotStates {
WAIT_CATEGORY
}
@Document("bot-user-states")
data class BotUserState(
@Id val id: String? = null,
@DBRef val user: User,
var data: MutableList<ChatData> = mutableListOf(),
)
data class ChatData (
val chatId: String,
var state: BotStates,
var data: MutableMap<String, String> = mutableMapOf(),
)

View File

@@ -0,0 +1,4 @@
package space.luminic.budgerapp.models
data class CategoryPrediction(val category: String, val weight: Double) {
}

View File

@@ -1,4 +1,5 @@
package space.luminic.budgerapp.models package space.luminic.budgerapp.models
open class NotFoundException(message: String) : Exception(message) open class NotFoundException(message: String) : Exception(message)
open class TelegramBotException(message: String, val chatId: Long) : Exception(message)

View File

@@ -0,0 +1,39 @@
package space.luminic.budgerapp.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(collection = "wishlists")
data class WishList(
@Id val id: String? = null,
@DBRef var space: Space? = null,
var name: String,
var description: String,
var isPrivate: Boolean,
@DBRef var owner: User? = null,
@DBRef var items: MutableList<WishListItem> = mutableListOf(),
var updatedAt: LocalDateTime = LocalDateTime.now(),
val createdAt: LocalDateTime = LocalDateTime.now()
)
@Document(collection = "wishlistItems")
data class WishListItem(
@Id val id: String? = null,
var name: String,
var description: String,
var price: Double,
var link: String,
var images: MutableList<String> = mutableListOf(),
var isActive: Boolean,
var reservedBy: Reserve? = null,
var updatedAt: LocalDateTime = LocalDateTime.now(),
var createdAt: LocalDateTime = LocalDateTime.now()
)
data class Reserve(
val aid: String,
val name: String? = null,
)

View File

@@ -0,0 +1,7 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import space.luminic.budgerapp.models.BotUserState
interface BotStatesRepo: ReactiveMongoRepository<BotUserState, String> {
}

View File

@@ -1,10 +1,21 @@
package space.luminic.budgerapp.repos package space.luminic.budgerapp.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import space.luminic.budgerapp.models.Subscription import space.luminic.budgerapp.models.Subscription
@Repository @Repository
interface SubscriptionRepo : ReactiveMongoRepository<Subscription, String> { interface SubscriptionRepo : ReactiveMongoRepository<Subscription, String> {
@Query("{ \$and: [ " +
"{ 'user': { '\$ref': 'users', '\$id': ?0 } }, " +
"{ 'isActive': true } " +
"]}")
fun findByUserIdAndIsActive(userId: ObjectId): Flux<Subscription>
} }

View File

@@ -0,0 +1,13 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.WishList
import space.luminic.budgerapp.models.WishListItem
@Repository
interface WishListRepo : ReactiveMongoRepository<WishList, String> {
}
@Repository
interface WishListItemRepo : ReactiveMongoRepository<WishListItem, String> {}

View File

@@ -1,10 +1,13 @@
package space.luminic.budgerapp.services package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.cache.annotation.Cacheable import org.springframework.cache.annotation.Cacheable
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.configs.AuthException import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.TokenStatus import space.luminic.budgerapp.models.TokenStatus
import space.luminic.budgerapp.models.User import space.luminic.budgerapp.models.User
@@ -22,72 +25,76 @@ class AuthService(
private val jwtUtil: JWTUtil, private val jwtUtil: JWTUtil,
private val userService: UserService private val userService: UserService
) { ) {
private val passwordEncoder = BCryptPasswordEncoder() private val passwordEncoder = BCryptPasswordEncoder()
fun login(username: String, password: String): Mono<String> { suspend fun getSecurityUser(): User {
return userRepository.findByUsername(username) val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull()
.flatMap { user -> ?: throw AuthException("Authentication failed")
if (passwordEncoder.matches(password, user.password)) { val authentication = securityContextHolder.authentication
val token = jwtUtil.generateToken(user.username!!)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10) val username = authentication.name
tokenService.saveToken( // Получаем пользователя по имени
token = token, return userService.getByUsername(username)
username = username,
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
).thenReturn(token)
} else {
Mono.error(AuthException("Invalid credentials"))
}
}
} }
fun tgLogin(tgId: String): Mono<String> { suspend fun login(username: String, password: String): String {
return userRepository.findByTgId(tgId) val user = userRepository.findByUsername(username).awaitFirstOrNull()
.switchIfEmpty(Mono.error(AuthException("Invalid credentials"))) ?: throw UsernameNotFoundException("Пользователь не найден")
.flatMap { user -> return if (passwordEncoder.matches(password, user.password)) {
println("here") val token = jwtUtil.generateToken(user.username!!)
val token = jwtUtil.generateToken(user.username!!) val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10) tokenService.saveToken(
tokenService.saveToken( token = token,
token = token, username = username,
username = user.username, expiresAt = LocalDateTime.ofInstant(
expiresAt = LocalDateTime.ofInstant( expireAt.toInstant(),
expireAt.toInstant(), ZoneId.systemDefault()
ZoneId.systemDefault() )
)
).thenReturn(token)
}
}
fun register(username: String, password: String, firstName: String): Mono<User> {
return userRepository.findByUsername(username)
.flatMap<User> { Mono.error(IllegalArgumentException("User with username '$username' already exists")) } // Ошибка, если пользователь уже существует
.switchIfEmpty(
Mono.defer {
val newUser = User(
username = username,
password = passwordEncoder.encode(password), // Шифрование пароля
firstName = firstName,
roles = mutableListOf("USER")
)
userRepository.save(newUser).map { user ->
user.password = null
user
} // Сохранение нового пользователя
}
) )
token
} else {
throw IllegalArgumentException("Ошибка логина или пароля")
}
}
suspend fun tgLogin(tgId: String): String {
val user =
userRepository.findByTgId(tgId).awaitSingleOrNull() ?: throw UsernameNotFoundException("Пользователь не найден")
val token = jwtUtil.generateToken(user.username!!)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
tokenService.saveToken(
token = token,
username = user.username,
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
)
return token
}
suspend fun register(username: String, password: String, firstName: String): User {
val user = userRepository.findByUsername(username).awaitSingleOrNull()
if (user == null) {
var newUser = User(
username = username,
password = passwordEncoder.encode(password), // Шифрование пароля
firstName = firstName,
roles = mutableListOf("USER")
)
newUser = userRepository.save(newUser).awaitSingle()
newUser.password = null
return newUser
} else throw IllegalArgumentException("Пользователь уже зарегистрирован")
} }
@Cacheable(cacheNames = ["tokens"], key = "#token") @Cacheable(cacheNames = ["tokens"], key = "#token")
suspend fun isTokenValid(token: String): User { suspend fun isTokenValid(token: String): User {
val tokenDetails = tokenService.getToken(token).awaitFirstOrNull() ?: throw AuthException("Invalid token") val tokenDetails = tokenService.getToken(token).awaitFirstOrNull() ?: throw AuthException("Токен не валиден")
when { when {
tokenDetails.status == TokenStatus.ACTIVE && tokenDetails.expiresAt.isAfter(LocalDateTime.now()) -> { tokenDetails.status == TokenStatus.ACTIVE && tokenDetails.expiresAt.isAfter(LocalDateTime.now()) -> {
return userService.getByUserNameWoPass(tokenDetails.username) return userService.getByUserNameWoPass(tokenDetails.username)
@@ -95,7 +102,7 @@ class AuthService(
else -> { else -> {
tokenService.revokeToken(tokenDetails.token) tokenService.revokeToken(tokenDetails.token)
throw AuthException("Token expired or inactive") throw AuthException("Токен истек или не валиден")
} }
} }
} }

View File

@@ -0,0 +1,319 @@
package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.runBlocking
import org.bson.Document
import org.bson.types.ObjectId
import org.slf4j.LoggerFactory
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.*
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.stereotype.Service
import org.telegram.telegrambots.bots.TelegramLongPollingBot
import org.telegram.telegrambots.meta.api.methods.send.SendMessage
import org.telegram.telegrambots.meta.api.methods.updatingmessages.DeleteMessage
import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageReplyMarkup
import org.telegram.telegrambots.meta.api.methods.updatingmessages.EditMessageText
import org.telegram.telegrambots.meta.api.objects.Update
import org.telegram.telegrambots.meta.api.objects.replykeyboard.InlineKeyboardMarkup
import org.telegram.telegrambots.meta.api.objects.replykeyboard.buttons.InlineKeyboardButton
import org.telegram.telegrambots.meta.exceptions.TelegramApiException
import space.luminic.budgerapp.configs.TelegramBotConfig
import space.luminic.budgerapp.configs.TelegramBotProperties
import space.luminic.budgerapp.models.*
import space.luminic.budgerapp.repos.BotStatesRepo
import java.time.LocalDate
import java.time.LocalDateTime
@Service
class BotService(
private val telegramBotProperties: TelegramBotProperties,
private val botStatesRepo: BotStatesRepo,
private val reactiveMongoTemplate: ReactiveMongoTemplate,
private val userService: UserService,
private val categoriesService: CategoryService,
private val financialService: FinancialService,
private val spaceService: SpaceService,
private val nlpService: NLPService
) : TelegramLongPollingBot(telegramBotProperties.token) {
private val logger = LoggerFactory.getLogger(javaClass)
private suspend fun constructCategoriesButtons(
nlp: Boolean, text: String? = null, chatId: Long
): InlineKeyboardMarkup {
val categories = categoriesService.getCategories(
"67af3c0f652da946a7dd9931", "EXPENSE", sortBy = "name", direction = "ASC"
)
val keyboard = InlineKeyboardMarkup()
val buttonLines = mutableListOf<MutableList<InlineKeyboardButton>>()
val filteredCategories = mutableListOf<Category>()
if (nlp) {
if (text.isNullOrBlank()) {
throw TelegramBotException("Текст не может быть пустым", chatId)
}
val predictedCategories = nlpService.predictCategory(text, 0)
for (category in predictedCategories) {
filteredCategories.add(categories.first { it.id == category.category })
}
} else {
filteredCategories.addAll(categories)
}
filteredCategories.map { category ->
val btn = InlineKeyboardButton.builder().text("${category.icon} ${category.name}")
.callbackData("category_${category.id}").build()
if (category.name.length >= 15) {
// Если текст длинный, создаём отдельную строку для кнопки
buttonLines.add(mutableListOf(btn))
} else {
var isAdded = false
// Пытаемся добавить кнопку в существующую строку
for (line in buttonLines) {
if (line.size < 2 && (line.isEmpty() || line[0].text.length < 14)) {
line.add(btn)
isAdded = true
break
}
}
// Если не нашли подходящую строку, создаём новую
if (!isAdded) {
buttonLines.add(mutableListOf(btn))
} else {
}
}
}
val allCatsBtn = InlineKeyboardButton.builder().text("Все категории").callbackData("all_cats").build()
val backButton = InlineKeyboardButton.builder().text("Отмена").callbackData("cancel").build()
buttonLines.add(mutableListOf(allCatsBtn))
buttonLines.add(mutableListOf(backButton))
keyboard.keyboard = buttonLines
return keyboard
}
override fun getBotUsername(): String {
return telegramBotProperties.username
}
override fun onUpdateReceived(update: Update) = runBlocking {
logger.info("Received message $update")
try {
if (update.hasCallbackQuery()) {
processCallback(update)
} else if (update.hasMessage()) {
if (update.message.hasText()) {
processMessage(update)
} else if (update.message.hasPhoto()) {
processPhoto(update)
} else if (update.message.hasVideo()) {
processVideo(update)
}
}
} catch (e: TelegramBotException) {
e.printStackTrace()
logger.error(e.message)
sendMessage(e.chatId.toString(), "${e.message}")
}
}
private suspend fun processCallback(update: Update) {
val chatId = update.callbackQuery.message.chatId.toString()
val tgUserId = update.callbackQuery.from.id
val user = userService.getUserByTelegramId(tgUserId) ?: throw TelegramBotException(
"User ${update.callbackQuery.from.userName} not found", chatId = update.callbackQuery.message.chatId
)
val state = getState(user.id!!)
if (state != null) {
when (state.data.first { it.chatId == chatId }.state) {
BotStates.WAIT_CATEGORY -> if (update.callbackQuery.data.startsWith("category_")) {
confirmTransaction(
chatId, user = userService.getUserByTelegramId(tgUserId)!!, update
)
} else if (update.callbackQuery.data == "cancel") {
finishState(chatId, user)
val deleteMsg = DeleteMessage(chatId, update.callbackQuery.message.messageId)
execute(deleteMsg)
sendMessage(chatId, "Введите сумму и комментарий когда будете готовы.")
} else if (update.callbackQuery.data == "all_cats") {
val editMessageReplyMarkup = EditMessageReplyMarkup()
editMessageReplyMarkup.chatId = chatId
editMessageReplyMarkup.messageId = update.callbackQuery.message.messageId
editMessageReplyMarkup.replyMarkup = constructCategoriesButtons(false, chatId = chatId.toLong())
execute(editMessageReplyMarkup)
}
}
}
}
private suspend fun processMessage(update: Update) {
val user = userService.getUserByTelegramId(update.message.from.id) ?: throw TelegramBotException(
"Мы не знакомы",
chatId = update.message.chatId,
)
getState(user.id!!)?.data?.find { it.chatId == update.message.chatId.toString() }?.let {
if (it.state == BotStates.WAIT_CATEGORY) {
throw TelegramBotException(
"Уже есть открытый выбор категории", update.message.chatId
)
}
}
newExpense(
update.message.chatId.toString(),
user = user,
text = update.message.text,
)
}
private fun processPhoto(update: Update) {
}
private fun processVideo(update: Update) {
}
private fun sendMessage(chatId: String, text: String) {
val message = SendMessage(chatId, text)
try {
execute(message)
} catch (e: TelegramApiException) {
e.printStackTrace()
}
}
private suspend fun newExpense(chatId: String, user: User, text: String) {
val splitText = text.split(" ")
if (splitText.size < 2) {
try {
throw TelegramBotException("Сумма или комментарий не введены", chatId.toLong())
} catch (e: TelegramApiException) {
e.printStackTrace()
}
} else {
val sum = try {
splitText[0].toInt()
} catch (e: NumberFormatException) {
throw TelegramBotException("Кажется первый параметр не цифра", chatId.toLong())
}
val textWOSum = splitText.drop(1)
var comment = ""
textWOSum.map { word ->
comment += "$word "
}
val message = SendMessage()
message.chatId = chatId
val msg = "Выберите категорию"
message.text = msg
message.replyMarkup = constructCategoriesButtons(true, text, chatId.toLong())
val userState = BotUserState(user = user)
val chatData =
userState.data.find { it.chatId == chatId } ?: ChatData(chatId, state = BotStates.WAIT_CATEGORY)
chatData.data["sum"] = sum.toString()
chatData.data["comment"] = comment
userState.data.add(chatData)
try {
execute(message)
setState(userState)
} catch (e: TelegramApiException) {
e.printStackTrace()
}
}
}
private suspend fun confirmTransaction(chatId: String, user: User, update: Update) {
val state = getState(user.id!!)
if (state == null) {
sendMessage(chatId, "Не можем найти информацию о сумме и комментарии")
return
}
val stateData = state.data.find { it.chatId == chatId }
if (stateData == null) {
sendMessage(chatId, "Не можем найти информацию о сумме и комментарии")
return
}
val category = categoriesService.getCategories(
"67af3c0f652da946a7dd9931", "EXPENSE", sortBy = "name", direction = "ASC"
).first { it.id == update.callbackQuery.data.split("_")[1] }
val space = spaceService.getSpace("67af3c0f652da946a7dd9931")
val instantType = financialService.getTransactionTypes().first { it.code == "INSTANT" }
val transaction = financialService.createTransaction(
space, transaction = Transaction(
space = space,
type = instantType,
user = user,
category = category,
comment = stateData.data["comment"]!!.trim(),
date = LocalDate.now(),
amount = stateData.data["sum"]!!.toDouble(),
isDone = true,
parentId = null,
createdAt = LocalDateTime.now()
), user = user
)
val editMsg = EditMessageText()
editMsg.chatId = chatId
editMsg.messageId = update.callbackQuery.message.messageId
editMsg.text = "Успешно создали транзакцию c id ${transaction.id}"
try {
execute(editMsg)
// execute(msg)
finishState(chatId, user)
} catch (e: TelegramApiException) {
e.printStackTrace()
logger.error(e.message)
}
}
private suspend fun getState(userId: String): BotUserState? {
val lookup = lookup("users", "user.\$id", "_id", "userDetails")
val unwind = unwind("userDetails")
val match = match(Criteria.where("userDetails._id").`is`(ObjectId(userId)))
val aggregation = newAggregation(lookup, unwind, match)
return reactiveMongoTemplate.aggregate(aggregation, "bot-user-states", Document::class.java).next().map { doc ->
val dataList = doc.getList("data", Document::class.java)
BotUserState(
id = doc.getObjectId("_id").toString(),
user = User(doc.get("userDetails", Document::class.java).getObjectId("_id").toString()),
data = dataList.map {
val data = it.get("data", Document::class.java)
ChatData(
chatId = it.getString("chatId"),
state = BotStates.valueOf(it.getString("state")),
data = (data.toMap().mapValues { it.value.toString() }.toMutableMap())
)
}.toMutableList(),
)
}.awaitSingleOrNull()
}
private suspend fun setState(userState: BotUserState): BotUserState {
val stateToSave = userState.user.id?.let { userId ->
getState(userId)?.copy(data = userState.data) ?: BotUserState(user = userState.user, data = userState.data)
} ?: BotUserState(user = userState.user, data = userState.data)
return botStatesRepo.save(stateToSave).awaitSingle()
}
private suspend fun finishState(chatId: String, user: User) {
val state = getState(user.id!!)
state?.data?.removeIf { it.chatId == chatId }
state?.let { botStatesRepo.save(state).awaitSingle() }
}
}

View File

@@ -3,6 +3,8 @@ package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.bson.Document import org.bson.Document
import org.bson.types.ObjectId import org.bson.types.ObjectId
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -16,10 +18,7 @@ import org.springframework.data.mongodb.core.query.isEqualTo
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
import space.luminic.budgerapp.mappers.CategoryMapper import space.luminic.budgerapp.mappers.CategoryMapper
import space.luminic.budgerapp.models.Category import space.luminic.budgerapp.models.*
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.Space
import space.luminic.budgerapp.repos.BudgetRepo import space.luminic.budgerapp.repos.BudgetRepo
import space.luminic.budgerapp.repos.CategoryRepo import space.luminic.budgerapp.repos.CategoryRepo
@@ -30,8 +29,8 @@ class CategoryService(
private val mongoTemplate: ReactiveMongoTemplate, private val mongoTemplate: ReactiveMongoTemplate,
private val categoryMapper: CategoryMapper, private val categoryMapper: CategoryMapper,
private val budgetRepo: BudgetRepo, private val budgetRepo: BudgetRepo,
private val nlpService: NLPService
) { ) {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
@@ -69,14 +68,15 @@ class CategoryService(
}.awaitFirstOrNull() ?: throw NotFoundException("Category not found") }.awaitFirstOrNull() ?: throw NotFoundException("Category not found")
} }
suspend fun getCategories(
fun getCategories(
spaceId: String, spaceId: String,
type: String? = null, type: String? = null,
sortBy: String, sortBy: String,
direction: String, direction: String,
tagCode: String? = null tagCode: String? = null,
): Mono<List<Category>> { predict: String? = null,
cloud: Int? = null
): MutableList<Category> {
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails") val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
val unwindSpace = unwind("spaceDetails") val unwindSpace = unwind("spaceDetails")
val matchCriteria = mutableListOf<Criteria>() val matchCriteria = mutableListOf<Criteria>()
@@ -101,7 +101,7 @@ class CategoryService(
).filterNotNull() ).filterNotNull()
val aggregation = newAggregation(aggregationBuilder) val aggregation = newAggregation(aggregationBuilder)
return mongoTemplate.aggregate( val categories = mongoTemplate.aggregate(
aggregation, "categories", Document::class.java aggregation, "categories", Document::class.java
) )
.collectList() // Преобразуем Flux<Transaction> в Mono<List<Transaction>> .collectList() // Преобразуем Flux<Transaction> в Mono<List<Transaction>>
@@ -109,9 +109,20 @@ class CategoryService(
docs.map { doc -> docs.map { doc ->
categoryMapper.fromDocument(doc) categoryMapper.fromDocument(doc)
} }
} }.awaitSingle().toMutableList()
val predictedCategories = mutableListOf<CategoryPrediction>()
if (!predict.isNullOrBlank() && cloud != null) {
predictedCategories.addAll(nlpService.predictCategory(predict, cloud))
}
val filteredCategories = mutableListOf<Category>()
for (category in predictedCategories) {
categories.find { it.id == category.category }?.let { filteredCategories.add(it) }
}
return if (filteredCategories.isEmpty()) categories else filteredCategories
} }
@Cacheable("categoryTypes") @Cacheable("categoryTypes")
fun getCategoryTypes(): List<CategoryType> { fun getCategoryTypes(): List<CategoryType> {
val types = mutableListOf<CategoryType>() val types = mutableListOf<CategoryType>()
@@ -121,7 +132,6 @@ class CategoryService(
} }
@CacheEvict(cacheNames = ["getAllCategories"], allEntries = true)
suspend fun editCategory(space: Space, category: Category): Category { suspend fun editCategory(space: Space, category: Category): Category {
val oldCategory = findCategory(space, id = category.id) val oldCategory = findCategory(space, id = category.id)
@@ -132,25 +142,29 @@ class CategoryService(
return categoryRepo.save(category).awaitSingle() // Сохраняем категорию, если тип не изменился return categoryRepo.save(category).awaitSingle() // Сохраняем категорию, если тип не изменился
} }
suspend fun deleteCategory(space: Space, categoryId: String) { suspend fun deleteCategory(space: Space, categoryId: String, author: User) {
findCategory(space, categoryId) findCategory(space, categoryId)
val transactions = financialService.getTransactions(space.id!!, categoryId = categoryId).awaitSingle() val transactions = financialService.getTransactions(space.id!!, categoryId = categoryId).awaitSingle()
val otherCategory = try { if (transactions.isNotEmpty()) {
findCategory(space, name = "Другое") val otherCategory = try {
} catch (nfe: NotFoundException) { findCategory(space, name = "Другое")
categoryRepo.save( } catch (nfe: NotFoundException) {
Category( categoryRepo.save(
space = space, Category(
type = CategoryType("EXPENSE", "Траты"), space = space,
name = "Другое", type = CategoryType("EXPENSE", "Траты"),
description = "Категория для других трат", name = "Другое",
icon = "🚮" description = "Категория для других трат",
) icon = "🚮"
).awaitSingle() )
} ).awaitSingle()
transactions.map { transaction -> }
transaction.category = otherCategory
financialService.editTransaction(transaction)
transactions.map { transaction ->
transaction.category = otherCategory
financialService.editTransaction(transaction, author)
}
} }
val budgets = financialService.findProjectedBudgets( val budgets = financialService.findProjectedBudgets(
ObjectId(space.id), ObjectId(space.id),
@@ -171,7 +185,7 @@ class CategoryService(
budget.categories.removeIf { it.category.id == categoryId } budget.categories.removeIf { it.category.id == categoryId }
budgetRepo.save(budget) budgetRepo.save(budget)
} }
categoryRepo.deleteById(categoryId).awaitSingle() categoryRepo.deleteById(categoryId).awaitSingleOrNull()
} }
} }

View File

@@ -1,11 +1,8 @@
package space.luminic.budgerapp.services package space.luminic.budgerapp.services
import kotlinx.coroutines.async import kotlinx.coroutines.*
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
@@ -23,7 +20,7 @@ import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.* import org.springframework.data.mongodb.core.aggregation.Aggregation.*
import org.springframework.data.mongodb.core.aggregation.DateOperators.DateToString import org.springframework.data.mongodb.core.aggregation.DateOperators.DateToString
import org.springframework.data.mongodb.core.query.Criteria import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
@@ -35,6 +32,7 @@ import space.luminic.budgerapp.repos.CategoryRepo
import space.luminic.budgerapp.repos.TransactionRepo import space.luminic.budgerapp.repos.TransactionRepo
import space.luminic.budgerapp.repos.WarnRepo import space.luminic.budgerapp.repos.WarnRepo
import java.time.* import java.time.*
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import java.util.* import java.util.*
@@ -48,7 +46,9 @@ class FinancialService(
val reactiveMongoTemplate: ReactiveMongoTemplate, val reactiveMongoTemplate: ReactiveMongoTemplate,
private val categoryRepo: CategoryRepo, private val categoryRepo: CategoryRepo,
val transactionsMapper: TransactionsMapper, val transactionsMapper: TransactionsMapper,
val budgetMapper: BudgetMapper val budgetMapper: BudgetMapper,
private val subscriptionService: SubscriptionService,
private val nlpService: NLPService
) { ) {
private val logger = LoggerFactory.getLogger(FinancialService::class.java) private val logger = LoggerFactory.getLogger(FinancialService::class.java)
@@ -57,20 +57,28 @@ class FinancialService(
val budget = findProjectedBudget( val budget = findProjectedBudget(
transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date transaction.space!!.id!!, budgetId = null, transaction.date, transaction.date
) )
val budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id } if (transaction.category.type.code == "EXPENSE") {
?: throw NotFoundException("Budget category not found in the budget") var budgetCategory = budget.categories.firstOrNull { it.category.id == transaction.category.id }
if (transaction.category.type.code == "INCOME") {
if (budgetCategory == null) {
budgetCategory = BudgetCategory(0.0, 0.0, 0.0, transaction.category)
budget.categories.add(budgetCategory)
budgetRepo.save(budget).awaitSingle()
}
if (transaction.category.type.code == "INCOME") {
budgetRepo.save(budget).awaitSingle()
}
val categorySums = getBudgetSumsByCategory(transaction.category.id!!, budget)
budgetCategory.currentPlanned = categorySums.getDouble("plannedAmount")
budgetCategory.currentSpent = categorySums.getDouble("instantAmount")
// При совпадении бюджетов разница просто корректирует лимит
if (transaction.type.code == "PLANNED") {
budgetCategory.currentLimit += transaction.amount
}
logger.info("updateBudgetOnCreate end")
budgetRepo.save(budget).awaitSingle() budgetRepo.save(budget).awaitSingle()
} }
val categorySums = getBudgetSumsByCategory(transaction.category.id!!, budget)
budgetCategory.currentPlanned = categorySums.getDouble("plannedAmount")
budgetCategory.currentSpent = categorySums.getDouble("instantAmount")
// При совпадении бюджетов разница просто корректирует лимит
if (transaction.type.code == "PLANNED") {
budgetCategory.currentLimit += transaction.amount
}
logger.info("updateBudgetOnCreate end")
budgetRepo.save(budget).awaitSingle()
} }
@@ -147,36 +155,50 @@ class FinancialService(
oldBudget: Budget, oldBudget: Budget,
newBudget: Budget newBudget: Budget
) = coroutineScope { ) = coroutineScope {
val oldCategory = findBudgetCategory(oldTransaction, oldBudget) // val oldCategory = findBudgetCategory(oldTransaction, oldBudget)
val newCategory = findBudgetCategory(newTransaction, newBudget) // val newCategory = findBudgetCategory(newTransaction, newBudget)
async {
updateBudgetCategory(
oldTransaction,
oldBudget,
-difference,
isCategoryChanged = true,
isOldCategory = true,
isNewBudget = true
)
updateBudgetCategory(newTransaction, newBudget, difference, isNewBudget = true)
}
}
private suspend fun findBudgetCategory(transaction: Transaction, budget: Budget): BudgetCategory {
return if (transaction.category.type.code == "EXPENSE") {
budget.categories.firstOrNull { it.category.id == transaction.category.id }
?: addCategoryToBudget(transaction.category, budget)
if (oldCategory.category.id == newCategory.category.id) {
async {
updateBudgetCategory(oldTransaction, oldBudget, -difference)
updateBudgetCategory(newTransaction, newBudget, difference)
}
} else { } else {
async { budget.incomeCategories.firstOrNull { it.category.id == transaction.category.id }
updateBudgetCategory( ?: addCategoryToBudget(transaction.category, budget)
oldTransaction,
oldBudget,
-difference,
isCategoryChanged = true,
isOldCategory = true
)
updateBudgetCategory(newTransaction, newBudget, difference)
}
} }
} }
private fun findBudgetCategory(transaction: Transaction, budget: Budget): BudgetCategory { private suspend fun addCategoryToBudget(category: Category, budget: Budget): BudgetCategory {
return if (transaction.category.type.code == "EXPENSE") { val sums = getBudgetSumsByCategory(category.id!!, budget)
budget.categories.firstOrNull { it.category.id == transaction.category.id } val categoryBudget = BudgetCategory(
?: throw RuntimeException("Budget category not found for expense") currentSpent = sums.getDouble("instantAmount"),
currentPlanned = sums.getDouble("plannedAmount"),
currentLimit = sums.getDouble("plannedAmount"),
category = category
)
if (category.type.code == "EXPENSE") {
budget.categories.add(categoryBudget)
} else { } else {
budget.incomeCategories.firstOrNull { it.category.id == transaction.category.id } budget.incomeCategories.add(categoryBudget)
?: throw RuntimeException("Budget category not found for income")
} }
budgetRepo.save(budget).awaitSingle()
return categoryBudget
} }
private suspend fun updateBudgetCategory( private suspend fun updateBudgetCategory(
@@ -184,22 +206,27 @@ class FinancialService(
budget: Budget, budget: Budget,
difference: Double, difference: Double,
isCategoryChanged: Boolean = false, isCategoryChanged: Boolean = false,
isOldCategory: Boolean = false isOldCategory: Boolean = false,
isNewBudget: Boolean = false
): Double { ): Double {
val sums = getBudgetSumsByCategory(transaction.category.id!!, budget) return if (transaction.category.type.code == "EXPENSE") {
val categoryBudget = budget.categories.firstOrNull { it.category.id == transaction.category.id } val sums = getBudgetSumsByCategory(transaction.category.id!!, budget)
?: throw NotFoundException("Not found category in budget") val categoryBudget = budget.categories.firstOrNull { it.category.id == transaction.category.id }
categoryBudget.currentPlanned = sums.getDouble("plannedAmount") ?: throw NotFoundException("Not found category in budget")
categoryBudget.currentSpent = sums.getDouble("instantAmount") categoryBudget.currentPlanned = sums.getDouble("plannedAmount")
if (transaction.type.code == "PLANNED") { categoryBudget.currentSpent = sums.getDouble("instantAmount")
if (isCategoryChanged) { if (transaction.type.code == "PLANNED") {
if (isOldCategory) { if (isCategoryChanged) {
categoryBudget.currentLimit -= transaction.amount if (isOldCategory) {
} else categoryBudget.currentLimit += transaction.amount categoryBudget.currentLimit -= transaction.amount
} else categoryBudget.currentLimit += difference } else categoryBudget.currentLimit += transaction.amount
} } else if (isNewBudget) {
budgetRepo.save(budget).awaitSingle() categoryBudget.currentLimit += transaction.amount
return 1.0 } else categoryBudget.currentLimit += difference
}
budgetRepo.save(budget).awaitSingle()
1.0
} else 0.0
} }
@@ -349,6 +376,7 @@ class FinancialService(
budgetDTO.plannedExpenses = transactions.get("plannedExpenses") as MutableList budgetDTO.plannedExpenses = transactions.get("plannedExpenses") as MutableList
budgetDTO.plannedIncomes = transactions["plannedIncomes"] as MutableList budgetDTO.plannedIncomes = transactions["plannedIncomes"] as MutableList
budgetDTO.transactions = transactions["instantTransactions"] as MutableList budgetDTO.transactions = transactions["instantTransactions"] as MutableList
logger.info("got budget for spaceId=$spaceId, id=$id")
budgetDTO budgetDTO
} }
@@ -734,8 +762,39 @@ class FinancialService(
} }
} }
suspend fun getAllTransactions(): List<Transaction>{
suspend fun getTransactionByParentId(parentId: String): Transaction { // Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwindCategory = unwind("categoryDetails")
val lookupSpaces = lookup("spaces", "space.\$id", "_id", "spaceDetails")
val unwindSpace = unwind("spaceDetails")
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
val unwindUser = unwind("userDetails")
val sort =
sort(Sort.by(Direction.DESC, "date").and(Sort.by(Direction.DESC, "createdAt")))
val aggregationBuilder = mutableListOf(
lookup,
unwindCategory,
lookupSpaces,
unwindSpace,
lookupUsers,
unwindUser,
sort
)
val aggregation = newAggregation(aggregationBuilder)
return reactiveMongoTemplate.aggregate(
aggregation, "transactions", Document::class.java
).collectList().awaitSingle().map { doc ->
transactionsMapper.fromDocument(doc)
}
}
suspend fun getTransactionByParentId(parentId: String): Transaction? {
// Сборка агрегации // Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails") val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwindCategory = unwind("categoryDetails") val unwindCategory = unwind("categoryDetails")
@@ -762,7 +821,7 @@ class FinancialService(
).map { doc -> ).map { doc ->
transactionsMapper.fromDocument(doc) transactionsMapper.fromDocument(doc)
}.awaitFirstOrNull() ?: throw NotFoundException("Child transaction with parent id $parentId not found") }.awaitFirstOrNull()
} }
@@ -825,24 +884,48 @@ class FinancialService(
} }
suspend fun createTransaction(space: Space, transaction: Transaction): Transaction { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val securityContextHolder = SecurityContextHolder.getContext()
val user = userService.getByUserNameWoPass(securityContextHolder.authentication.name) suspend fun createTransaction(space: Space, transaction: Transaction, user: User? = null): Transaction {
if (space.users.none { it.id.toString() == user.id }) { val author = user
?: userService.getByUserNameWoPass(
ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name
)
if (space.users.none { it.id.toString() == author.id }) {
throw IllegalArgumentException("User does not have access to this Space") throw IllegalArgumentException("User does not have access to this Space")
} }
// Привязываем space и user к транзакции // Привязываем space и user к транзакции
transaction.user = user transaction.user = author
transaction.space = space transaction.space = space
val savedTransaction = transactionsRepo.save(transaction).awaitSingle() val savedTransaction = transactionsRepo.save(transaction).awaitSingle()
updateBudgetOnCreate(savedTransaction) updateBudgetOnCreate(savedTransaction)
scope.launch {
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую"
subscriptionService.sendToSpaceOwner(
space.owner!!.id!!, PushMessage(
title = "Новая транзакция в пространстве ${space.name}!",
body = "Пользователь ${author.username} создал $transactionType транзакцию на сумму ${transaction.amount.toInt()} с комментарием ${transaction.comment} с датой ${
dateFormatter.format(
transaction.date
)
}",
url = "https://luminic.space/"
)
)
}
scope.launch {
nlpService.reteach()
}
return savedTransaction return savedTransaction
} }
@CacheEvict(cacheNames = ["transactions", "budgets"], allEntries = true) @CacheEvict(cacheNames = ["transactions", "budgets"], allEntries = true)
suspend fun editTransaction(transaction: Transaction): Transaction { suspend fun editTransaction(transaction: Transaction, author: User): Transaction {
val oldStateOfTransaction = getTransactionById(transaction.id!!) val oldStateOfTransaction = getTransactionById(transaction.id!!)
val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction) val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction)
if (!changed) { if (!changed) {
@@ -850,39 +933,90 @@ class FinancialService(
} }
val amountDifference = transaction.amount - oldStateOfTransaction.amount val amountDifference = transaction.amount - oldStateOfTransaction.amount
if (oldStateOfTransaction.isDone && oldStateOfTransaction.type.code == "PLANNED") { if (oldStateOfTransaction.type.code == "PLANNED") {
handleChildTransaction( handleChildTransaction(
oldStateOfTransaction, oldStateOfTransaction,
transaction transaction
) )
} }
val space = transaction.space
val savedTransaction = transactionsRepo.save(transaction).awaitSingle() val savedTransaction = transactionsRepo.save(transaction).awaitSingle()
updateBudgetOnEdit(oldStateOfTransaction, savedTransaction, amountDifference) updateBudgetOnEdit(oldStateOfTransaction, savedTransaction, amountDifference)
scope.launch {
var whatChanged = "nothing"
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
val transactionType = if (transaction.type.code == "INSTANT") "текущую" else "плановую"
val sb = StringBuilder()
if (oldStateOfTransaction.amount != transaction.amount) {
sb.append("${oldStateOfTransaction.amount}${transaction.amount}\n")
whatChanged = "main"
}
if (oldStateOfTransaction.comment != transaction.comment) {
sb.append("${oldStateOfTransaction.comment}${transaction.comment}\n")
whatChanged = "main"
}
if (oldStateOfTransaction.date != transaction.date) {
sb.append("${dateFormatter.format(oldStateOfTransaction.date)}${dateFormatter.format(transaction.date)}\n")
whatChanged = "main"
}
if (!oldStateOfTransaction.isDone && transaction.isDone) {
whatChanged = "done_true"
}
if (oldStateOfTransaction.isDone && !transaction.isDone) {
whatChanged = "done_false"
}
val body: String = when (whatChanged) {
"main" -> {
"Пользователь ${author.username} изменил $transactionType транзакцию:\n$sb"
}
"done_true" -> {
"Пользователь ${author.username} выполнил ${transaction.comment} с суммой ${transaction.amount.toInt()}"
}
"done_false" -> {
"Пользователь ${author.username} отменил выполнение ${transaction.comment} с суммой ${transaction.amount.toInt()}"
}
else -> "Изменения не обнаружены, но что то точно изменилось"
}
subscriptionService.sendToSpaceOwner(
space?.owner!!.id!!, PushMessage(
title = "Новое действие в пространстве ${space.name}!",
body = body,
url = "https://luminic.space/"
)
)
}
return savedTransaction return savedTransaction
} }
private suspend fun handleChildTransaction(oldTransaction: Transaction, newTransaction: Transaction) { private suspend fun handleChildTransaction(oldTransaction: Transaction, newTransaction: Transaction) {
val childTransaction = getTransactionByParentId(newTransaction.id!!)
logger.info("Updating child: $childTransaction")
val updatedChild = childTransaction.copy(
amount = newTransaction.amount,
category = newTransaction.category,
comment = newTransaction.comment,
user = newTransaction.user
)
transactionsRepo.save(updatedChild).awaitSingle()
if (!oldTransaction.isDone && newTransaction.isDone) { if (!oldTransaction.isDone && newTransaction.isDone) {
val newChildTransaction = newTransaction.copy( val newChildTransaction = newTransaction.copy(
id = null, type = TransactionType("INSTANT", "Текущие"), parentId = newTransaction.id id = null, type = TransactionType("INSTANT", "Текущие"), parentId = newTransaction.id
) )
transactionsRepo.save(newChildTransaction).awaitSingle() transactionsRepo.save(newChildTransaction).awaitSingle()
updateBudgetOnCreate(newChildTransaction) updateBudgetOnCreate(newChildTransaction)
} else if (oldTransaction.isDone && !newTransaction.isDone) { } else if (oldTransaction.isDone && !newTransaction.isDone) {
deleteTransaction(childTransaction) val childTransaction = getTransactionByParentId(newTransaction.id!!)
childTransaction?.let { deleteTransaction(it) }
} else {
val childTransaction = getTransactionByParentId(newTransaction.id!!)
childTransaction?.let {
logger.info("Updating child: $it")
it.amount = newTransaction.amount
it.category = newTransaction.category
it.comment = newTransaction.comment
it.user = newTransaction.user
transactionsRepo.save(it).awaitSingle()
}
} }
} }
@@ -903,7 +1037,7 @@ class FinancialService(
suspend fun deleteTransaction(transaction: Transaction) = coroutineScope { suspend fun deleteTransaction(transaction: Transaction) = coroutineScope {
transactionsRepo.deleteById(transaction.id!!).awaitSingle() transactionsRepo.deleteById(transaction.id!!).awaitFirstOrNull()
launch { updateBudgetOnDelete(transaction) } launch { updateBudgetOnDelete(transaction) }
} }

View File

@@ -0,0 +1,36 @@
package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.web.reactive.function.client.bodyToMono
import space.luminic.budgerapp.configs.NLPConfig
import space.luminic.budgerapp.models.CategoryPrediction
@Service
class NLPService(
private val nlpConfig: NLPConfig,
private val webClient: WebClient = WebClient.builder()
.baseUrl(nlpConfig.address)
// .defaultHeader("Authorization", "Bearer YOUR_API_KEY")
.build(),
) {
private val logging = LoggerFactory.getLogger(NLPService::class.java)
suspend fun reteach() {
logging.info("Reteaching NLP")
webClient.get().uri("/reteach").retrieve().bodyToMono<Void>().awaitSingleOrNull()
}
suspend fun predictCategory(category: String, cloud: Int): List<CategoryPrediction> {
val response = webClient.get().uri("/predict?req=$category&cloud=$cloud")
.retrieve()
.bodyToFlux(CategoryPrediction::class.java)
.collectList()
.awaitSingle()
return response
}
}

View File

@@ -1,6 +1,7 @@
package space.luminic.budgerapp.services package space.luminic.budgerapp.services
import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitLast import kotlinx.coroutines.reactive.awaitLast
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
@@ -36,7 +37,7 @@ class RecurrentService(
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)
fun getRecurrents(spaceId: String): Mono<List<Recurrent>> { suspend fun getRecurrents(spaceId: String): List<Recurrent> {
val lookupCategories = lookup("categories", "category.\$id", "_id", "categoryDetails") val lookupCategories = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwindCategory = unwind("categoryDetails") val unwindCategory = unwind("categoryDetails")
@@ -48,12 +49,13 @@ class RecurrentService(
val aggregation = val aggregation =
newAggregation(lookupCategories, unwindCategory, lookupSpace, unwindSpace, matchStage, sort) newAggregation(lookupCategories, unwindCategory, lookupSpace, unwindSpace, matchStage, sort)
// Запрос рекуррентных платежей // Запрос рекуррентных платежей
return reactiveMongoTemplate.aggregate(aggregation, "recurrents", Document::class.java).collectList() return reactiveMongoTemplate.aggregate(aggregation, "recurrents", Document::class.java)
.collectList()
.map { docs -> .map { docs ->
docs.map { doc -> docs.map { doc ->
recurrentMapper.fromDocument(doc) recurrentMapper.fromDocument(doc)
}.toList() }
} }.awaitSingle()
} }
@@ -82,35 +84,38 @@ class RecurrentService(
val context = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull() val context = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull()
?: throw IllegalStateException("SecurityContext is empty!") ?: throw IllegalStateException("SecurityContext is empty!")
val user = userService.getByUserNameWoPass(context.authentication.name) val user = userService.getByUserNameWoPass(context.authentication.name)
val recurrents = getRecurrents(space.id!!).awaitSingle() val recurrents = getRecurrents(space.id!!)
val transactions = recurrents.map { recurrent -> if (recurrents.isNotEmpty()) {
val transactionDate = when { val transactions = recurrents.map { recurrent ->
recurrent.atDay in budget.dateFrom.dayOfMonth..daysInCurrentMonth -> { val transactionDate = when {
currentYearMonth.atDay(recurrent.atDay) recurrent.atDay in budget.dateFrom.dayOfMonth..daysInCurrentMonth -> {
} currentYearMonth.atDay(recurrent.atDay)
}
recurrent.atDay < budget.dateFrom.dayOfMonth -> { recurrent.atDay < budget.dateFrom.dayOfMonth -> {
currentYearMonth.atDay(recurrent.atDay).plusMonths(1) currentYearMonth.atDay(recurrent.atDay).plusMonths(1)
} }
else -> { else -> {
val extraDays = recurrent.atDay - daysInCurrentMonth val extraDays = recurrent.atDay - daysInCurrentMonth
currentYearMonth.plusMonths(1).atDay(extraDays) currentYearMonth.plusMonths(1).atDay(extraDays)
}
} }
// Создаем транзакцию
Transaction(
space = space,
date = transactionDate,
amount = recurrent.amount.toDouble(),
category = recurrent.category,
isDone = false,
comment = recurrent.name,
user = user,
type = TransactionType("PLANNED", "Запланированные")
)
} }
// Создаем транзакцию transactionRepo.saveAll(transactions).awaitLast()
Transaction(
space = space,
date = transactionDate,
amount = recurrent.amount.toDouble(),
category = recurrent.category,
isDone = false,
comment = recurrent.name,
user = user,
type = TransactionType("PLANNED", "Запланированные")
)
} }
transactionRepo.saveAll(transactions).awaitLast()
} }
@@ -120,8 +125,8 @@ class RecurrentService(
) )
} }
fun deleteRecurrent(id: String): Mono<Void> { suspend fun deleteRecurrent(id: String) {
return recurrentRepo.deleteById(id) recurrentRepo.deleteById(id).awaitFirstOrNull()
} }

View File

@@ -4,6 +4,7 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.reactive.awaitFirst
import kotlinx.coroutines.reactive.awaitFirstOrNull import kotlinx.coroutines.reactive.awaitFirstOrNull
import kotlinx.coroutines.reactive.awaitLast
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.bson.Document import org.bson.Document
@@ -18,6 +19,7 @@ import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.* import space.luminic.budgerapp.models.*
import space.luminic.budgerapp.repos.* import space.luminic.budgerapp.repos.*
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import java.util.UUID import java.util.UUID
@Service @Service
@@ -35,14 +37,9 @@ class SpaceService(
private val tagRepo: TagRepo private val tagRepo: TagRepo
) { ) {
suspend fun isValidRequest(spaceId: String): Space {
val securityContextHolder = ReactiveSecurityContextHolder.getContext().awaitSingleOrNull()
?: throw AuthException("Authentication failed")
val authentication = securityContextHolder.authentication
val username = authentication.name
// Получаем пользователя по имени suspend fun isValidRequest(spaceId: String, user: User): Space {
val user = userService.getByUsername(username)
val space = getSpace(spaceId) val space = getSpace(spaceId)
// Проверяем доступ пользователя к пространству // Проверяем доступ пользователя к пространству
@@ -92,7 +89,8 @@ class SpaceService(
username = userDoc.getString("username"), username = userDoc.getString("username"),
firstName = userDoc.getString("firstName") firstName = userDoc.getString("firstName")
) )
}.toMutableList() }.toMutableList(),
createdAt = doc.getDate("createdAt").toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
) )
} }
}.awaitFirst() }.awaitFirst()
@@ -120,7 +118,7 @@ class SpaceService(
.map { category -> .map { category ->
category.copy(id = null, space = savedSpace) // Создаем новую копию category.copy(id = null, space = savedSpace) // Создаем новую копию
} }
categoryRepo.saveAll(categories).awaitSingle() categoryRepo.saveAll(categories).awaitLast()
savedSpace savedSpace
} }
@@ -142,12 +140,12 @@ class SpaceService(
launch { launch {
val categories = val categories =
categoryService.getCategories(objectId.toString(), null, "name", "ASC").awaitFirstOrNull().orEmpty() categoryService.getCategories(objectId.toString(), null, "name", "ASC")
categoryRepo.deleteAll(categories).awaitFirstOrNull() categoryRepo.deleteAll(categories).awaitFirstOrNull()
} }
launch { launch {
val recurrents = recurrentService.getRecurrents(objectId.toString()).awaitFirstOrNull().orEmpty() val recurrents = recurrentService.getRecurrents(objectId.toString())
recurrentRepo.deleteAll(recurrents).awaitFirstOrNull() recurrentRepo.deleteAll(recurrents).awaitFirstOrNull()
} }
} }
@@ -282,7 +280,6 @@ class SpaceService(
val existedTag = findTag(space, tagCode) ?: throw NoSuchElementException("Tag with code $tagCode not found") val existedTag = findTag(space, tagCode) ?: throw NoSuchElementException("Tag with code $tagCode not found")
val categoriesWithTag = val categoriesWithTag =
categoryService.getCategories(space.id!!, sortBy = "name", direction = "ASC", tagCode = existedTag.code) categoryService.getCategories(space.id!!, sortBy = "name", direction = "ASC", tagCode = existedTag.code)
.awaitSingleOrNull().orEmpty()
categoriesWithTag.map { cat -> categoriesWithTag.map { cat ->
cat.tags.removeIf { it.code == tagCode } // Изменяем список тегов cat.tags.removeIf { it.code == tagCode } // Изменяем список тегов
cat cat

View File

@@ -0,0 +1,29 @@
package space.luminic.budgerapp.services
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.reactor.awaitSingleOrNull
import kotlinx.coroutines.withContext
import org.springframework.http.codec.multipart.FilePart
import org.springframework.stereotype.Service
import space.luminic.budgerapp.configs.StorageConfig
import java.nio.file.Files
import java.nio.file.Paths
import java.util.*
@Service
class StaticService(private val storageConfig: StorageConfig) {
suspend fun saveFile(spaceId: String, wishListItemId: String, filePart: FilePart): String {
val folder = Paths.get(storageConfig.rootLocation.toString(), spaceId, "wishlists", wishListItemId)
withContext(Dispatchers.IO) {
Files.createDirectories(folder)
}
val filename = UUID.randomUUID().toString().split("-")[0] + "." + filePart.filename().split(".").last()
val filePath =
folder.resolve(filename)
filePart.transferTo(filePath).awaitSingleOrNull()
return filePath.toString()
}
}

View File

@@ -3,13 +3,16 @@ package space.luminic.budgerapp.services
import com.interaso.webpush.VapidKeys import com.interaso.webpush.VapidKeys
import com.interaso.webpush.WebPushService import com.interaso.webpush.WebPushService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.awaitSingle import kotlinx.coroutines.reactive.awaitSingle
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.bson.types.ObjectId
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.PushMessage import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.Subscription import space.luminic.budgerapp.models.Subscription
import space.luminic.budgerapp.models.SubscriptionDTO import space.luminic.budgerapp.models.SubscriptionDTO
@@ -36,38 +39,50 @@ class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) {
vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) vapidKeys = VapidKeys.fromUncompressedBytes(VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
) )
fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage): Mono<Void> {
return Mono.fromRunnable<Void> { suspend fun sendToSpaceOwner(ownerId: String, message: PushMessage) = coroutineScope {
val ownerTokens = subscriptionRepo.findByUserIdAndIsActive(ObjectId(ownerId)).collectList().awaitSingle()
ownerTokens.forEach { token ->
launch(Dispatchers.IO) { // Теперь мы точно в корутин скоупе
try {
sendNotification(token.endpoint, token.p256dh, token.auth, message)
} catch (e: Exception) {
logger.error("Ошибка при отправке уведомления: ${e.message}", e)
}
}
}
}
suspend fun sendNotification(endpoint: String, p256dh: String, auth: String, payload: PushMessage) {
try {
pushService.send( pushService.send(
payload = Json.encodeToString(payload), payload = Json.encodeToString(payload),
endpoint = endpoint, endpoint = endpoint,
p256dh = p256dh, p256dh = p256dh,
auth = auth auth = auth
) )
logger.info("Уведомление успешно отправлено на endpoint: $endpoint")
} catch (e: Exception) {
logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}")
throw e
} }
.doOnSuccess {
logger.info("Уведомление успешно отправлено на endpoint: $endpoint")
}
.doOnError { e ->
logger.error("Ошибка при отправке уведомления на endpoint $endpoint: ${e.message}")
}
.onErrorResume { e ->
Mono.error(e) // Пробрасываем ошибку дальше, если нужна обработка выше
}
} }
fun sendToAll(payload: PushMessage): Mono<List<String>> { suspend fun sendToAll(payload: PushMessage) {
return subscriptionRepo.findAll()
.flatMap { sub -> subscriptionRepo.findAll().collectList().awaitSingle().forEach { sub ->
try {
sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload) sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload)
.then(Mono.just("${sub.user?.username} at endpoint ${sub.endpoint}")) } catch (e: Exception) {
.onErrorResume { e -> sub.isActive = false
sub.isActive = false subscriptionRepo.save(sub).awaitSingle()
subscriptionRepo.save(sub).then(Mono.empty())
}
} }
.collectList() // Собираем результаты в список }
} }

View File

@@ -1,5 +1,6 @@
package space.luminic.budgerapp.services package space.luminic.budgerapp.services
import kotlinx.coroutines.reactor.awaitSingle
import org.springframework.cache.annotation.CacheEvict import org.springframework.cache.annotation.CacheEvict
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Mono import reactor.core.publisher.Mono
@@ -12,14 +13,14 @@ import java.time.LocalDateTime
class TokenService(private val tokenRepository: TokenRepo) { class TokenService(private val tokenRepository: TokenRepo) {
@CacheEvict("tokens", allEntries = true) @CacheEvict("tokens", allEntries = true)
fun saveToken(token: String, username: String, expiresAt: LocalDateTime): Mono<Token> { suspend fun saveToken(token: String, username: String, expiresAt: LocalDateTime): Token {
val newToken = Token( val newToken = Token(
token = token, token = token,
username = username, username = username,
issuedAt = LocalDateTime.now(), issuedAt = LocalDateTime.now(),
expiresAt = expiresAt expiresAt = expiresAt
) )
return tokenRepository.save(newToken) return tokenRepository.save(newToken).awaitSingle()
} }
fun getToken(token: String): Mono<Token> { fun getToken(token: String): Mono<Token> {

View File

@@ -29,8 +29,12 @@ class UserService(val userRepo: UserRepo) {
.switchIfEmpty(Mono.error(Exception("User not found"))) // Обрабатываем случай, когда пользователь не найден .switchIfEmpty(Mono.error(Exception("User not found"))) // Обрабатываем случай, когда пользователь не найден
} }
suspend fun getUserByTelegramId(telegramId: Long): User? {
return userRepo.findByTgId(telegramId.toString()).awaitSingleOrNull()
}
@Cacheable("users", key = "#username")
@Cacheable("users", key = "#username")
suspend fun getByUserNameWoPass(username: String): User { suspend fun getByUserNameWoPass(username: String): User {
return userRepo.findByUsernameWOPassword(username).awaitSingleOrNull() return userRepo.findByUsernameWOPassword(username).awaitSingleOrNull()
?: throw NotFoundException("User with username: $username not found") ?: throw NotFoundException("User with username: $username not found")

View File

@@ -0,0 +1,135 @@
package space.luminic.budgerapp.services
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.bson.Document
import org.bson.types.ObjectId
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.*
import org.springframework.data.mongodb.core.aggregation.AggregationOperation
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service
import space.luminic.budgerapp.mappers.WishListMapper
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.Space
import space.luminic.budgerapp.models.WishList
import space.luminic.budgerapp.models.WishListItem
import space.luminic.budgerapp.repos.WishListItemRepo
import space.luminic.budgerapp.repos.WishListRepo
import java.time.LocalDateTime
@Service
class WishListService(
private val reactiveMongoTemplate: ReactiveMongoTemplate,
private val wishListMapper: WishListMapper,
private val wishListRepo: WishListRepo,
private val wishListItemRepo: WishListItemRepo,
private val userService: UserService
) {
private fun getLookupsAndUnwinds(): List<AggregationOperation> {
val aggregationOperation = mutableListOf<AggregationOperation>()
aggregationOperation.add(lookup("spaces", "space.\$id", "_id", "spaceDetails"))
aggregationOperation.add(unwind("spaceDetails"))
aggregationOperation.add(lookup("users", "owner.\$id", "_id", "ownerDetails"))
aggregationOperation.add(unwind("ownerDetails"))
aggregationOperation.add(lookup("wishlistItems", "items.\$id", "_id", "itemsDetails"))
return aggregationOperation
}
suspend fun findWishLists(spaceId: String): List<WishList> {
val user = userService.getByUserNameWoPass(
ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name
)
val match = match(
Criteria.where("spaceDetails._id").`is`(ObjectId(spaceId))
.andOperator(
Criteria().orOperator(
Criteria.where("ownerDetails._id").`is`(ObjectId(user.id)),
Criteria.where("isPrivate").`is`(false)
)
)
)
val aggregation = newAggregation(*(getLookupsAndUnwinds().toTypedArray()), match)
return reactiveMongoTemplate.aggregate(aggregation, "wishlists", Document::class.java)
.asFlow().map {
wishListMapper.fromDocument(it)
}.toList()
}
suspend fun getList(listId: String, isActive: Boolean = true): WishList {
val user = userService.getByUserNameWoPass(
ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name
)
val match = match(
Criteria.where("_id").`is`(ObjectId(listId))
// .andOperator(Criteria.where("ownerDetails._id").`is`(ObjectId(user.id)))
// .andOperator(Criteria.where("isActive").`is`(isActive))
)
val aggregation = newAggregation(*(getLookupsAndUnwinds().toTypedArray()), match)
// val aggregation = newAggregation(lookupSpace, unwindSpace, lookupOwner, unwindOwner, lookupItems, match)
return reactiveMongoTemplate.aggregate(aggregation, "wishlists", Document::class.java)
.next().map {
wishListMapper.fromDocument(it)
}.awaitSingleOrNull() ?: throw NotFoundException("WishList with id $listId does not exist")
}
suspend fun createWishList(space: Space, wishList: WishList): WishList {
val user = userService.getByUserNameWoPass(
ReactiveSecurityContextHolder.getContext().awaitSingle().authentication.name
)
wishList.owner = user
wishList.space = space
return wishListRepo.save(wishList).awaitSingle()
}
suspend fun updateWishListInfo(wishList: WishList): WishList {
val oldStateOfWishList = getList(wishList.id!!)
val newStateOfWishList = oldStateOfWishList.copy(
name = wishList.name,
description = wishList.description,
isPrivate = wishList.isPrivate,
updatedAt = LocalDateTime.now()
)
return wishListRepo.save(newStateOfWishList).awaitSingle()
}
suspend fun updateWishListItemInfo(wishListId: String, wishListItem: WishListItem): WishList {
wishListItemRepo.save(wishListItem).awaitSingle()
return getList(wishListId)
}
suspend fun addItemToWishList(wishListId: String, item: WishListItem): WishList {
val wishList = getList(wishListId)
val savedItem = wishListItemRepo.save(item).awaitSingle()
wishList.items.add(savedItem)
return wishListRepo.save(wishList).awaitSingle()
}
suspend fun removeItemFromWishList(wishListId: String, itemId: String): WishList {
val wishList = getList(wishListId)
return if (wishList.items.removeIf { it.id == itemId }) wishListRepo.save(wishList).awaitSingle() else wishList
}
suspend fun deleteWishList(wishListId: String) {
wishListRepo.deleteById(wishListId).awaitSingleOrNull()
}
suspend fun cancelReserve(wishListId: String, wishlistItemId: String): WishList {
val wishList = getList(wishListId)
val wishlistItem = wishList.items.first { it.id == wishlistItemId }
return if (wishlistItem.reservedBy != null) {
wishlistItem.reservedBy = null
wishListItemRepo.save(wishlistItem).awaitSingle()
wishList
} else wishList
}
}

View File

@@ -0,0 +1,94 @@
package space.luminic.budgerapp.services
import com.mongodb.client.model.Filters.and
import com.mongodb.client.model.Filters.eq
import kotlinx.coroutines.reactor.awaitSingle
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.bson.Document
import org.bson.types.ObjectId
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation
import org.springframework.data.mongodb.core.aggregation.Aggregation.*
import org.springframework.data.mongodb.core.aggregation.AggregationOperation
import org.springframework.data.mongodb.core.aggregation.LookupOperation
import org.springframework.data.mongodb.core.aggregation.VariableOperators
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Criteria.expr
import org.springframework.stereotype.Service
import space.luminic.budgerapp.mappers.WishListMapper
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.Reserve
import space.luminic.budgerapp.models.WishList
import space.luminic.budgerapp.models.WishListItem
import space.luminic.budgerapp.repos.WishListItemRepo
import java.time.LocalDateTime
@Service
class WishlistExternalService(
private val reactiveMongoTemplate: ReactiveMongoTemplate,
private val wishListMapper: WishListMapper,
private val wishListItemRepo: WishListItemRepo
) {
suspend fun getWishListInfo(wishListId: String): WishList {
val lookupSpace = lookup("spaces", "space.\$id", "_id", "spaceDetails")
val unwindSpace = unwind("spaceDetails")
val lookupOwner = lookup("users", "owner.\$id", "_id", "ownerDetails")
val unwindOwner = unwind("ownerDetails")
// val lookupItems = lookup("wishlistItems", "items.\$id", "_id", "itemsDetails")
// Расширенный lookup с фильтром isActive == true
val lookupItems = AggregationOperation { context ->
Document(
"\$lookup", Document()
.append("from", "wishlistItems")
.append("let", Document("itemIds", "\$items.\$id"))
.append("pipeline", listOf(
Document(
"\$match", Document(
"\$expr", Document(
"\$and", listOf(
Document("\$in", listOf("\$_id", Document("\$map", Document()
.append("input", "\$\$itemIds")
.append("as", "id")
.append("in", Document("\$toObjectId", "\$\$id"))
))),
Document("\$eq", listOf("\$isActive", true))
)
)
)
)
))
.append("as", "itemsDetails")
)
}
val match = match(
Criteria.where("_id").`is`(ObjectId(wishListId))
)
val aggregation = newAggregation(lookupSpace, unwindSpace, lookupOwner, unwindOwner, lookupItems, match)
return reactiveMongoTemplate.aggregate(aggregation, "wishlists", Document::class.java)
.next().map {
wishListMapper.fromDocument(it)
}.awaitSingleOrNull() ?: throw NotFoundException("WishList with id $wishListId does not exist")
}
suspend fun reserveWishlistItem(wishListId: String, wishlistItemId: String, reservedBy: Reserve): WishListItem {
val wishlist = getWishListInfo(wishListId)
val wishlistItem = wishlist.items.first { wishlistItem -> wishlistItem.id == wishlistItemId }
return if (wishlistItem.reservedBy == null) {
wishlistItem.reservedBy = reservedBy
wishlistItem.updatedAt = LocalDateTime.now()
wishListItemRepo.save(wishlistItem).awaitSingle()
} else throw IllegalArgumentException("Wishlist item already reserved")
}
suspend fun cancelReserve(wishListId: String, wishlistItemId: String, reservedBy: Reserve): WishListItem {
val wishlist = getWishListInfo(wishListId)
val wishlistItem = wishlist.items.first { wishlistItem -> wishlistItem.id == wishlistItemId }
return if (wishlistItem.reservedBy?.aid == reservedBy.aid) {
wishlistItem.reservedBy = null
wishListItemRepo.save(wishlistItem).awaitSingle()
} else if (wishlistItem.reservedBy == null) {
wishlistItem
} else throw IllegalArgumentException("Вы не можете отменить не свою бронь")
}
}

View File

@@ -24,14 +24,6 @@ class JWTUtil(private val tokenService: TokenService) {
.setExpiration(expireAt) // 10 дней .setExpiration(expireAt) // 10 дней
.signWith(key) .signWith(key)
.compact() .compact()
tokenService.saveToken(
token,
username,
LocalDateTime.from(
expireAt.toInstant().atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
)
return token return token
} }

View File

@@ -14,7 +14,7 @@ class ScheduledTasks(private val subscriptionService: SubscriptionService,
private val logger = LoggerFactory.getLogger(ScheduledTasks::class.java) private val logger = LoggerFactory.getLogger(ScheduledTasks::class.java)
@Scheduled(cron = "0 30 19 * * *") @Scheduled(cron = "0 30 19 * * *")
fun sendNotificationOfMoneyFilling() { suspend fun sendNotificationOfMoneyFilling() {
subscriptionService.sendToAll( subscriptionService.sendToAll(
PushMessage( PushMessage(
title = "Время заполнять траты!🤑", title = "Время заполнять траты!🤑",
@@ -23,13 +23,6 @@ class ScheduledTasks(private val subscriptionService: SubscriptionService,
badge = "/apple-touch-icon.png", badge = "/apple-touch-icon.png",
url = "https://luminic.space/transactions/create" url = "https://luminic.space/transactions/create"
) )
).doOnNext { responses ->
responses.forEach { response ->
logger.info("Уведомление отправлено: $response")
}
}.subscribe(
{ logger.info("Все уведомления отправлены.") },
{ error -> logger.error("Ошибка при отправке уведомлений: ${error.message}", error) }
) )
} }

View File

@@ -17,11 +17,4 @@ spring.datasource.url=jdbc:postgresql://213.183.51.243/familybudget_app
spring.datasource.username=familybudget_app spring.datasource.username=familybudget_app
spring.datasource.password=FB1q2w3e4r! spring.datasource.password=FB1q2w3e4r!
telegram.bot.token = 6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
# ??????? JDBC
spring.datasource.driver-class-name=org.postgresql.Driver
# ????????? Hibernate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=none

View File

@@ -1,27 +1,9 @@
spring.application.name=budger-app spring.application.name=budger-app
spring.data.mongodb.uri=mongodb://budger-app:BA1q2w3e4r!@luminic.space:27017/budger-app?authSource=admin&minPoolSize=10&maxPoolSize=100
spring.data.mongodb.host=77.222.32.64
spring.data.mongodb.port=27017
spring.data.mongodb.database=budger-app
#spring.data.mongodb.username=budger-app #spring.data.mongodb.username=budger-app
#spring.data.mongodb.password=BA1q2w3e4r! #spring.data.mongodb.password=BA1q2w3e4r!
#spring.data.mongodb.authentication-database=admin #spring.data.mongodb.authentication-database=admin
management.endpoints.web.exposure.include=* management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always management.endpoint.health.show-details=always
telegram.bot.token=6972242509:AAGyXuL3T-BNE4XMoo_qvtaYxw_SuiS_dDs
nlp.address=http://127.0.0.1:8000
spring.datasource.url=jdbc:postgresql://213.183.51.243/familybudget_app
spring.datasource.username=familybudget_app
spring.datasource.password=FB1q2w3e4r!
# ??????? JDBC
spring.datasource.driver-class-name=org.postgresql.Driver
# ????????? Hibernate
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=none

View File

@@ -16,5 +16,5 @@ logging.level.org.springframework.data.mongodb.code = DEBUG
#management.endpoint.metrics.access=read_only #management.endpoint.metrics.access=read_only
telegram.bot.token = 6662300972:AAFXjk_h0AUCy4bORC12UcdXbYnh2QSVKAY
nlp.address=https://nlp.luminic.space

View File

@@ -7,8 +7,6 @@ spring.webflux.base-path=/api
spring.profiles.active=prod spring.profiles.active=prod
spring.main.web-application-type=reactive spring.main.web-application-type=reactive
logging.level.org.springframework.web=INFO logging.level.org.springframework.web=INFO
logging.level.org.springframework.data = INFO logging.level.org.springframework.data = INFO
logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=INFO logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=INFO
@@ -20,6 +18,12 @@ logging.level.org.mongodb.driver.protocol.command = INFO
server.compression.enabled=true server.compression.enabled=true
server.compression.mime-types=application/json server.compression.mime-types=application/json
# ???????????? ?????? ????? (?? ????????? 1 ??)
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
storage.location: static
# Expose prometheus, health, and info endpoints # Expose prometheus, health, and info endpoints
#management.endpoints.web.exposure.include=prometheus,health,info #management.endpoints.web.exposure.include=prometheus,health,info
@@ -28,3 +32,6 @@ management.endpoints.web.exposure.include=*
# Enable Prometheus metrics export # Enable Prometheus metrics export
management.prometheus.metrics.export.enabled=true management.prometheus.metrics.export.enabled=true
telegram.bot.username = expenses_diary_bot