This commit is contained in:
Vladimir Voronin
2025-01-07 12:35:17 +03:00
commit afd8e9f6d7
72 changed files with 4606 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
package space.luminic.budgerapp
import com.interaso.webpush.VapidKeys
//import org.apache.tomcat.util.codec.binary.Base64.encodeBase64
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.cache.annotation.EnableCaching
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.web.reactive.config.EnableWebFlux
import java.security.Security
import java.util.TimeZone
@SpringBootApplication
@EnableCaching
@EnableAsync
@EnableMongoRepositories(basePackages = ["space.luminic.budgerapp.repos"])
class BudgerAppApplication
fun main(args: Array<String>) {
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Moscow"))
runApplication<BudgerAppApplication>(*args)
}

View File

@@ -0,0 +1,20 @@
package space.luminic.budgerapp.configs
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.Executor
//
//@Configuration
//class AsyncConfig {
// @Bean(name = ["taskExecutor"])
// fun taskExecutor(): Executor {
// val executor = ThreadPoolTaskExecutor()
// executor.corePoolSize = 5
// executor.maxPoolSize = 10
// executor.setQueueCapacity(25)
// executor.setThreadNamePrefix("Async-")
// executor.initialize()
// return executor
// }
//}

View File

@@ -0,0 +1,60 @@
package space.luminic.budgerapp.configs
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.BadCredentialsException
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.core.context.SecurityContextImpl
import org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import org.springframework.web.server.WebFilterChain
import reactor.core.publisher.Mono
import space.luminic.budgerapp.services.AuthService
@Component
class BearerTokenFilter(private val authService: AuthService) : SecurityContextServerWebExchangeWebFilter() {
private val logger = LoggerFactory.getLogger(BearerTokenFilter::class.java)
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val token = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)?.removePrefix("Bearer ")
if (exchange.request.path.value() == "/api/auth/login"){
return chain.filter(exchange)
}
return if (token != null) {
authService.isTokenValid(token)
.flatMap { userDetails ->
val authorities = userDetails.roles.map { SimpleGrantedAuthority(it) }
val securityContext = SecurityContextImpl(
UsernamePasswordAuthenticationToken(
userDetails.username, null, authorities
)
)
chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(securityContext)))
}
.onErrorMap(AuthException::class.java) { ex ->
BadCredentialsException(ex.message ?: "Unauthorized")
}
} else {
Mono.error(AuthException("Authorization token is missing"))
}
}
}
open class AuthException(msg: String) : RuntimeException(msg)

View File

@@ -0,0 +1,18 @@
package space.luminic.budgerapp.configs
import org.springframework.context.annotation.Configuration
import org.springframework.web.reactive.config.CorsRegistry
import org.springframework.web.reactive.config.WebFluxConfigurer
@Configuration
class CorsConfig : WebFluxConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**") // Разрешить все пути
.allowedOrigins("http://localhost:5173") // Разрешить домен localhost:5173
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") // Разрешить методы
.allowedHeaders("*") // Разрешить все заголовки
.allowCredentials(true) // Разрешить передачу cookies
}
}

View File

@@ -0,0 +1,44 @@
//package space.luminic.budgerapp.configs
//import org.springframework.http.HttpHeaders
//import org.springframework.http.HttpStatus
//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
//import org.springframework.security.core.context.SecurityContextHolder
//import org.springframework.stereotype.Component
//import org.springframework.web.server.ServerWebExchange
//import org.springframework.web.server.WebFilter
//import org.springframework.web.server.WebFilterChain
//import reactor.core.publisher.Mono
//import space.luminic.budgerapp.services.AuthService
//import space.luminic.budgerapp.services.TokenService
//import space.luminic.budgerapp.utils.JWTUtil
//
//@Component
//class JWTAuthFilter(
// private val jwtUtil: JWTUtil,
// private val authService: AuthService
//) : WebFilter {
//
// override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
// val authHeader = exchange.request.headers.getFirst(HttpHeaders.AUTHORIZATION)
//
// if (authHeader != null && authHeader.startsWith("Bearer ")) {
// val token = authHeader.substring(7)
// return Mono.just(token)
// .filter { authService.isTokenValid(it) }
// .flatMap { validToken ->
// val username = jwtUtil.validateToken(validToken)
// if (username != null) {
// val auth = UsernamePasswordAuthenticationToken(username, null, emptyList())
// SecurityContextHolder.getContext().authentication = auth
// }
// chain.filter(exchange)
// }
// .onErrorResume {
// exchange.response.statusCode = HttpStatus.UNAUTHORIZED
// exchange.response.setComplete()
// }
// }
//
// return chain.filter(exchange)
// }
//}

View File

@@ -0,0 +1,33 @@
//package space.luminic.budgerapp.configs
//
//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
//import org.springframework.security.authentication.ReactiveAuthenticationManager
//import org.springframework.security.core.Authentication
//import org.springframework.security.core.userdetails.ReactiveUserDetailsService
//import org.springframework.security.crypto.password.PasswordEncoder
//import org.springframework.stereotype.Component
//import reactor.core.publisher.Mono
//import space.luminic.budgerapp.services.CustomReactiveUserDetailsService
//import space.luminic.budgerapp.services.TokenService
//import space.luminic.budgerapp.utils.JWTUtil
//
//@Component
//class JWTReactiveAuthenticationManager(
// private val passwordEncoder: PasswordEncoder,
// private val userDetailsService: CustomReactiveUserDetailsService
//) : ReactiveAuthenticationManager {
//
//
// override fun authenticate(authentication: Authentication): Mono<Authentication> {
// val username = authentication.principal as String
// val password = authentication.credentials as String
//
// return userDetailsService.findByUsername(username)
// .filter { userDetails -> password == passwordEncoder.encode(userDetails.password) } // Пример проверки пароля
// .map { userDetails ->
// UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
// }
// }
//
//
//}

View File

@@ -0,0 +1,62 @@
package space.luminic.budgerapp.configs
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpMethod
import org.springframework.security.config.web.server.SecurityWebFiltersOrder
import org.springframework.security.config.web.server.ServerHttpSecurity
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.web.server.SecurityWebFilterChain
import space.luminic.budgerapp.controllers.CustomAuthenticationEntryPoint
import space.luminic.budgerapp.services.AuthService
@Configuration
class SecurityConfig(
private val authService: AuthService
) {
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
bearerTokenFilter: BearerTokenFilter,
customAuthenticationEntryPoint: CustomAuthenticationEntryPoint
): SecurityWebFilterChain {
return http
.csrf { it.disable() }
.cors { it.configurationSource(corsConfigurationSource()) }
.logout { it.disable() }
.authorizeExchange {
it.pathMatchers(HttpMethod.POST, "/auth/login").permitAll()
it.pathMatchers("/actuator/**").permitAll()
it.anyExchange().authenticated()
}
.addFilterAt(
bearerTokenFilter,
SecurityWebFiltersOrder.AUTHENTICATION
) // BearerTokenFilter только для authenticated
.build()
}
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
}
@Bean
fun corsConfigurationSource(): org.springframework.web.cors.reactive.CorsConfigurationSource {
val corsConfig = org.springframework.web.cors.CorsConfiguration()
corsConfig.allowedOrigins =
listOf("https://luminic.space", "http://localhost:5173") // Ваши разрешённые источники
corsConfig.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
corsConfig.allowedHeaders = listOf("*")
corsConfig.allowCredentials = true
val source = org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", corsConfig)
return source
}
}

View File

@@ -0,0 +1,45 @@
package space.luminic.budgerapp.controllers
import org.springframework.http.ResponseEntity
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.*
import reactor.core.publisher.Mono
//import space.luminic.budgerapp.configs.JWTReactiveAuthenticationManager
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.services.AuthService
import space.luminic.budgerapp.services.UserService
import space.luminic.budgerapp.utils.JWTUtil
@RestController
@RequestMapping("/auth")
class AuthController(
// private val authenticationManager: JWTReactiveAuthenticationManager,
private val jwtUtil: JWTUtil,
private val userService: UserService,
private val authService: AuthService
) {
@PostMapping("/login")
fun login(@RequestBody request: AuthRequest): Mono<Map<String, String>> {
return authService.login(request.username, request.password)
.map { token -> mapOf("token" to token) }
}
@GetMapping("/me")
fun getMe(@RequestHeader("Authorization") token: String): Mono<User> {
return authService.isTokenValid(token.removePrefix("Bearer "))
.flatMap { username ->
userService.getByUserNameWoPass(username.username)
}
// return mapOf(
// "username" to authentication.name,
// "details" to authentication.details,
// "roles" to authentication.authorities.map { it.authority }
// )
}
}
data class AuthRequest(val username: String, val password: String)

View File

@@ -0,0 +1,112 @@
package space.luminic.budgerapp.controllers
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.BudgetDTO
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.Warn
import space.luminic.budgerapp.services.BudgetService
import space.luminic.budgerapp.services.CacheInspector
import java.math.BigDecimal
import java.sql.Date
import java.time.LocalDate
@RestController
@RequestMapping("/budgets")
class BudgetController(
val budgetService: BudgetService
) {
private val logger = LoggerFactory.getLogger(BudgetController::class.java)
@GetMapping
fun getBudgets(): Mono<MutableList<Budget>> {
return budgetService.getBudgets()
}
@GetMapping("/{id}")
fun getBudget(@PathVariable id: String): Mono<BudgetDTO> {
// logger.info(cacheInspector.getCacheContent("budgets").toString())
return budgetService.getBudget(id)
}
@GetMapping("/by-dates")
fun getBudgetByDate(@RequestParam date: LocalDate): ResponseEntity<Any> {
return ResponseEntity.ok(budgetService.getBudgetByDate(date))
}
@GetMapping("/{id}/categories")
fun getBudgetCategories(@PathVariable id: String): ResponseEntity<Any> {
return ResponseEntity.ok(budgetService.getBudgetCategories(id))
}
@GetMapping("/{id}/transactions")
fun getBudgetTransactions(@PathVariable id: String):Mono<Map<String,List<Transaction>>> {
return budgetService.getBudgetTransactionsByType(id)
}
@PostMapping("/")
fun createBudget(@RequestBody budgetCreationDTO: BudgetCreationDTO): Mono<Budget> {
return budgetService.createBudget(
budgetCreationDTO.budget,
budgetCreationDTO.createRecurrent
)
}
@DeleteMapping("/{id}")
fun deleteBudget(@PathVariable id: String): Mono<Void> {
return budgetService.deleteBudget(id)
}
@PostMapping("/{budgetId}/categories/{catId}/limit")
fun setCategoryLimit(
@PathVariable budgetId: String,
@PathVariable catId: String,
@RequestBody limit: LimitValue,
): ResponseEntity<Any> {
return try {
ResponseEntity.ok(budgetService.setCategoryLimit(budgetId, catId, limit.limit))
} catch (e: Exception) {
ResponseEntity.badRequest().body(e.message)
}
}
@GetMapping("/recalc-categories")
fun recalcCategories(): Mono<Void> {
return budgetService.recalcBudgetCategory()
}
@GetMapping("/{id}/warns")
fun budgetWarns(@PathVariable id: String, @RequestParam hidden: Boolean? = null): Mono<List<Warn>> {
return budgetService.getWarns(id, hidden)
}
@PostMapping("/{id}/warns/{warnId}/hide")
fun setWarnHide(@PathVariable id: String, @PathVariable warnId: String): Mono<Warn> {
return budgetService.hideWarn(id, warnId)
}
data class LimitValue(
var limit: Double
)
}

View File

@@ -0,0 +1,64 @@
package space.luminic.budgerapp.controllers
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
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.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.RestController
import org.springframework.web.client.HttpClientErrorException
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.services.CategoryService
@RestController
@RequestMapping("/categories")
class CategoriesController(
private val categoryService: CategoryService
) {
private val logger = LoggerFactory.getLogger(javaClass)
@GetMapping()
fun getCategories(): ResponseEntity<Any> {
return try {
ResponseEntity.ok(categoryService.getCategories())
} catch (e: Exception) {
ResponseEntity(HttpClientErrorException(HttpStatus.INTERNAL_SERVER_ERROR), HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@GetMapping("/types")
fun getCategoriesTypes(): ResponseEntity<Any> {
return try {
ResponseEntity.ok(categoryService.getCategoryTypes())
}catch (e: Exception) {
ResponseEntity(HttpClientErrorException(HttpStatus.INTERNAL_SERVER_ERROR), HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@PostMapping()
fun createCategory(@RequestBody category: Category): Mono<Category> {
return categoryService.createCategory(category)
}
@PutMapping("/{categoryId}")
fun editCategory(@PathVariable categoryId: String, @RequestBody category: Category): Mono<Category> {
return categoryService.editCategory(category)
}
@DeleteMapping("/{categoryId}")
fun deleteCategory(@PathVariable categoryId: String): Mono<String> {
return categoryService.deleteCategory(categoryId)
}
}

View File

@@ -0,0 +1,34 @@
package space.luminic.budgerapp.controllers
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.core.AuthenticationException
import org.springframework.security.web.server.ServerAuthenticationEntryPoint
import org.springframework.stereotype.Component
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import java.util.*
@Component
class CustomAuthenticationEntryPoint : ServerAuthenticationEntryPoint {
override fun commence(
exchange: ServerWebExchange,
ex: AuthenticationException
): Mono<Void> {
val response = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.contentType = MediaType.APPLICATION_JSON
val body = mapOf(
"timestamp" to Date(),
"status" to HttpStatus.UNAUTHORIZED.value(),
"error" to "Unauthorized",
"message" to ex.message,
"path" to exchange.request.path.value()
)
val buffer = response.bufferFactory().wrap(ObjectMapper().writeValueAsBytes(body))
return response.writeWith(Mono.just(buffer))
}
}

View File

@@ -0,0 +1,118 @@
package space.luminic.budgerapp.controllers
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.server.reactive.ServerHttpRequest
import org.springframework.security.core.AuthenticationException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.reactive.function.server.ServerRequest
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.NotFoundException
@RestControllerAdvice
class GlobalExceptionHandler {
fun constructErrorBody(
e: Exception,
message: String,
status: HttpStatus,
request: ServerHttpRequest
): Map<String, Any?> {
val errorResponse = mapOf(
"timestamp" to System.currentTimeMillis(),
"status" to status.value(),
"error" to message,
"message" to e.message,
"path" to request.path.value()
)
return errorResponse
}
@ExceptionHandler(AuthException::class)
fun handleAuthenticationException(
ex: AuthException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
ex.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
ex,
ex.message.toString(),
HttpStatus.UNAUTHORIZED,
exchange.request
), HttpStatus.UNAUTHORIZED
)
)
}
@ExceptionHandler(NotFoundException::class)
fun handleNotFoundException(
e: NotFoundException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
e.printStackTrace()
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.NOT_FOUND,
exchange.request
), HttpStatus.NOT_FOUND
)
)
}
@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgumentException(
e: IllegalArgumentException,
exchange: ServerWebExchange
): Mono<ResponseEntity<Map<String, Any?>>?> {
e.printStackTrace()
val errorBody = mapOf("error" to e.message.orEmpty())
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.BAD_REQUEST,
exchange.request
), HttpStatus.BAD_REQUEST
)
)
}
@ExceptionHandler(Exception::class)
fun handleGenericException(
e: Exception,
exchange: ServerWebExchange
): Mono<out ResponseEntity<out Map<String, Any?>>?> {
e.printStackTrace()
// val errorBody = mapOf("error" to "An unexpected error occurred")
val errorResponse = mapOf(
"timestamp" to System.currentTimeMillis(),
"status" to HttpStatus.INTERNAL_SERVER_ERROR.value(),
"error" to "Internal Server Error",
"message" to e.message,
"path" to exchange.request.path.value()
)
return Mono.just(
ResponseEntity(
constructErrorBody(
e,
e.message.toString(),
HttpStatus.INTERNAL_SERVER_ERROR,
exchange.request
), HttpStatus.INTERNAL_SERVER_ERROR
)
)
}
}

View File

@@ -0,0 +1,51 @@
package space.luminic.budgerapp.controllers
import org.springframework.boot.autoconfigure.web.WebProperties
import org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler
import org.springframework.boot.web.reactive.error.ErrorAttributes
import org.springframework.context.ApplicationContext
import org.springframework.core.annotation.Order
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.reactive.function.BodyInserters
import org.springframework.web.reactive.function.server.*
import org.springframework.web.reactive.result.view.ViewResolver
import org.springframework.http.codec.ServerCodecConfigurer
import reactor.core.publisher.Mono
@Component
@Order(-2)
class GlobalErrorWebExceptionHandler(
errorAttributes: ErrorAttributes,
applicationContext: ApplicationContext,
serverCodecConfigurer: ServerCodecConfigurer
) : AbstractErrorWebExceptionHandler(
errorAttributes,
WebProperties.Resources(),
applicationContext
) {
init {
super.setMessageWriters(serverCodecConfigurer.writers)
super.setMessageReaders(serverCodecConfigurer.readers)
}
override fun getRoutingFunction(errorAttributes: ErrorAttributes): RouterFunction<ServerResponse> {
return RouterFunctions.route(RequestPredicates.all(), this::renderErrorResponse)
}
private fun renderErrorResponse(request: ServerRequest): Mono<ServerResponse> {
val errorAttributesMap = getErrorAttributes(
request,
org.springframework.boot.web.error.ErrorAttributeOptions.of(
org.springframework.boot.web.error.ErrorAttributeOptions.Include.MESSAGE
)
)
return ServerResponse.status(401)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorAttributesMap))
}
private fun getHttpStatus(errorAttributes: Map<String, Any>): Int {
return errorAttributes["status"] as Int
}
}

View File

@@ -0,0 +1,54 @@
package space.luminic.budgerapp.controllers
import org.springframework.http.ResponseEntity
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.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.RestController
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Recurrent
import space.luminic.budgerapp.services.RecurrentService
@RestController
@RequestMapping("/recurrents")
class RecurrentController (
private val recurrentService: RecurrentService
){
@GetMapping("/")
fun getRecurrents(): Mono<List<Recurrent>> {
return recurrentService.getRecurrents()
}
@GetMapping("/{id}")
fun getRecurrent(@PathVariable id: String): Mono<Recurrent> {
return recurrentService.getRecurrentById(id)
}
@PostMapping("/")
fun createRecurrent(@RequestBody recurrent: Recurrent): Mono<Recurrent> {
return recurrentService.createRecurrent(recurrent)
}
@PutMapping("/{id}")
fun editRecurrent(@PathVariable id: String, @RequestBody recurrent: Recurrent): Mono<Recurrent> {
return recurrentService.editRecurrent(recurrent)
}
@DeleteMapping("/{id}")
fun deleteRecurrent(@PathVariable id: String): Mono<Void> {
return recurrentService.deleteRecurrent(id)
}
@GetMapping("/transfer")
fun transferRecurrents() : ResponseEntity<Any> {
return ResponseEntity.ok(recurrentService.transferRecurrents())
}
}

View File

@@ -0,0 +1,19 @@
package space.luminic.budgerapp.controllers
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/push")
class SubsController {
@GetMapping("/vapid")
fun getVapid(): ResponseEntity<String> {
return ResponseEntity.ok("BKmMyBUhpkcmzYWcYsjH_spqcy0zf_8eVtZo60f7949TgLztCmv3YD0E_vtV2dTfECQ4sdLdPK3ICDcyOkCqr84")
}
}

View File

@@ -0,0 +1,49 @@
package space.luminic.budgerapp.controllers
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.SubscriptionDTO
import space.luminic.budgerapp.services.SubscriptionService
import space.luminic.budgerapp.services.UserService
@RestController
@RequestMapping("/subscriptions")
class SubscriptionController(
private val subscriptionService: SubscriptionService,
private val userService: UserService
) {
@PostMapping("/subscribe")
fun subscribe(
@RequestBody subscription: SubscriptionDTO,
authentication: Authentication
): Mono<String> {
return userService.getByUserNameWoPass(authentication.name)
.flatMap { user ->
subscriptionService.subscribe(subscription, user)
.thenReturn("Subscription successful")
}
.switchIfEmpty(Mono.just("User not found"))
}
@PostMapping("/notifyAll")
fun notifyAll(@RequestBody payload: PushMessage): ResponseEntity<Any> {
return try {
ResponseEntity.ok(subscriptionService.sendToAll(payload))
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity.badRequest().body(e.message)
}
}
}

View File

@@ -0,0 +1,135 @@
package space.luminic.budgerapp.controllers
import org.springframework.data.mongodb.core.aggregation.AggregationResults
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.Authentication
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.HttpClientErrorException
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.services.BudgetService
import space.luminic.budgerapp.services.TransactionService
@RestController
@RequestMapping("/transactions")
class TransactionController(private val transactionService: TransactionService, val budgetService: BudgetService) {
@GetMapping
fun getTransactions(
@RequestParam(value = "transaction_type") transactionType: String? = null,
@RequestParam(value = "category_type") categoryType: String? = null,
@RequestParam(value = "user_id") userId: String? = null,
@RequestParam(value = "is_child") isChild: Boolean? = null,
@RequestParam(value = "limit") limit: Int = 10,
@RequestParam(value = "offset") offset: Int = 0
): ResponseEntity<Any> {
try {
return ResponseEntity.ok(
transactionService.getTransactions(
transactionType = transactionType,
categoryType = categoryType,
userId = userId,
isChild = isChild,
limit = limit,
offset = offset
)
)
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@GetMapping("/{id}")
fun getTransaction(@PathVariable id: String): ResponseEntity<Any> {
try {
return ResponseEntity.ok(transactionService.getTransactionById(id))
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(e.message, HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@PostMapping
fun createTransaction(@RequestBody transaction: Transaction): ResponseEntity<Any> {
try {
return ResponseEntity.ok(transactionService.createTransaction(transaction))
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(e.message, HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@PutMapping("/{id}")
fun editTransaction(@PathVariable id: String, @RequestBody transaction: Transaction): ResponseEntity<Any> {
try {
return ResponseEntity.ok(transactionService.editTransaction(transaction))
} catch (e: Exception) {
e.printStackTrace()
return ResponseEntity(e.message, HttpStatus.INTERNAL_SERVER_ERROR)
}
}
@DeleteMapping("/{id}")
fun deleteTransaction(@PathVariable id: String): Mono<Void> {
return transactionService.deleteTransaction(id)
}
// @PatchMapping("/{id}/set-done")
// fun setTransactionDoneStatus(@PathVariable id: String, @RequestBody transaction: Transaction): ResponseEntity<Any> {
// try {
// transactionService.setTransactionDone(transaction)
// return ResponseEntity.ok(true)
// } catch (e: Exception) {
// e.printStackTrace()
// return ResponseEntity(e.message, HttpStatus.INTERNAL_SERVER_ERROR)
// }
// }
@GetMapping("/{id}/child")
fun getChildTransactions(@PathVariable id: String): ResponseEntity<Any> {
return ResponseEntity.ok(transactionService.getChildTransaction(id))
}
@GetMapping("/avg-by-category")
fun getAvgSums(): ResponseEntity<Any> {
return ResponseEntity.ok(transactionService.getAverageSpendingByCategory())
}
// @GetMapping("/_transfer")
// fun transfer(): ResponseEntity<Any> {
// return ResponseEntity.ok(transactionService.transferTransactions())
// }
@GetMapping("/types")
fun getTypes(): ResponseEntity<Any> {
return try {
ResponseEntity.ok(transactionService.getTransactionTypes())
} catch (e: Exception) {
ResponseEntity(HttpClientErrorException(HttpStatus.INTERNAL_SERVER_ERROR), HttpStatus.INTERNAL_SERVER_ERROR)
}
}
// @GetMapping("/test")
// fun getSome(): List<Map<*, *>?> {
// return transactionService.getPlannedForBudget(budgetService.getBudget("675ae567f232c35272f8a692")!!)
// }
}

View File

@@ -0,0 +1,31 @@
package space.luminic.budgerapp.controllers
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.services.TransferService
@RestController
@RequestMapping("/transfer")
class TransferController(private val transferService: TransferService) {
@GetMapping("/transactions")
fun transferTransactions(): Mono<List<Transaction>> {
return transferService.getTransactions()
}
@GetMapping("/categories")
fun transferCategories(): Mono<List<Category>> {
return transferService.getCategories()
}
@GetMapping("/budgets")
fun budgets(): Mono<List<Budget>> {
return transferService.transferBudgets()
}
}

View File

@@ -0,0 +1,35 @@
package space.luminic.budgerapp.controllers
import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.services.UserService
@RestController
@RequestMapping("/users")
class UsersController(val userService: UserService) {
val logger = LoggerFactory.getLogger(javaClass)
@GetMapping("/{id}")
fun getUser(@PathVariable id: String): Mono<User> {
return userService.getById(id)
}
@GetMapping("/")
fun getUsers(): Mono<List<User>> {
// return ResponseEntity.ok("teset")
return userService.getUsers()
}
//
// @GetMapping("/regen")
// fun regenUsers(): ResponseEntity<Any> {
// return ResponseEntity.ok(userService.regenPass())
// }
}

View File

@@ -0,0 +1,8 @@
package space.luminic.budgerapp.controllers.dtos
import space.luminic.budgerapp.models.Budget
data class BudgetCreationDTO(
val budget: Budget,
val createRecurrent: Boolean = false
)

View File

@@ -0,0 +1,42 @@
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.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
data class BudgetDTO(
var id: String? = null,
var name: String,
var dateFrom: LocalDate,
var dateTo: LocalDate,
val createdAt: LocalDateTime = LocalDateTime.now(),
var plannedExpenses: MutableList<Transaction>? = null,
var plannedIncomes: MutableList<Transaction>? = null,
var categories: MutableList<BudgetCategory> = mutableListOf(),
var transactions: MutableList<Transaction>? = null,
)
@Document("budgets")
data class Budget(
@Id var id: String? = null,
var name: String,
var dateFrom: LocalDate,
var dateTo: LocalDate,
val createdAt: LocalDateTime = LocalDateTime.now(),
var categories: MutableList<BudgetCategory> = mutableListOf(),
)
data class BudgetCategory(
@Transient var currentSpent: Double? = null,
var currentLimit: Double,
@Transient var currentPlanned: Double? = null,
@DBRef var category: Category
)
class BudgetNotFoundException(message: String) : NotFoundException(message)

View File

@@ -0,0 +1,21 @@
package space.luminic.budgerapp.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
@Document("categories")
data class Category(
@Id
val id: String? = null,
var type: CategoryType,
val name: String,
val description: String? = null,
val icon: String? = null
)
data class CategoryType(
val code: String,
val name: String? = null
)

View File

@@ -0,0 +1,4 @@
package space.luminic.budgerapp.models
open class NotFoundException(message: String) : Exception(message)

View File

@@ -0,0 +1,20 @@
package space.luminic.budgerapp.models
import kotlinx.serialization.Serializable
@Serializable
data class PushMessage(
// title: str
// body: str
// icon: str
// badge: str
// url: str
val title: String,
val body: String,
val icon: String? = null,
val budge: String? = null,
val url: String? = null
)

View File

@@ -0,0 +1,17 @@
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.util.Date
@Document(collection = "recurrents")
data class Recurrent(
@Id val id: String? = null,
var atDay: Int,
@DBRef var category: Category,
var name: String,
var description: String,
var amount: Int,
var createdAt: Date = Date()
)

View File

@@ -0,0 +1,13 @@
package space.luminic.budgerapp.models
import org.springframework.data.domain.Sort.Direction
enum class SortTypes {
ASC, DESC
}
data class SortSetting(
val by: String,
val order: Direction
)

View File

@@ -0,0 +1,23 @@
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.util.Date
@Document(collection = "subscriptions")
data class Subscription(
@Id val id: String? = null,
@DBRef val user: User? = null,
val endpoint: String,
val auth: String,
val p256dh: String,
var isActive: Boolean,
val createdAt: Date = Date()
)
data class SubscriptionDTO (
val endpoint: String,
val keys: Map<String, String>
)

View File

@@ -0,0 +1,20 @@
package space.luminic.budgerapp.models
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.time.LocalDateTime
@Document(collection = "tokens")
data class Token(
@Id
val id: String? = null,
val token: String,
val username: String,
val issuedAt: LocalDateTime,
val expiresAt: LocalDateTime,
val status: TokenStatus = TokenStatus.ACTIVE
)
enum class TokenStatus {
ACTIVE, REVOKED, EXPIRED
}

View File

@@ -0,0 +1,29 @@
package space.luminic.budgerapp.models
import com.fasterxml.jackson.annotation.JsonFormat
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.util.Date
@Document(collection = "transactions")
data class Transaction(
@Id var id: String? = null,
var type: TransactionType,
@DBRef var user: User?=null,
@DBRef var category: Category,
var comment: String,
val date: LocalDate,
var amount: Double,
val isDone: Boolean,
var parentId: String? = null,
var createdAt: LocalDateTime? = LocalDateTime.now(),
)
data class TransactionType(
val code: String,
val name: String
)

View File

@@ -0,0 +1,20 @@
package space.luminic.budgerapp.models
import org.springframework.context.ApplicationEvent
import java.math.BigDecimal
enum class TransactionEventType {
CREATE, EDIT, DELETE
}
class TransactionEvent(
source: Any,
val eventType: TransactionEventType,
val oldTransaction: Transaction,
val newTransaction: Transaction,
var difference: Double? = null,
) : ApplicationEvent(source) {
override fun toString(): String {
return "${eventType}, ${oldTransaction}, ${newTransaction}, $difference"
}
}

View File

@@ -0,0 +1,26 @@
package space.luminic.budgerapp.models
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.validation.constraints.NotBlank
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.Document
import java.util.Date
@Document("users")
data class User (
@Id
var id: String? = null,
@field:NotBlank val username: String,
var firstName: String,
var tgId: String? = null,
var tgUserName: String? = null,
@JsonIgnore // Скрывает пароль при сериализации
var password: String? = null,
var isActive: Boolean = true,
var regDate: Date? = null,
val createdAt: Date? = null,
var roles: MutableList<String> = mutableListOf(),
)

View File

@@ -0,0 +1,22 @@
package space.luminic.budgerapp.models
import org.bson.types.ObjectId
import org.springframework.data.annotation.Id
import org.springframework.data.mongodb.core.mapping.DBRef
import org.springframework.data.mongodb.core.mapping.Document
enum class WarnSerenity(val level: String, val sort: Int) {
OTHER("Другое", -10),
MAIN("Основное", 0),
IMPORTANT("Важное", 10)
}
@Document(collection = "warns")
data class Warn(
@Id var id: String? = null,
var serenity: WarnSerenity,
var message: PushMessage,
var budgetId: String,
var context: String,
var isHide: Boolean
)

View File

@@ -0,0 +1,25 @@
package space.luminic.budgerapp.repos
import org.springframework.data.domain.Sort
import org.springframework.data.mongodb.core.aggregation.SortOperation
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.data.repository.PagingAndSortingRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import java.time.LocalDate
import java.util.Date
import java.util.Optional
@Repository
interface BudgetRepo: ReactiveMongoRepository<Budget, String> {
override fun findAll(sort: Sort): Flux<Budget>
fun findByDateFromLessThanEqualAndDateToGreaterThan(dateOne: LocalDate, dateTwo: LocalDate): Mono<Budget>
}

View File

@@ -0,0 +1,15 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Category
import java.util.Optional
@Repository
interface CategoryRepo : ReactiveMongoRepository<Category, String> {
fun findByName(name: String): Mono<Category>
}

View File

@@ -0,0 +1,11 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Category
@Repository
interface CategoryRepoOld: MongoRepository<Category, String> {
fun findByName(name: String): Category?
}

View File

@@ -0,0 +1,11 @@
package space.luminic.budgerapp.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Recurrent
@Repository
interface RecurrentRepo: ReactiveMongoRepository<Recurrent, String> {
}

View File

@@ -0,0 +1,11 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Subscription
@Repository
interface SubscriptionRepo : ReactiveMongoRepository<Subscription, String> {
}

View File

@@ -0,0 +1,17 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Token
import space.luminic.budgerapp.models.TokenStatus
import java.time.LocalDateTime
@Repository
interface TokenRepo: ReactiveMongoRepository<Token, String> {
fun findByToken(token: String): Mono<Token>
fun findByUsernameAndStatus(username: String, status: TokenStatus): Mono<List<Token>>
fun deleteByExpiresAtBefore(dateTime: LocalDateTime)
}

View File

@@ -0,0 +1,20 @@
package space.luminic.budgerapp.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Transaction
import java.util.Optional
@Repository
interface TransactionReactiveRepo: ReactiveMongoRepository<Transaction, String> {
// fun findByOldId(transactionId: Int): Optional<Transaction>
fun findByParentId(parentId: String): Optional<Transaction>
}

View File

@@ -0,0 +1,21 @@
package space.luminic.budgerapp.repos
import org.bson.types.ObjectId
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Transaction
import java.util.Optional
@Repository
interface TransactionRepo: ReactiveMongoRepository<Transaction, String> {
// fun findByOldId(transactionId: Int): Optional<Transaction>
fun findByParentId(parentId: String): Mono<Transaction>
}

View File

@@ -0,0 +1,19 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.Query
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.User
import java.util.Optional
@Repository
interface UserRepo : ReactiveMongoRepository<User, String> {
@Query(value = "{ 'username': ?0 }", fields = "{ 'password': 0 }")
fun findByUsernameWOPassword(username: String): Mono<User>
fun findByUsername(username: String): Mono<User>
}

View File

@@ -0,0 +1,9 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import space.luminic.budgerapp.models.User
interface UserRepoOld: MongoRepository<User, String> {
fun findByUsername(username: String): User?
}

View File

@@ -0,0 +1,19 @@
package space.luminic.budgerapp.repos
import org.springframework.data.mongodb.repository.MongoRepository
import org.springframework.data.mongodb.repository.ReactiveMongoRepository
import org.springframework.stereotype.Repository
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Warn
@Repository
interface WarnRepo : ReactiveMongoRepository<Warn, String> {
fun findAllByBudgetIdAndIsHide(budgetId: String, isHide: Boolean): Flux<Warn>
fun deleteAllByBudgetId(budgetId: String)
fun findWarnByContext(context: String): Mono<Warn>
}

View File

@@ -0,0 +1,137 @@
package space.luminic.budgerapp.repos.sqlrepo
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.controllers.dtos.BudgetCreationDTO
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.BudgetCategory
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.services.CategoryService
import space.luminic.budgerapp.services.UserService
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
@Repository
class BudgetRepoSQL(
val jdbcTemplate: JdbcTemplate,
val userService: UserService,
val categoryService: CategoryService,
val transactionsRepoSQl: TransactionsRepoSQl
) {
private val logger = LoggerFactory.getLogger(BudgetRepoSQL::class.java)
fun getBudgetsIds(): List<Int> {
return jdbcTemplate.queryForList("SELECT id FROM budger.budgets", Int::class.java)
}
fun getBudgets(): List<BudgetCreationDTO> {
val sql = "SELECT * FROM budger.budgets"
return jdbcTemplate.query(sql, RowMapper { rs, _ ->
BudgetCreationDTO(
Budget(
// id = rs.getString("id"),
name = rs.getString("name"),
dateFrom = rs.getDate("date_from").toLocalDate(),
dateTo = rs.getDate("date_to").toLocalDate(),
createdAt = LocalDateTime.of(rs.getDate("created_at").toLocalDate(), LocalTime.NOON),
), false
)
})
}
// fun getBudgetCategory(id: Int): List<BudgetCategory> {
// val sql = "SELECT c.*, \n" +
// " COALESCE(SUM(CASE WHEN t.transaction_type_code = 'INSTANT' THEN t.amount END), 0) AS total_instant_expenses,\n" +
// " COALESCE(SUM(CASE WHEN t.transaction_type_code = 'PLANNED' THEN t.amount END), 0) AS total_planned_expenses,\n" +
// " COALESCE(bcs.category_setting_value, 0) AS current_limit,\n" +
// " COALESCE((SELECT AVG(amount) FROM budger.transactions where category_id=c.id AND transaction_type_code='INSTANT'),0) as average\n" +
// "FROM\n" +
// " budger.budget_category_settings bcs\n" +
// "JOIN\n" +
// " budger.budgets b ON bcs.budget_id = b.id\n" +
// "LEFT JOIN\n" +
// " budger.transactions t ON bcs.category_id = t.category_id\n" +
// " AND t.date BETWEEN b.date_from AND b.date_to\n" +
// "JOIN\n" +
// " budger.categories c ON bcs.category_id = c.id\n" +
// "WHERE\n" +
// " b.id = ? -- Укажите ID бюджета, для которого нужно вывести данные\n" +
// "GROUP BY\n" +
// " c.id, bcs.category_setting_value\n" +
// "ORDER BY\n" +
// " c.id;"
// return jdbcTemplate.query(sql, RowMapper { rs, _ ->
// BudgetCategory(
// currentSpent = rs.getBigDecimal("total_instant_expenses"),
// currentLimit = rs.getBigDecimal("current_limit"),
// category = categoryService.getCategoryByName(rs.getString("name"))
// )
// }, id)
// }
fun getBudgetTransactions(id: Int, transactionType: String?, categoryType: String?): List<Transaction> {
val whereConditions = mutableListOf<String>()
val parameters = mutableListOf<Any>()
// Базовое условие
whereConditions.add("b.id = ?")
parameters.add(id)
// Условие для transactionType, если указано
if (transactionType != null) {
whereConditions.add("t.transaction_type_code = ?")
parameters.add(transactionType)
}
// Условие для categoryType, если указано
if (categoryType != null) {
whereConditions.add("c.type_code = ?")
parameters.add(categoryType)
}
// Генерация WHERE
val whereClause = if (whereConditions.isNotEmpty()) "WHERE ${whereConditions.joinToString(" AND ")}" else ""
// SQL запрос
val sql = """
SELECT
tt.code as tt_code,
tt.name as tt_name,
t.comment,
t.date,
t.amount,
t.is_done,
t.created_at,
u.username,
c.name as c_name,
t.id
FROM budger.transactions t
JOIN budger.transaction_types tt ON tt.code = t.transaction_type_code
JOIN budger.categories c ON c.id = t.category_id
JOIN budger.category_types ct ON ct.code = c.type_code
JOIN budger.budgets b ON t.date BETWEEN b.date_from AND b.date_to
LEFT JOIN budger.users u ON t.user_id = u.id
$whereClause
ORDER BY t.date DESC, c.name, t.id
""".trimIndent()
logger.info(parameters.toTypedArray().toString())
// Выполнение запроса
return jdbcTemplate.query(
sql, parameters.toTypedArray(),
transactionsRepoSQl.transactionRowMapper()
)
}
}

View File

@@ -0,0 +1,40 @@
package space.luminic.budgerapp.repos.sqlrepo
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import java.time.LocalDate
import java.time.ZoneId
@Repository
class CategoriesRepoSQL(private val jdbcTemplate: JdbcTemplate) {
fun getCategories(): List<Category> {
val sql = "SELECT * FROM budger.categories"
return jdbcTemplate.query(sql, categoriesRowMapper())
}
fun categoriesRowMapper() = RowMapper { rs, _ ->
val category = Category(
type = if (rs.getString("type_code") == "EXPENSE") CategoryType("EXPENSE", "Траты") else CategoryType(
"INCOME",
"Поступления"
),
name = rs.getString("name"),
description = rs.getString("description"),
icon = rs.getString("icon")
)
return@RowMapper category
}
}

View File

@@ -0,0 +1,38 @@
package space.luminic.budgerapp.repos.sqlrepo
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Recurrent
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.services.CategoryService
@Repository
class RecurrentRepoSQL(
private val jdbcTemplate: JdbcTemplate,
private val categoryService: CategoryService
) {
private val logger = LoggerFactory.getLogger(RecurrentRepoSQL::class.java)
fun getRecurrents(): List<Recurrent> {
return jdbcTemplate.query("SELECT r.*, c.name as c_name FROM budger.recurrent_payments r join budger.categories c on r.category_id = c.id ", recurrentRowMapper())
}
fun recurrentRowMapper() = RowMapper { rs, _ ->
val recurrent = Recurrent(
atDay = rs.getInt("at_day"),
category = categoryService.getCategoryByName(rs.getString("c_name")).block()!!,
name = rs.getString("name"),
description = rs.getString("description"),
amount = rs.getInt("amount")
)
logger.info(recurrent.toString())
return@RowMapper recurrent
}
}

View File

@@ -0,0 +1,65 @@
package space.luminic.budgerapp.repos.sqlrepo
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Repository
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.CategoryRepoOld
import space.luminic.budgerapp.repos.UserRepoOld
import space.luminic.budgerapp.services.CategoryService
import space.luminic.budgerapp.services.UserService
import java.sql.SQLException
import java.time.LocalDate
import java.time.ZoneId
@Repository
class TransactionsRepoSQl(
private val jdbcTemplate: JdbcTemplate,
private val userRepoOld: UserRepoOld,
private val categoryRepoOld: CategoryRepoOld
) {
private val logger = LoggerFactory.getLogger(TransactionsRepoSQl::class.java)
fun getTransactions(): List<Transaction> {
return jdbcTemplate.query(
"SELECT tt.code as tt_code, tt.name as tt_name, u.username, c.name as c_name, t.comment, t.date, t.amount, t.is_done, t.created_at, t.id" +
" FROM budger.transactions t" +
" JOIN budger.transaction_types tt on t.transaction_type_code = tt.code " +
" JOIN budger.categories c on t.category_id = c.id" +
" LEFT JOIN budger.users u on t.user_id = u.id", transactionRowMapper()
)
}
fun transactionRowMapper() = RowMapper { rs, _ ->
val transaction = Transaction(
type = TransactionType(rs.getString("tt_code"), rs.getString("tt_name")),
user = rs.getString("username")
?.let { userRepoOld.findByUsername(it) }
?: userRepoOld.findByUsername("voroninv"),
category = categoryRepoOld.findByName(rs.getString("c_name"))!!,
comment = rs.getString("comment"),
date = rs.getDate("date").toLocalDate(),
amount = rs.getDouble("amount"),
isDone = rs.getBoolean("is_done"),
createdAt = rs.getTimestamp("created_at").toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
logger.info(transaction.toString())
return@RowMapper transaction
}
}

View File

@@ -0,0 +1,69 @@
package space.luminic.budgerapp.services
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.configs.AuthException
import space.luminic.budgerapp.models.Token
import space.luminic.budgerapp.models.TokenStatus
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.TokenRepo
import space.luminic.budgerapp.repos.UserRepo
import space.luminic.budgerapp.utils.JWTUtil
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Date
import java.util.UUID
@Service
class AuthService(
private val userRepository: UserRepo,
private val tokenRepo: TokenRepo,
private val jwtUtil: JWTUtil
) {
private val passwordEncoder = BCryptPasswordEncoder()
fun login(username: String, password: String): Mono<String> {
return userRepository.findByUsername(username)
.flatMap { user ->
if (passwordEncoder.matches(password, user.password)) {
val token = jwtUtil.generateToken(user.username)
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
tokenRepo.save(
Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
expiresAt = LocalDateTime.ofInstant(
expireAt.toInstant(),
ZoneId.systemDefault()
)
)
)
.thenReturn(token)
} else {
Mono.error(AuthException("Invalid credentials"))
}
}
}
@Cacheable("tokens")
fun isTokenValid(token: String): Mono<User> {
// print("checking token: $token")
return tokenRepo.findByToken(token)
.flatMap {
if (it.status == TokenStatus.ACTIVE &&
it.expiresAt.isAfter(LocalDateTime.now())
) {
userRepository.findByUsername(it.username)
} else {
Mono.error(AuthException("Token expired"))
}
}.switchIfEmpty(Mono.error(AuthException("User not found")))
}
}

View File

@@ -0,0 +1,616 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.context.event.EventListener
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Sort.Direction
import org.springframework.stereotype.Service
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.BudgetCategory
import space.luminic.budgerapp.models.Transaction
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.Aggregation.group
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.project
import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.query.Criteria
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.BudgetDTO
import space.luminic.budgerapp.models.BudgetNotFoundException
import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.SortSetting
import space.luminic.budgerapp.models.TransactionEvent
import space.luminic.budgerapp.models.TransactionEventType
import space.luminic.budgerapp.models.Warn
import space.luminic.budgerapp.models.WarnSerenity
import space.luminic.budgerapp.repos.BudgetRepo
import space.luminic.budgerapp.repos.WarnRepo
import java.time.LocalDate
import java.util.Optional
import kotlin.collections.get
@Service
class BudgetService(
val budgetRepo: BudgetRepo,
val warnRepo: WarnRepo,
val transactionService: TransactionService,
val recurrentService: RecurrentService,
val categoryService: CategoryService,
val reactiveMongoTemplate: ReactiveMongoTemplate
) {
private val logger = LoggerFactory.getLogger(BudgetService::class.java)
@EventListener
@CacheEvict(cacheNames = ["budgets"], allEntries = true)
fun handleTransactionEvent(event: TransactionEvent) {
logger.info("Got ${event.eventType} event on transaction ${event.newTransaction.id}")
if (event.newTransaction.category.type?.code == "EXPENSE") {
when (event.eventType) {
TransactionEventType.EDIT -> updateBudgetOnEdit(event)
TransactionEventType.CREATE -> updateBudgetOnCreate(event)
TransactionEventType.DELETE -> updateBudgetOnDelete(event)
}
}
// runBlocking(Dispatchers.IO) {
// updateBudgetWarns(
// budget = budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
// event.newTransaction.date.toLocalDate(), event.newTransaction.date.toLocalDate()
// )
// )
// }
}
fun updateBudgetOnCreate(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.newTransaction.amount
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetOnEdit(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.oldTransaction.date, event.oldTransaction.date
).switchIfEmpty(
Mono.error(BudgetNotFoundException("old budget cannot be null"))
).then().subscribe()
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
if (category.category.id == event.newTransaction.category.id) {
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.difference!!
}
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetOnDelete(event: TransactionEvent) {
budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(
event.newTransaction.date, event.newTransaction.date
).flatMap { budget ->
categoryService.getBudgetCategories(budget.dateFrom, budget.dateTo).flatMap { categories ->
val updatedCategories = when (event.newTransaction.type.code) {
"PLANNED" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
category.currentLimit += event.newTransaction.amount
}
category
}.collectList()
"INSTANT" -> Flux.fromIterable(budget.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}.collectList()
else -> Mono.just(budget.categories) // Добавляем обработку типа по умолчанию
}
updatedCategories.flatMap { updated ->
budget.categories = updated
budgetRepo.save(budget) // Сохраняем обновленный бюджет
}
}
}.then() // Гарантируем завершение
.subscribe() // Запускаем выполнение
}
fun updateBudgetTransactions(budget: Budget): Budget {
// budget.plannedExpenses = getBudgetTransactions(
// budget = budget,
// transactionType = "PLANNED",
// categoryType = "EXPENSE",
// sortBy = SortSetting("date", Direction.ASC)
// )
// budget.plannedIncomes = getBudgetTransactions(
// budget = budget,
// transactionType = "PLANNED",
// categoryType = "INCOME",
// sortBy = SortSetting("date", Direction.ASC)
// )
return budget
}
@Cacheable("budgetsList")
fun getBudgets(sortSetting: SortSetting? = null): Mono<MutableList<Budget>> {
val sort = if (sortSetting != null) {
Sort.by(sortSetting.order, sortSetting.by)
} else {
Sort.by(Sort.Direction.DESC, "dateFrom")
}
return budgetRepo.findAll(sort)
.collectList() // Сбор Flux<Budget> в Mono<List<Budget>>
}
// @Cacheable("budgets", key = "#id")
fun getBudget(id: String): Mono<BudgetDTO> {
return budgetRepo.findById(id)
.flatMap { budget ->
val budgetDTO = BudgetDTO(
budget.id,
budget.name,
budget.dateFrom,
budget.dateTo,
budget.createdAt,
categories = budget.categories
)
logger.info("Fetching categories and transactions")
val categoriesMono = categoryService.getBudgetCategories(budgetDTO.dateFrom, budgetDTO.dateTo)
val transactionsMono =
transactionService.getTransactionsByTypes(budgetDTO.dateFrom, budgetDTO.dateTo)
Mono.zip(categoriesMono, transactionsMono)
.flatMap { tuple ->
val categories = tuple.t1
val transactions = tuple.t2
Flux.fromIterable(budgetDTO.categories)
.map { category ->
categories[category.category.id]?.let { data ->
category.currentSpent = data["instantAmount"] ?: 0.0
category.currentPlanned = data["plannedAmount"] ?: 0.0
}
category
}
.collectList()
.map { updatedCategories ->
budgetDTO.categories = updatedCategories
budgetDTO.plannedExpenses = transactions["plannedExpenses"] as MutableList
budgetDTO.plannedIncomes = transactions["plannedIncomes"] as MutableList
budgetDTO.transactions = transactions["instantTransactions"] as MutableList
budgetDTO
}
}
}
.doOnError { error ->
logger.error("Error fetching budget: ${error.message}", error)
}
.switchIfEmpty(Mono.error(BudgetNotFoundException("Budget not found with id: $id")))
}
// fun transferBudgets() {
// val budgets = getBudgets()
// budgetRepo.saveAll<Budget>(budgets)
//
// }
//
// fun getBudgets(): List<Budget> {
// val budgetIds = budgetRepoSql.getBudgetsIds()
// var budgets = mutableListOf<Budget>()
// budgetIds.forEach { budgetId ->
// budgets.add(getBudgetSQL(budgetId)!!)
// }
// return budgets
// }
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun createBudget(budget: Budget, createRecurrent: Boolean): Mono<Budget> {
return Mono.zip(
getBudgetByDate(budget.dateFrom).map { Optional.ofNullable(it) }
.switchIfEmpty(Mono.just(Optional.empty())),
getBudgetByDate(budget.dateTo).map { Optional.ofNullable(it) }
.switchIfEmpty(Mono.just(Optional.empty()))
).flatMap { tuple ->
val startBudget = tuple.t1.orElse(null)
val endBudget = tuple.t2.orElse(null)
// Проверяем, пересекаются ли бюджеты по датам
if (startBudget != null || endBudget != null) {
return@flatMap Mono.error<Budget>(IllegalArgumentException("Бюджет с теми же датами найден"))
}
// Если createRecurrent=true, создаем рекуррентные транзакции
val recurrentsCreation = if (createRecurrent) {
recurrentService.createRecurrentsForBudget(budget)
} else {
Mono.empty()
}
// Создаем бюджет после возможного создания рекуррентных транзакций
recurrentsCreation.then(
categoryService.getCategoryTransactionPipeline(budget.dateFrom, budget.dateTo)
.flatMap { categories ->
budget.categories = categories
budgetRepo.save(budget)
}
.doOnNext { savedBudget ->
// Выполнение updateBudgetWarns в фоне
updateBudgetWarns(budget = savedBudget)
.doOnError { error ->
// Логируем ошибку, если произошла
println("Error during updateBudgetWarns: ${error.message}")
}
.subscribe()
}
)
}
}
fun getBudgetByDate(date: LocalDate): Mono<Budget> {
return budgetRepo.findByDateFromLessThanEqualAndDateToGreaterThan(date, date).switchIfEmpty(Mono.empty())
}
// fun getBudgetCategorySQL(id: Int): List<BudgetCategory>? {
// var categories = budgetRepoSql.getBudgetCategory(id)
// for (category in categories) {
// categoryService.getCategoryByName(category.category.name)?.let { category.category = it }
// }
// return categories
// }
fun getBudgetCategories(id: String): Mono<List<BudgetCategory>> {
return budgetRepo.findById(id).flatMap { budget ->
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetailed")
val unwind = unwind("categoryDetailed")
val projectDouble = project("categoryDetailed", "amount", "date")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val match = match(Criteria.where("date").gte(budget.dateFrom).lt(budget.dateTo))
val group = group("categoryDetailed").sum("amount").`as`("currentSpent")
val project = project("currentSpent").and("_id").`as`("category")
val sort = sort(Sort.by(Sort.Order.asc("_id")))
val aggregation = newAggregation(lookup, unwind, projectDouble, match, group, project, sort)
reactiveMongoTemplate.aggregate(aggregation, "transactions", BudgetCategory::class.java)
.collectList() // Преобразование результата в список
}
}
fun getBudgetTransactionsByType(budgetId: String): Mono<Map<String, List<Transaction>>> {
return budgetRepo.findById(budgetId).flatMap { it ->
transactionService.getTransactionsByTypes(it.dateFrom, it.dateTo)
}
}
fun getBudgetTransactions(
budget: Budget,
transactionType: String? = null,
isDone: Boolean? = null,
categoryType: String? = null,
sortBy: SortSetting? = null
): Mono<MutableList<Transaction>> {
val defaultSort = SortSetting("date", Sort.Direction.ASC) // Сортировка по умолчанию
return transactionService.getTransactions(
dateFrom = budget.dateFrom,
dateTo = budget.dateTo,
transactionType = transactionType,
isDone = isDone,
categoryType = categoryType,
sortSetting = sortBy ?: defaultSort
).onErrorResume { e ->
Mono.error(RuntimeException("Error fetching transactions: ${e.message}", e))
}
}
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun deleteBudget(budgetId: String): Mono<Void> {
return budgetRepo.findById(budgetId)
.switchIfEmpty(Mono.error(BudgetNotFoundException("Budget with id: $budgetId not found")))
.flatMap { budget ->
transactionService.getTransactionsToDelete(budget.dateFrom, budget.dateTo)
.flatMapMany { transactions ->
Flux.fromIterable(transactions)
.flatMap { transaction ->
transactionService.deleteTransaction(transaction.id!!)
}
}
.then(
budgetRepo.delete(budget)
)
}
}
@CacheEvict(cacheNames = ["budgets", "budgetsList"], allEntries = true)
fun setCategoryLimit(budgetId: String, catId: String, limit: Double): Mono<BudgetCategory> {
return budgetRepo.findById(budgetId).flatMap { budget ->
val catEdit = budget.categories.firstOrNull { it.category.id == catId }
?: return@flatMap Mono.error<BudgetCategory>(Exception("Category not found in the budget"))
transactionService.calcTransactionsSum(budget, catId, "PLANNED").flatMap { catPlanned ->
if (catPlanned > limit) {
Mono.error(Exception("Limit can't be less than planned expenses on category. Current planned value: $catPlanned"))
} else {
catEdit.currentLimit = limit
budgetRepo.save(budget).flatMap {
updateBudgetWarns(it)
.thenReturn(catEdit)
}
}
}
}
}
fun recalcBudgetCategory(): Mono<Void> {
return budgetRepo.findAll(Sort.by(Direction.DESC, "dateFrom")) // Получаем все бюджеты
// .flatMapIterable { budgets -> budgets } // Преобразуем Flux<Budget> в Flux<Budget> (итерация по бюджетам)
// .flatMap { budget ->
// logger.warn("HERE $budget")
// Flux.fromIterable(budget.categories) // Преобразуем категории в поток
// .flatMap { category ->
// logger.warn("HERE $category")
// Mono.zip(
// transactionService.calcTransactionsSum(budget, category.category.id!!, "PLANNED"),
// transactionService.calcTransactionsSum(budget, category.category.id!!, "INSTANT", isDone = true),
// transactionService.calcTransactionsSum(budget, category.category.id!!, "PLANNED")
// ).map { (plannedSum, spentSum, limitSum) ->
// category.currentPlanned = plannedSum
// category.currentSpent = spentSum
// category.currentLimit = limitSum
// }
// }
// .then() // Завершаем поток категорий
// .flatMap { updateBudgetWarns(budgetId = budget.id!!) } // Обновляем предупреждения
// }
// .collectList() // Собираем все бюджеты
// .flatMap { budgets -> budgetRepo.saveAll(budgets).collectList() } // Сохраняем все бюджеты
.then() // Завершаем метод
}
fun getWarns(budgetId: String, isHide: Boolean? = null): Mono<List<Warn>> {
return warnRepo.findAllByBudgetIdAndIsHide(budgetId, isHide == true).collectList()
}
fun hideWarn(budgetId: String, warnId: String): Mono<Warn> {
return warnRepo.findById(warnId) // Ищем предупреждение
.flatMap { warn ->
warn.isHide = true // Обновляем поле
warnRepo.save(warn) // Сохраняем изменённое предупреждение
}
}
fun updateBudgetWarns(budget: Budget? = null): Mono<List<Warn>> {
logger.info("STARTED WARNS UPDATE")
val finalBudgetMono = budget?.let { Mono.just(it) }
?: return Mono.just(emptyList())
return finalBudgetMono.flatMap { finalBudget ->
if (finalBudget.categories.isEmpty()) {
logger.info("No categories found for budget ${finalBudget.id}")
return@flatMap Mono.just(emptyList<Warn>())
}
val averageSumsMono = transactionService.getAverageSpendingByCategory()
val averageIncomeMono = transactionService.getAverageIncome()
val currentBudgetIncomeMono = transactionService.calcTransactionsSum(
finalBudget, transactionType = "PLANNED", categoryType = "INCOME"
)
val plannedIncomeMono = transactionService.calcTransactionsSum(
finalBudget, categoryType = "INCOME", transactionType = "PLANNED"
)
val plannedSavingMono = transactionService.calcTransactionsSum(
finalBudget, categoryId = "675850148198643f121e466a", transactionType = "PLANNED"
)
Mono.zip(
averageSumsMono,
averageIncomeMono,
currentBudgetIncomeMono,
plannedIncomeMono,
plannedSavingMono
).flatMap { tuple ->
val averageSums = tuple.t1
val averageIncome = tuple.t2
val currentBudgetIncome = tuple.t3
val plannedIncome = tuple.t4
val plannedSaving = tuple.t5
Flux.fromIterable(finalBudget.categories)
.flatMap { category ->
processCategoryWarnings(
category,
finalBudget,
averageSums,
averageIncome,
currentBudgetIncome,
plannedIncome,
plannedSaving
)
}
.collectList()
.flatMap { warns ->
warnRepo.saveAll(warns.filterNotNull()).collectList()
}
.doOnSuccess { logger.info("ENDED WARNS UPDATE") }
.map { it.sortedByDescending { warn -> warn.serenity.sort } }
}
}.doOnError { error ->
logger.error("Error updating budget warns: ${error.message}", error)
}.onErrorResume {
Mono.just(emptyList()) // Возвращаем пустой список в случае ошибки
}
}
private fun processCategoryWarnings(
category: BudgetCategory,
finalBudget: Budget,
averageSums: Map<String, Double>,
averageIncome: Double,
currentBudgetIncome: Double,
plannedIncome: Double,
plannedSaving: Double
): Flux<Warn> {
val warnsForCategory = mutableListOf<Mono<Warn?>>()
val averageSum = averageSums[category.category.id] ?: 0.0
val categorySpentRatioInAvgIncome = if (averageIncome > 0.0) averageSum / averageIncome else 0.0
val projectedAvailableSum = currentBudgetIncome * categorySpentRatioInAvgIncome
val contextAtAvg = "category${category.category.id}atbudget${finalBudget.id}lessavg"
val lowSavingContext = "savingValueLess10atBudget${finalBudget.id}"
if (averageSum > category.currentLimit) {
val warnMono = warnRepo.findWarnByContext(contextAtAvg)
.switchIfEmpty(
Mono.just(
Warn(
serenity = WarnSerenity.MAIN,
message = PushMessage(
title = "Внимание на ${category.category.name}!",
body = "Лимит меньше средних трат (Среднее: <b>${averageSum.toInt()} ₽</b> Текущий лимит: <b>${category.currentLimit.toInt()} ₽</b>)." +
"\nСредняя доля данной категории в доходах: <b>${(categorySpentRatioInAvgIncome * 100).toInt()}%</b>." +
"\nПроецируется на текущие поступления: <b>${projectedAvailableSum.toInt()} ₽</b>",
icon = category.category.icon
),
budgetId = finalBudget.id!!,
context = contextAtAvg,
isHide = false
)
)
)
warnsForCategory.add(warnMono)
} else {
warnRepo.findWarnByContext(contextAtAvg).flatMap { warnRepo.delete(it).then(Mono.empty<Warn>()) }
}
if (category.category.id == "675850148198643f121e466a") {
val savingRatio = if (plannedIncome > 0.0) category.currentLimit / plannedIncome else 0.0
if (savingRatio < 0.1) {
val warnMono = warnRepo.findWarnByContext(lowSavingContext)
.switchIfEmpty(
Mono.just(
Warn(
serenity = WarnSerenity.IMPORTANT,
message = PushMessage(
title = "Доля сбережений очень мала!",
body = "Текущие плановые сбережения равны ${plannedSaving.toInt()} (${
(savingRatio * 100).toInt()
}%)! Исправьте!",
icon = category.category.icon
),
budgetId = finalBudget.id!!,
context = lowSavingContext,
isHide = false
)
)
)
warnsForCategory.add(warnMono)
} else {
warnRepo.findWarnByContext(lowSavingContext)
.flatMap { warnRepo.delete(it).then(Mono.empty<Warn>()) }
}
}
return Flux.fromIterable(warnsForCategory).flatMap { it }
}
}

View File

@@ -0,0 +1,16 @@
package space.luminic.budgerapp.services
import org.springframework.cache.CacheManager
import org.springframework.stereotype.Service
@Service
class CacheInspector(private val cacheManager: CacheManager) {
fun getCacheContent(cacheName: String): Map<Any, Any>? {
val cache = cacheManager.getCache(cacheName)
if (cache != null && cache is org.springframework.cache.concurrent.ConcurrentMapCache) {
return cache.nativeCache as Map<Any, Any>
}
return null
}
}

View File

@@ -0,0 +1,362 @@
package space.luminic.budgerapp.services
import org.bson.Document
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.BudgetCategory
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.repos.CategoryRepo
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Date
import kotlin.jvm.optionals.getOrNull
@Service
class CategoryService(
private val categoryRepo: CategoryRepo,
private val transactionService: TransactionService,
private val mongoTemplate: ReactiveMongoTemplate
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun getCategoryByName(name: String): Mono<Category> {
return categoryRepo.findByName(name)
}
@Cacheable("getAllCategories")
fun getCategories(): Mono<List<Category>> {
return categoryRepo.findAll().collectList()
}
@Cacheable("categoryTypes")
fun getCategoryTypes(): List<CategoryType> {
var types = mutableListOf<CategoryType>()
types.add(CategoryType("EXPENSE", "Траты"))
types.add(CategoryType("INCOME", "Поступления"))
return types
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun createCategory(category: Category): Mono<Category> {
return categoryRepo.save(category)
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun editCategory(category: Category): Mono<Category> {
return categoryRepo.findById(category.id!!) // Возвращаем Mono<Category>
.flatMap { oldCategory ->
if (oldCategory.type.code != category.type.code) {
return@flatMap Mono.error<Category>(IllegalArgumentException("You cannot change category type"))
}
categoryRepo.save(category) // Сохраняем категорию, если тип не изменился
}
}
@CacheEvict(cacheNames = ["getAllCategories"],allEntries = true)
fun deleteCategory(categoryId: String): Mono<String> {
return categoryRepo.findById(categoryId).switchIfEmpty(
Mono.error(IllegalArgumentException("Category with id: $categoryId not found"))
).flatMap {
transactionService.getTransactions(categoryId = categoryId)
.flatMapMany { transactions ->
categoryRepo.findByName("Другое").switchIfEmpty(
categoryRepo.save(
Category(
type = CategoryType("EXPENSE", "Траты"),
name = "Другое",
description = "Категория для других трат",
icon = "🚮"
)
)
).flatMapMany { newCategory ->
Flux.fromIterable(transactions).flatMap { transaction ->
transaction.category = newCategory // Присваиваем конкретный объект категории
transactionService.editTransaction(transaction) // Сохраняем изменения
}
}
}
.then(categoryRepo.deleteById(categoryId)) // Удаляем старую категорию
.thenReturn(categoryId) // Возвращаем удалённую категорию
}
}
fun getBudgetCategories(dateFrom: LocalDate, dateTo: LocalDate): Mono<Map<String, Map<String, Double>>> {
logger.info("here cat starts")
val pipeline = listOf(
Document(
"\$lookup",
Document("from", "transactions")
.append(
"let",
Document("categoryId", "\$_id")
)
.append(
"pipeline", listOf(
Document(
"\$match",
Document(
"\$expr",
Document(
"\$and", listOf(
Document("\$eq", listOf("\$category.\$id", "\$\$categoryId")),
Document(
"\$gte", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
),
)
),
Document(
"\$lt", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
)
)
)
)
),
Document(
"\$group",
Document("_id", "\$type.code")
.append(
"totalAmount",
Document("\$sum", "\$amount")
)
)
)
)
.append("as", "transactionSums")
),
Document(
"\$project",
Document("_id", 1L)
.append(
"plannedAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "PLANNED"))
)
), 0L
)
)
)
.append(
"instantAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "INSTANT"))
)
), 0L
)
)
)
),
Document(
"\$addFields",
Document(
"plannedAmount",
Document("\$ifNull", listOf("\$plannedAmount.totalAmount", 0.0))
)
.append(
"instantAmount",
Document("\$ifNull", listOf("\$instantAmount.totalAmount", 0.0))
)
)
)
// Анализ плана выполнения (вывод для отладки)
// getCategoriesExplainReactive(pipeline)
// .doOnNext { explainResult ->
// logger.info("Explain Result: ${explainResult.toJson()}")
// }
// .subscribe() // Этот вызов лучше оставить только для отладки
//
return mongoTemplate.getCollection("categories")
.flatMapMany { it.aggregate(pipeline) }
.collectList()
.flatMap { result ->
val categories = result.associate { document ->
val id = document["_id"].toString()
val values = mapOf(
"plannedAmount" to (document["plannedAmount"] as Double? ?: 0.0),
"instantAmount" to (document["instantAmount"] as Double? ?: 0.0)
)
id to values
}
logger.info("here cat ends")
Mono.just(categories)
}
}
fun getCategoryTransactionPipeline(dateFrom: LocalDate, dateTo: LocalDate): Mono<MutableList<BudgetCategory>> {
val pipeline = listOf(
Document("\$match", Document("type.code", "EXPENSE")),
Document(
"\$lookup",
Document("from", "transactions")
.append(
"let",
Document("categoryId", "\$_id")
)
.append(
"pipeline", listOf(
Document(
"\$match",
Document(
"\$expr",
Document(
"\$and", listOf(
Document("\$eq", listOf("\$category.\$id", "\$\$categoryId")),
Document(
"\$gte", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
),
Document(
"\$lt", listOf(
"\$date",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC).toInstant()
)
)
)
)
)
)
),
Document(
"\$group",
Document("_id", "\$type.code")
.append(
"totalAmount",
Document("\$sum", "\$amount")
)
)
)
)
.append("as", "transactionSums")
),
Document(
"\$project",
Document("_id", 1L)
.append("type", 1L)
.append("name", 1L)
.append("description", 1L)
.append("icon", 1L)
.append(
"plannedAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "PLANNED"))
)
), 0.0
)
)
)
.append(
"instantAmount",
Document(
"\$arrayElemAt", listOf(
Document(
"\$filter",
Document("input", "\$transactionSums")
.append("as", "sum")
.append(
"cond",
Document("\$eq", listOf("\$\$sum._id", "INSTANT"))
)
), 0.0
)
)
)
),
Document(
"\$addFields",
Document(
"plannedAmount",
Document("\$ifNull", listOf("\$plannedAmount.totalAmount", 0.0))
)
.append(
"instantAmount",
Document("\$ifNull", listOf("\$instantAmount.totalAmount", 0.0))
)
)
)
return mongoTemplate.getCollection("categories")
.flatMapMany { it.aggregate(pipeline, Document::class.java) }
.map { document ->
val catType = document["type"] as Document
BudgetCategory(
currentSpent = document["instantAmount"] as Double,
currentLimit = document["plannedAmount"] as Double,
currentPlanned = document["plannedAmount"] as Double,
category = Category(
document["_id"].toString(),
CategoryType(catType["code"] as String, catType["name"] as String),
name = document["name"] as String,
description = document["description"] as String,
icon = document["icon"] as String
)
)
}
.collectList()
.map { it.toMutableList() }
}
}

View File

@@ -0,0 +1,16 @@
package space.luminic.budgerapp.services
import org.springframework.security.core.userdetails.ReactiveUserDetailsService
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
@Service
class CustomReactiveUserDetailsService(
private val userDetailsService: UserDetailsService // Ваш синхронный сервис
) : ReactiveUserDetailsService {
override fun findByUsername(username: String): Mono<UserDetails> {
return Mono.fromCallable { userDetailsService.loadUserByUsername(username) }
}
}

View File

@@ -0,0 +1,128 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.Recurrent
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.repos.RecurrentRepo
import space.luminic.budgerapp.repos.TransactionRepo
import space.luminic.budgerapp.repos.sqlrepo.RecurrentRepoSQL
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.YearMonth
import kotlin.jvm.optionals.getOrNull
@Service
class RecurrentService(
private val recurrentRepo: RecurrentRepo,
private val recurrentRepoSQL: RecurrentRepoSQL,
private val transactionRepo: TransactionRepo,
private val userService: UserService,
) {
private val logger = LoggerFactory.getLogger(javaClass)
@Cacheable("recurrentsList")
fun getRecurrents(): Mono<List<Recurrent>> {
return recurrentRepo.findAll().collectList()
}
@Cacheable("recurrents", key = "#id")
fun getRecurrentById(id: String): Mono<Recurrent> {
return recurrentRepo.findById(id)
.switchIfEmpty(Mono.error(NotFoundException("Recurrent with id: $id not found")))
}
@CacheEvict(cacheNames = ["recurrentsList", "recurrents"])
fun createRecurrent(recurrent: Recurrent): Mono<Recurrent> {
return if (recurrent.id == null && recurrent.atDay <= 31) recurrentRepo.save(recurrent) else Mono.error(
RuntimeException("Cannot create recurrent with id or date cannot be higher than 31")
)
}
@CacheEvict(cacheNames = ["recurrentsList", "recurrents"])
fun createRecurrentsForBudget(budget: Budget): Mono<Void> {
val currentYearMonth = YearMonth.of(budget.dateFrom.year, budget.dateFrom.monthValue)
val daysInCurrentMonth = currentYearMonth.lengthOfMonth()
val context = ReactiveSecurityContextHolder.getContext()
.doOnNext { println("Security context: $it") }
.switchIfEmpty(Mono.error(IllegalStateException("SecurityContext is empty!")))
return context
.map {
logger.debug(it.authentication.name)
it.authentication
}
.flatMap { authentication ->
val username = authentication.name
userService.getByUsername(username)
.switchIfEmpty(Mono.error(IllegalArgumentException("User not found for username: $username")))
}
.flatMapMany { user ->
recurrentRepo.findAll()
.map { recurrent ->
// Определяем дату транзакции
val transactionDate = when {
recurrent.atDay <= daysInCurrentMonth && recurrent.atDay >= budget.dateFrom.dayOfMonth -> {
currentYearMonth.atDay(recurrent.atDay)
}
recurrent.atDay < budget.dateFrom.dayOfMonth -> {
currentYearMonth.atDay(recurrent.atDay).plusMonths(1)
}
else -> {
val extraDays = recurrent.atDay - daysInCurrentMonth
currentYearMonth.plusMonths(1).atDay(extraDays)
}
}
// Создаем транзакцию
Transaction(
date = transactionDate,
amount = recurrent.amount.toDouble(),
category = recurrent.category,
isDone = false,
comment = recurrent.name,
user = user,
type = TransactionType("PLANNED", "Запланированные")
)
}
}
.collectList() // Собираем все транзакции в список
.flatMap { transactions ->
transactionRepo.saveAll(transactions) // Сохраняем все транзакции разом
.then() // Возвращаем Mono<Void>
}
}
fun editRecurrent(recurrent: Recurrent): Mono<Recurrent> {
return if (recurrent.id != null && recurrent.atDay <= 31) recurrentRepo.save(recurrent) else Mono.error(
RuntimeException("Cannot edit recurrent without id or date cannot be higher than 31")
)
}
fun deleteRecurrent(id: String): Mono<Void> {
return recurrentRepo.deleteById(id)
}
fun transferRecurrents() {
recurrentRepo.saveAll(recurrentRepoSQL.getRecurrents()).then().subscribe()
}
}

View File

@@ -0,0 +1,100 @@
package space.luminic.budgerapp.services
import com.interaso.webpush.VapidKeys
import com.interaso.webpush.WebPushService
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.PushMessage
import space.luminic.budgerapp.models.Subscription
import space.luminic.budgerapp.models.SubscriptionDTO
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.SubscriptionRepo
import space.luminic.budgerapp.services.VapidConstants.VAPID_PRIVATE_KEY
import space.luminic.budgerapp.services.VapidConstants.VAPID_PUBLIC_KEY
import space.luminic.budgerapp.services.VapidConstants.VAPID_SUBJECT
import java.security.GeneralSecurityException
object VapidConstants {
const val VAPID_PUBLIC_KEY =
"BKmMyBUhpkcmzYWcYsjH_spqcy0zf_8eVtZo60f7949TgLztCmv3YD0E_vtV2dTfECQ4sdLdPK3ICDcyOkCqr84"
const val VAPID_PRIVATE_KEY = "YeJH_0LhnVYN6RdxMidgR6WMYlpGXTJS3HjT9V3NSGI"
const val VAPID_SUBJECT = "mailto:voroninvyu@gmail.com"
}
@Service
class SubscriptionService(private val subscriptionRepo: SubscriptionRepo) {
private val logger = LoggerFactory.getLogger(javaClass)
private val pushService =
WebPushService(
subject = VAPID_SUBJECT,
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> {
pushService.send(
payload = Json.encodeToString(payload),
endpoint = endpoint,
p256dh = p256dh,
auth = auth
)
}
.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>> {
return subscriptionRepo.findAll()
.flatMap { sub ->
sendNotification(sub.endpoint, sub.p256dh, sub.auth, payload)
.then(Mono.just("${sub.user?.username} at endpoint ${sub.endpoint}"))
.onErrorResume { e ->
sub.isActive = false
subscriptionRepo.save(sub).then(Mono.empty())
}
}
.collectList() // Собираем результаты в список
}
fun subscribe(subscriptionDTO: SubscriptionDTO, user: User): Mono<String> {
val subscription = Subscription(
id = null,
user = user,
endpoint = subscriptionDTO.endpoint,
auth = subscriptionDTO.keys["auth"].orEmpty(),
p256dh = subscriptionDTO.keys["p256dh"].orEmpty(),
isActive = true
)
return subscriptionRepo.save(subscription)
.flatMap { savedSubscription ->
Mono.just("Subscription created with ID: ${savedSubscription.id}")
}
.onErrorResume(DuplicateKeyException::class.java) {
logger.info("Subscription already exists. Skipping.")
Mono.just("Subscription already exists. Skipping.")
}
.onErrorResume { e ->
logger.error("Error while saving subscription: ${e.message}")
Mono.error(RuntimeException("Error while saving subscription"))
}
}
}

View File

@@ -0,0 +1,42 @@
package space.luminic.budgerapp.services
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Token
import space.luminic.budgerapp.models.TokenStatus
import space.luminic.budgerapp.repos.TokenRepo
import java.time.LocalDateTime
@Service
class TokenService(private val tokenRepository: TokenRepo) {
@CacheEvict("tokens", allEntries = true)
fun saveToken(token: String, username: String, expiresAt: LocalDateTime) {
val newToken = Token(
token = token,
username = username,
issuedAt = LocalDateTime.now(),
expiresAt = expiresAt
)
tokenRepository.save(newToken)
}
@CacheEvict("tokens", allEntries = true)
fun revokeToken(token: String): Mono<Void> {
return tokenRepository.findByToken(token)
.flatMap { existingToken ->
val updatedToken = existingToken.copy(status = TokenStatus.REVOKED)
tokenRepository.save(updatedToken).then()
}
.switchIfEmpty(Mono.error(Exception("Token not found")))
}
@CacheEvict("tokens", allEntries = true)
fun deleteExpiredTokens() {
tokenRepository.deleteByExpiresAtBefore(LocalDateTime.now())
}
}

View File

@@ -0,0 +1,784 @@
package space.luminic.budgerapp.services
import com.mongodb.client.model.Aggregates.addFields
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.bson.Document
import org.bson.types.ObjectId
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.CacheEvict
import org.springframework.cache.annotation.Cacheable
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.Sort
import org.springframework.data.domain.Sort.Direction
import org.springframework.data.mongodb.MongoExpression
import org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation
import org.springframework.data.mongodb.core.MongoTemplate
import org.springframework.data.mongodb.core.ReactiveMongoTemplate
import org.springframework.data.mongodb.core.aggregation.AddFieldsOperation
import org.springframework.data.mongodb.core.aggregation.Aggregation
import org.springframework.data.mongodb.core.aggregation.Aggregation.ROOT
import org.springframework.data.mongodb.core.aggregation.Aggregation.group
import org.springframework.data.mongodb.core.aggregation.Aggregation.lookup
import org.springframework.data.mongodb.core.aggregation.Aggregation.match
import org.springframework.data.mongodb.core.aggregation.Aggregation.project
import org.springframework.data.mongodb.core.aggregation.Aggregation.sort
import org.springframework.data.mongodb.core.aggregation.Aggregation.unwind
import org.springframework.data.mongodb.core.aggregation.Aggregation.addFields
import org.springframework.data.mongodb.core.aggregation.Aggregation.limit
import org.springframework.data.mongodb.core.aggregation.Aggregation.skip
import org.springframework.data.mongodb.core.aggregation.AggregationExpression
import org.springframework.data.mongodb.core.aggregation.AggregationResults
import org.springframework.data.mongodb.core.aggregation.ArrayOperators
import org.springframework.data.mongodb.core.aggregation.ArrayOperators.Filter.filter
import org.springframework.data.mongodb.core.aggregation.ConditionalOperators
import org.springframework.data.mongodb.core.aggregation.ConditionalOperators.IfNull.ifNull
import org.springframework.data.mongodb.core.aggregation.DateOperators
import org.springframework.data.mongodb.core.aggregation.DateOperators.DateToString
import org.springframework.data.mongodb.core.aggregation.LookupOperation
import org.springframework.data.mongodb.core.aggregation.MatchOperation
import org.springframework.data.mongodb.core.query.Criteria
import org.springframework.data.mongodb.core.query.Query
import org.springframework.data.mongodb.core.query.update
import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.CategoryType
import space.luminic.budgerapp.models.SortSetting
import space.luminic.budgerapp.models.SortTypes
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.models.TransactionEvent
import space.luminic.budgerapp.models.TransactionEventType
import space.luminic.budgerapp.models.TransactionType
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.TransactionRepo
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.ArrayList
import java.util.Arrays
import java.util.Date
import kotlin.jvm.optionals.getOrNull
@Service
class TransactionService(
private val mongoTemplate: MongoTemplate,
private val reactiveMongoTemplate: ReactiveMongoTemplate,
val transactionsRepo: TransactionRepo,
val userService: UserService,
private val eventPublisher: ApplicationEventPublisher
) {
private val logger = LoggerFactory.getLogger(TransactionService::class.java)
@Cacheable("transactions")
fun getTransactions(
dateFrom: LocalDate? = null,
dateTo: LocalDate? = null,
transactionType: String? = null,
isDone: Boolean? = null,
categoryId: String? = null,
categoryType: String? = null,
userId: String? = null,
parentId: String? = null,
isChild: Boolean? = null,
sortSetting: SortSetting? = null,
limit: Int? = null,
offset: Int? = null,
): Mono<MutableList<Transaction>> {
val matchCriteria = mutableListOf<Criteria>()
// Добавляем фильтры
dateFrom?.let { matchCriteria.add(Criteria.where("date").gte(it)) }
dateTo?.let { matchCriteria.add(Criteria.where("date").lt(it)) }
transactionType?.let { matchCriteria.add(Criteria.where("type.code").`is`(it)) }
isDone?.let { matchCriteria.add(Criteria.where("isDone").`is`(it)) }
categoryId?.let { matchCriteria.add(Criteria.where("categoryDetails._id").`is`(it)) }
categoryType?.let { matchCriteria.add(Criteria.where("categoryDetails.type.code").`is`(it)) }
userId?.let { matchCriteria.add(Criteria.where("userDetails._id").`is`(ObjectId(it))) }
parentId?.let { matchCriteria.add(Criteria.where("parentId").`is`(it)) }
isChild?.let { matchCriteria.add(Criteria.where("parentId").exists(it)) }
// Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val lookupUsers = lookup("users", "user.\$id", "_id", "userDetails")
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
var sort = sort(Sort.by(Direction.DESC, "date").and(Sort.by(Direction.DESC, "createdAt")))
sortSetting?.let {
sort = sort(Sort.by(it.order, it.by).and(Sort.by(Direction.ASC, "createdAt")))
}
val aggregationBuilder = mutableListOf(
lookup,
lookupUsers,
match.takeIf { matchCriteria.isNotEmpty() },
sort,
offset?.let { skip(it.toLong()) },
limit?.let { limit(it.toLong()) }
).filterNotNull()
val aggregation = newAggregation(aggregationBuilder)
return reactiveMongoTemplate.aggregate(
aggregation, "transactions", Transaction::class.java
)
.collectList() // Преобразуем Flux<Transaction> в Mono<List<Transaction>>
.map { it.toMutableList() }
}
fun getTransactionsToDelete(dateFrom: LocalDate, dateTo: LocalDate): Mono<List<Transaction>> {
val criteria = Criteria().andOperator(
Criteria.where("date").gte(dateFrom),
Criteria.where("date").lte(dateTo),
Criteria().orOperator(
Criteria.where("type.code").`is`("PLANNED"),
Criteria.where("parentId").exists(true)
)
)
// Пример использования в MongoTemplate:
val query = Query(criteria)
// Если вы хотите использовать ReactiveMongoTemplate:
return reactiveMongoTemplate.find(query, Transaction::class.java)
.collectList()
.doOnNext { transactions -> println("Found transactions: $transactions") }
}
@Cacheable("transactions")
fun getTransactionById(id: String): Mono<Transaction> {
return transactionsRepo.findById(id)
.map {
it
}
.switchIfEmpty(
Mono.error(IllegalArgumentException("Transaction with id: $id not found"))
)
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun createTransaction(transaction: Transaction): Mono<String> {
return ReactiveSecurityContextHolder.getContext()
.map { it.authentication } // Получаем Authentication из SecurityContext
.flatMap { authentication ->
val username = authentication.name // Имя пользователя из токена
// Получаем пользователя и сохраняем транзакцию
userService.getByUsername(username)
.switchIfEmpty(Mono.error(IllegalArgumentException("User not found for username: $username")))
.flatMap { user ->
transaction.user = user
transactionsRepo.save(transaction)
.doOnNext { savedTransaction ->
// Публикуем событие после сохранения
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.CREATE,
newTransaction = savedTransaction,
oldTransaction = savedTransaction
)
)
}
.map { it.id!! } // Возвращаем ID сохраненной транзакции
}
}
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun editTransaction(transaction: Transaction): Mono<Transaction> {
return transactionsRepo.findById(transaction.id!!)
.flatMap { oldStateOfTransaction ->
val changed = compareSumDateDoneIsChanged(oldStateOfTransaction, transaction)
if (!changed) {
return@flatMap transactionsRepo.save(transaction) // Сохраняем, если изменений нет
}
val amountDifference = transaction.amount - oldStateOfTransaction.amount
// Обработка дочерней транзакции
handleChildTransaction(oldStateOfTransaction, transaction, amountDifference)
.then(transactionsRepo.save(transaction)) // Сохраняем основную транзакцию
.doOnSuccess { savedTransaction ->
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.EDIT,
newTransaction = savedTransaction,
oldTransaction = oldStateOfTransaction,
difference = amountDifference
)
)
}
}
.switchIfEmpty(
Mono.error(IllegalArgumentException("Transaction not found with id: ${transaction.id}"))
)
}
private fun handleChildTransaction(
oldTransaction: Transaction,
newTransaction: Transaction,
amountDifference: Double
): Mono<Void> {
return transactionsRepo.findByParentId(newTransaction.id!!)
.flatMap { childTransaction ->
logger.info(childTransaction.toString())
// Если родительская транзакция обновлена, обновляем дочернюю
childTransaction.amount = newTransaction.amount
childTransaction.category = newTransaction.category
childTransaction.comment = newTransaction.comment
childTransaction.user = newTransaction.user
transactionsRepo.save(childTransaction)
}
.switchIfEmpty(
Mono.defer {
// Создание новой дочерней транзакции, если требуется
if (!oldTransaction.isDone && newTransaction.isDone) {
val newChildTransaction = newTransaction.copy(
id = null,
type = TransactionType("INSTANT", "Текущие"),
parentId = newTransaction.id
)
transactionsRepo.save(newChildTransaction).doOnSuccess { savedChildTransaction ->
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.CREATE,
newTransaction = savedChildTransaction,
oldTransaction = oldTransaction,
difference = amountDifference
)
)
}
} else Mono.empty()
}
)
.flatMap {
// Удаление дочерней транзакции, если родительская помечена как не выполненная
if (oldTransaction.isDone && !newTransaction.isDone) {
transactionsRepo.findByParentId(newTransaction.id!!)
.flatMap { child ->
deleteTransaction(child.id!!)
}.then()
} else {
Mono.empty()
}
}
}
fun compareSumDateDoneIsChanged(t1: Transaction, t2: Transaction): Boolean {
return if (t1.amount != t2.amount) {
true
} else if (t1.date != t2.date) {
true
} else if (t1.isDone != t2.isDone) {
true
} else if (t1.category.id != t2.category.id) {
return true
} else {
return false
}
}
@CacheEvict(cacheNames = ["transactions"], allEntries = true)
fun deleteTransaction(transactionId: String): Mono<Void> {
return transactionsRepo.findById(transactionId)
.flatMap { transactionToDelete ->
transactionsRepo.deleteById(transactionId) // Удаляем транзакцию
.then(
Mono.fromRunnable<Void> {
// Публикуем событие после успешного удаления
eventPublisher.publishEvent(
TransactionEvent(
this,
TransactionEventType.DELETE,
newTransaction = transactionToDelete,
oldTransaction = transactionToDelete
)
)
}
)
}
}
// @CacheEvict(cacheNames = ["transactions", "childTransactions"], allEntries = true)
// fun setTransactionDone(transaction: Transaction): Transaction {
// val oldStateTransaction = transactionsRepo.findById(transaction.id!!)
// .orElseThrow { RuntimeException("Transaction ${transaction.id} not found") }
//
// if (transaction.isDone) {
// if (oldStateTransaction.isDone) {
// throw RuntimeException("Transaction ${transaction.id} is already done")
// }
//
// // Создание дочерней транзакции
// val childTransaction = transaction.copy(
// id = null,
// type = TransactionType("INSTANT", "Текущие"),
// parentId = transaction.id
// )
// createTransaction(childTransaction)
// } else {
// // Удаление дочерней транзакции, если она существует
// transactionsRepo.findByParentId(transaction.id!!).getOrNull()?.let {
// deleteTransaction(it.id!!)
// } ?: logger.warn("Child transaction of parent ${transaction.id} not found")
// }
//
// return editTransaction(transaction)
// }
@Cacheable("childTransactions", key = "#parentId")
fun getChildTransaction(parentId: String): Mono<Transaction> {
return transactionsRepo.findByParentId(parentId)
}
// fun getTransactionByOldId(id: Int): Transaction? {
// return transactionsRepo.findByOldId(id).getOrNull()
// }
// fun transferTransactions(): Mono<Void> {
// var transactions = transactionsRepoSQl.getTransactions()
// return transactionsRepo.saveAll(transactions).then()
// }
//
fun calcTransactionsSum(
budget: Budget,
categoryId: String? = null,
categoryType: String? = null,
transactionType: String? = null,
isDone: Boolean? = null
): Mono<Double> {
val matchCriteria = mutableListOf<Criteria>()
// Добавляем фильтры
matchCriteria.add(Criteria.where("date").gte(budget.dateFrom))
matchCriteria.add(Criteria.where("date").lt(budget.dateTo))
categoryId?.let { matchCriteria.add(Criteria.where("category.\$id").`is`(ObjectId(it))) }
categoryType?.let { matchCriteria.add(Criteria.where("categoryDetails.type.code").`is`(it)) }
transactionType?.let { matchCriteria.add(Criteria.where("type.code").`is`(it)) }
isDone?.let { matchCriteria.add(Criteria.where("isDone").`is`(it)) }
// Сборка агрегации
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwind = unwind("categoryDetails")
val match = match(Criteria().andOperator(*matchCriteria.toTypedArray()))
val project = project("category").andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val group = group(categoryId ?: "all").sum("amount").`as`("totalSum")
val projectSum = project("totalSum")
val aggregation = newAggregation(lookup, unwind, match, project, group, projectSum)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.map { result ->
val totalSum = result["totalSum"]
if (totalSum is Double) {
totalSum
} else {
0.0
}
}
.reduce(0.0) { acc, sum -> acc + sum } // Суммируем значения, если несколько результатов
}
// @Cacheable("transactions")
fun getAverageSpendingByCategory(): Mono<Map<String, Double>> {
val firstDateOfMonth = LocalDate.now().with(TemporalAdjusters.firstDayOfMonth())
val lookup = lookup("categories", "category.\$id", "_id", "categoryDetails")
val unwind = unwind("categoryDetails")
val match = match(
Criteria.where("categoryDetails.type.code").`is`("EXPENSE")
.and("type.code").`is`("INSTANT")
.and("date").lt(firstDateOfMonth)
)
val projectDate = project("_id", "category", "amount", "categoryDetails")
.and(DateToString.dateOf("date").toString("%Y-%m")).`as`("month")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val groupByMonthAndCategory = group("month", "category.\$id").sum("amount").`as`("sum")
val groupByCategory = group("_id.id").avg("sum").`as`("averageAmount")
val project = project()
.and("_id").`as`("category")
.and("averageAmount").`as`("avgAmount")
val sort = sort(Sort.by(Sort.Order.asc("_id")))
val aggregation = newAggregation(
lookup, unwind, match, projectDate, groupByMonthAndCategory, groupByCategory, project, sort
)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.collectList()
.map { results ->
results.associate { result ->
val category = result["category"]?.toString() ?: "Unknown"
val avgAmount = (result["avgAmount"] as? Double) ?: 0.0
category to avgAmount
}
}
.defaultIfEmpty(emptyMap()) // Возвращаем пустую карту, если результатов нет
}
@Cacheable("transactionTypes")
fun getTransactionTypes(): List<TransactionType> {
var types = mutableListOf<TransactionType>()
types.add(TransactionType("PLANNED", "Плановые"))
types.add(TransactionType("INSTANT", "Текущие"))
return types
}
fun getAverageIncome(): Mono<Double> {
val lookup = lookup("categories", "category.\$id", "_id", "detailedCategory")
val unwind = unwind("detailedCategory")
val match = match(
Criteria.where("detailedCategory.type.code").`is`("INCOME")
.and("type.code").`is`("INSTANT")
.and("isDone").`is`(true)
)
val project = project("_id", "category", "detailedCategory")
.and(DateToString.dateOf("date").toString("%Y-%m")).`as`("month")
.andExpression("{ \$toDouble: \"\$amount\" }").`as`("amount")
val groupByMonth = group("month").sum("amount").`as`("sum")
val groupForAverage = group("avgIncomeByMonth").avg("sum").`as`("averageAmount")
val aggregation = newAggregation(lookup, unwind, match, project, groupByMonth, groupForAverage)
return reactiveMongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
.singleOrEmpty() // Ожидаем только один результат
.map { result ->
result["averageAmount"] as? Double ?: 0.0
}
.defaultIfEmpty(0.0) // Если результат пустой, возвращаем 0.0
}
fun getTransactionsByTypes(dateFrom: LocalDate, dateTo: LocalDate): Mono<Map<String, List<Transaction>>> {
logger.info("here tran starts")
val pipeline = listOf(
Document(
"\$lookup",
Document("from", "categories")
.append("localField", "category.\$id")
.append("foreignField", "_id")
.append("as", "categoryDetailed")
),
Document(
"\$lookup",
Document("from", "users")
.append("localField", "user.\$id")
.append("foreignField", "_id")
.append("as", "userDetailed")
),
Document(
"\$unwind",
Document("path", "\$categoryDetailed").append("preserveNullAndEmptyArrays", true)
),
Document(
"\$unwind",
Document("path", "\$userDetailed").append("preserveNullAndEmptyArrays", true)
),
Document(
"\$match",
Document(
"\$and", listOf(
Document(
"date",
Document(
"\$gte",
Date.from(
LocalDateTime.of(dateFrom, LocalTime.MIN)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toInstant()
)
)
),
Document(
"date",
Document(
"\$lt",
Date.from(
LocalDateTime.of(dateTo, LocalTime.MAX)
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneOffset.UTC)
.toInstant()
)
)
)
)
)
),
Document(
"\$facet",
Document(
"plannedExpenses",
listOf(
Document(
"\$match",
Document("type.code", "PLANNED")
.append("categoryDetailed.type.code", "EXPENSE")
),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
.append(
"plannedIncomes",
listOf(
Document(
"\$match",
Document("type.code", "PLANNED")
.append("categoryDetailed.type.code", "INCOME")
),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
.append(
"instantTransactions",
listOf(
Document("\$match", Document("type.code", "INSTANT")),
Document("\$sort", Document("date", 1).append("_id", 1))
)
)
)
)
// getCategoriesExplainReactive(pipeline)
// .doOnNext { explainResult ->
// logger.info("Explain Result: ${explainResult.toJson()}")
// }
// .subscribe() // Этот вызов лучше оставить только для отладки
return reactiveMongoTemplate.getCollection("transactions")
.flatMapMany { it.aggregate(pipeline, Document::class.java) }
.single() // Получаем только первый результат агрегации
.flatMap { aggregationResult ->
Mono.zip(
extractTransactions(aggregationResult, "plannedExpenses"),
extractTransactions(aggregationResult, "plannedIncomes"),
extractTransactions(aggregationResult, "instantTransactions")
).map { tuple ->
val plannedExpenses = tuple.t1
val plannedIncomes = tuple.t2
val instantTransactions = tuple.t3
logger.info("here tran ends")
mapOf(
"plannedExpenses" to plannedExpenses,
"plannedIncomes" to plannedIncomes,
"instantTransactions" to instantTransactions
)
}
}
}
fun getCategoriesExplainReactive(pipeline: List<Document>): Mono<Document> {
val command = Document("aggregate", "transactions")
.append("pipeline", pipeline)
.append("explain", true)
return reactiveMongoTemplate.executeCommand(command)
}
private fun extractTransactions(aggregationResult: Document, key: String): Mono<List<Transaction>> {
val resultTransactions = aggregationResult[key] as? List<Document> ?: emptyList()
return Flux.fromIterable(resultTransactions)
.map { documentToTransactionMapper(it) }
.collectList()
}
private fun documentToTransactionMapper(document: Document): Transaction {
val transactionType = document["type"] as Document
var user: User? = null
val userDocument = document["userDetailed"] as Document
user = User(
id = (userDocument["_id"] as ObjectId).toString(),
username = userDocument["username"] as String,
firstName = userDocument["firstName"] as String,
tgId = userDocument["tgId"] as String,
tgUserName = userDocument["tgUserName"]?.let { it as String },
password = null,
isActive = userDocument["isActive"] as Boolean,
regDate = userDocument["regDate"] as Date,
createdAt = userDocument["createdAt"] as Date,
roles = userDocument["roles"] as ArrayList<String>,
)
val categoryDocument = document["categoryDetailed"] as Document
val categoryTypeDocument = categoryDocument["type"] as Document
val category = Category(
id = (categoryDocument["_id"] as ObjectId).toString(),
type = CategoryType(categoryTypeDocument["code"] as String, categoryTypeDocument["name"] as String),
name = categoryDocument["name"] as String,
description = categoryDocument["description"] as String,
icon = categoryDocument["icon"] as String
)
return Transaction(
(document["_id"] as ObjectId).toString(),
TransactionType(
transactionType["code"] as String,
transactionType["name"] as String
),
user = user!!,
category = category,
comment = document["comment"] as String,
date = (document["date"] as Date).toInstant().atZone(ZoneId.systemDefault()).toLocalDate(),
amount = document["amount"] as Double,
isDone = document["isDone"] as Boolean,
parentId = if (document["parentId"] != null) document["parentId"] as String else null,
createdAt = LocalDateTime.ofInstant((document["createdAt"] as Date).toInstant(), ZoneOffset.UTC),
)
}
// fun getPlannedForBudget(budget: Budget, transactionType: String? = null): List<Map<String, Any>> {
// // 1) $lookup: "categories"
// val lookupCategories = Aggregation.lookup(
// "categories",
// "category.\$id",
// "_id",
// "categoryDetailed"
// )
//
// // 2) $lookup: "budgets" (pipeline + let)
// val matchBudgetsDoc = Document(
// "\$expr", Document(
// "\$and", listOf(
// Document("\$gte", listOf("\$\$transactionDate", "\$dateFrom")),
// Document("\$lt", listOf("\$\$transactionDate", "\$dateTo"))
// )
// )
// )
// val matchBudgetsOp = MatchOperation(matchBudgetsDoc)
//
// val lookupBudgets = LookupOperation.newLookup()
// .from("budgets")
// .letValueOf("transactionDate").bindTo("date")
// .pipeline(matchBudgetsOp)
// .`as`("budgetDetails")
//
// // 3) $unwind
// val unwindCategory = Aggregation.unwind("categoryDetailed")
// val unwindBudget = Aggregation.unwind("budgetDetails")
//
// // 4) $match: диапазон дат
// val matchDates = Aggregation.match(
// Criteria("date")
// .gte(budget.dateFrom)
// .lt(budget.dateTo)
// )
//
// // 5) $facet (разные ветки: plannedExpenses, plannedExpensesSum, ...)
// // plannedExpenses
// val plannedExpensesMatch = Aggregation.match(
// Criteria().andOperator(
// Criteria("type.code").`is`("PLANNED"),
// Criteria("categoryDetailed.type.code").`is`("EXPENSE")
// )
// )
// val plannedExpensesPipeline = listOf(plannedExpensesMatch)
//
// // plannedExpensesSum
// val plannedExpensesSumPipeline = listOf(
// plannedExpensesMatch,
// group(null).`as`("_id").sum("amount").`as`("sum"),
// project("sum").andExclude("_id")
// )
//
// // plannedIncome
// val plannedIncomeMatch = Aggregation.match(
// Criteria().andOperator(
// Criteria("type.code").`is`("PLANNED"),
// Criteria("categoryDetailed.type.code").`is`("INCOME")
// )
// )
// val plannedIncomePipeline = listOf(plannedIncomeMatch)
//
// // plannedIncomeSum
// val plannedIncomeSumPipeline = listOf(
// plannedIncomeMatch,
// group().`as`("_id").sum("amount").`as`("sum"),
// project("sum").andExclude("_id")
// )
//
// // instantTransactions
// val instantTransactionsMatch = Aggregation.match(
// Criteria("type.code").`is`("INSTANT")
// )
// val instantTransactionsProject = Aggregation.project(
// "_id", "type", "comment", "user", "amount", "date",
// "category", "isDone", "createdAt", "parentId"
// )
// val instantTransactionsPipeline = listOf(instantTransactionsMatch, instantTransactionsProject)
//
// val facetStage = Aggregation.facet(*plannedExpensesPipeline.toTypedArray()).`as`("plannedExpenses")
// .and(*plannedExpensesSumPipeline.toTypedArray()).`as`("plannedExpensesSum")
// .and(*plannedIncomePipeline.toTypedArray()).`as`("plannedIncome")
// .and(*plannedIncomeSumPipeline.toTypedArray()).`as`("plannedIncomeSum")
// .and(*instantTransactionsPipeline.toTypedArray()).`as`("instantTransactions")
//
// // 6) $set: вытаскиваем суммы из массивов
// val setStage = AddFieldsOperation.builder()
// .addField("plannedExpensesSum").withValue(
// ArrayOperators.ArrayElemAt.arrayOf("\$plannedExpensesSum.sum").elementAt(0)
// )
// .addField("plannedIncomeSum").withValue(
// ArrayOperators.ArrayElemAt.arrayOf("\$plannedIncomeSum.sum").elementAt(0)
// )
// .build()
//
// // Собираем все стадии
// val aggregation = Aggregation.newAggregation(
// lookupCategories,
// lookupBudgets,
// unwindCategory,
// unwindBudget,
// matchDates,
// facetStage,
// setStage
// )
//
// val results = mongoTemplate.aggregate(aggregation, "transactions", Map::class.java)
// return results.mappedResults
// }
}

View File

@@ -0,0 +1,50 @@
package space.luminic.budgerapp.services
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.Budget
import space.luminic.budgerapp.models.Category
import space.luminic.budgerapp.models.Transaction
import space.luminic.budgerapp.repos.BudgetRepo
import space.luminic.budgerapp.repos.CategoryRepo
import space.luminic.budgerapp.repos.TransactionRepo
import space.luminic.budgerapp.repos.sqlrepo.BudgetRepoSQL
import space.luminic.budgerapp.repos.sqlrepo.CategoriesRepoSQL
import space.luminic.budgerapp.repos.sqlrepo.TransactionsRepoSQl
@Service
class TransferService(
private val transactionsRepoSQl: TransactionsRepoSQl,
private val categoriesRepoSQL: CategoriesRepoSQL,
private val budgetRepoSQL: BudgetRepoSQL,
private val categoryRepo: CategoryRepo,
private val transactionRepo: TransactionRepo,
private val budgetService: BudgetService
) {
fun getTransactions(): Mono<List<Transaction>> {
val transactions = transactionsRepoSQl.getTransactions()
return transactionRepo.saveAll(transactions).collectList()
}
fun getCategories(): Mono<List<Category>> {
val categories = categoriesRepoSQL.getCategories()
return Flux.fromIterable(categories)
.flatMap { category -> categoryRepo.save(category) }
.collectList() // Преобразуем Flux<Category> в Mono<List<Category>>
}
fun transferBudgets(): Mono<List<Budget>> {
val budgets = budgetRepoSQL.getBudgets()
return Flux.fromIterable(budgets)
.flatMap { budget ->
budgetService.createBudget(budget.budget, budget.createRecurrent)
}.collectList()
}
}

View File

@@ -0,0 +1,57 @@
package space.luminic.budgerapp.services
import org.slf4j.LoggerFactory
import org.springframework.cache.annotation.Cacheable
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import reactor.core.publisher.Mono
import space.luminic.budgerapp.models.NotFoundException
import space.luminic.budgerapp.models.User
import space.luminic.budgerapp.repos.UserRepo
import kotlin.jvm.optionals.getOrNull
@Service
class UserService(val userRepo: UserRepo, val passwordEncoder: PasswordEncoder) {
val logger = LoggerFactory.getLogger(javaClass)
// fun regenPass(): List<User>? {
// var users = getUsers()!!.toMutableList()
// for (user in users) {
// user.password = passwordEncoder.encode(user.password)
// }
// userRepo.saveAll<User>(users)
// return users
// }
@Cacheable("users", key = "#username")
fun getByUsername(username: String): Mono<User> {
return userRepo.findByUsernameWOPassword(username).switchIfEmpty(
Mono.error(NotFoundException("User with username: $username not found"))
)
}
fun getById(id: String): Mono<User> {
return userRepo.findById(id)
.map { user ->
user.apply { password = null } // Убираем пароль
}
.switchIfEmpty(Mono.error(Exception("User not found"))) // Обрабатываем случай, когда пользователь не найден
}
@Cacheable("users", key = "#username")
fun getByUserNameWoPass(username: String): Mono<User> {
return userRepo.findByUsernameWOPassword(username)
}
fun getUsers(): Mono<List<User>> {
return userRepo.findAll()
.map { user -> user.apply { password = null } } // Убираем пароль
.collectList() // Преобразуем Flux<User> в Mono<List<User>>
.doOnNext { logger.debug("Users fetched successfully: ${it.size} users found") }
}
}

View File

@@ -0,0 +1,19 @@
package space.luminic.budgerapp.utils
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Date
//class DateSerializer : JsonDeserializer<LocalDateTime>() {
//
// private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") // Пример формата
//
// override fun deserialize(p: JsonParser, ctxt: DeserializationContext): LocalDateTime {
// val dateAsString = p.text
// var date = formatter.parse(dateAsString)
// return LocalDateTime.from(date, LocalDateTime.MIN ) // Преобразуем строку в LocalDateTime
// }
//}

View File

@@ -0,0 +1,54 @@
package space.luminic.budgerapp.utils
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import org.springframework.stereotype.Component
import space.luminic.budgerapp.services.TokenService
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.*
@Component
class JWTUtil(private val tokenService: TokenService) {
private val key = Keys.hmacShaKeyFor("MyTusimMyFleximMyEstSilaNasNeVzlomayutEtoNevozmozhno".toByteArray())
fun generateToken(username: String): String {
val expireAt = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 10)
val token = Jwts.builder()
.setSubject(username)
.setIssuedAt(Date())
.setExpiration(expireAt) // 10 дней
.signWith(key)
.compact()
tokenService.saveToken(
token,
username,
LocalDateTime.from(
expireAt.toInstant().atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
)
return token
}
fun validateToken(token: String): String? {
return try {
val claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
claims.body.subject
} catch (e: io.jsonwebtoken.ExpiredJwtException) {
println("Token expired: ${e.message}")
null
} catch (e: io.jsonwebtoken.SignatureException) {
println("Invalid token signature: ${e.message}")
null
} catch (e: Exception) {
println("Token validation error: ${e.message}")
null
}
}
}

View File

@@ -0,0 +1,24 @@
spring.application.name=budger-app
spring.data.mongodb.host=127.0.0.1
spring.data.mongodb.port=27017
spring.data.mongodb.database=budger-app
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
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

@@ -0,0 +1,25 @@
spring.application.name=budger-app
spring.data.mongodb.host=213.226.71.138
spring.data.mongodb.port=27017
spring.data.mongodb.database=budger-app
spring.data.mongodb.username=budger-app
spring.data.mongodb.password=BA1q2w3e4r!
spring.data.mongodb.authentication-database=admin
spring.datasource.url=jdbc:postgresql://213.183.51.243/familybudget_app
spring.datasource.username=familybudget_app
spring.datasource.password=FB1q2w3e4r!
logging.level.org.springframework.web=INFO
logging.level.org.springframework.data = INFO
logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=INFO
logging.level.org.springframework.security = INFO
logging.level.org.springframework.data.mongodb.code = INFO
logging.level.org.springframework.web.reactive=INFO

View File

@@ -0,0 +1,29 @@
spring.application.name=budger-app
server.port=8082
#server.servlet.context-path=/api
spring.webflux.base-path=/api
spring.profiles.active=prod
spring.main.web-application-type=reactive
logging.level.org.springframework.web=DEBUG
logging.level.org.springframework.data = DEBUG
logging.level.org.springframework.data.mongodb.core.ReactiveMongoTemplate=DEBUG
logging.level.org.springframework.security = DEBUG
logging.level.org.springframework.data.mongodb.code = DEBUG
logging.level.org.springframework.web.reactive=DEBUG
server.compression.enabled=true
server.compression.mime-types=application/json
# ??????? 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