Add category routes

This commit is contained in:
William Brawner 2021-08-03 19:55:03 -06:00
parent ed1dde49bf
commit 4de09a8f1c
16 changed files with 247 additions and 309 deletions

1
api/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -23,8 +23,10 @@ fun Application.budgetRoutes(
block: suspend (Budget) -> Unit
) {
val session = call.principal<Session>()!!
val userPermission =
permissionRepository.findAllByUserId(session.userId).firstOrNull { it.budgetId == budgetId }
val userPermission = permissionRepository.findAll(
userId = session.userId,
budgetIds = listOf(budgetId)
).firstOrNull()
if (userPermission?.permission?.isNotAtLeast(permission) != true) {
call.respond(HttpStatusCode.Forbidden)
return
@ -33,24 +35,20 @@ fun Application.budgetRoutes(
}
routing {
route("/api/budgets") {
authenticate(optional = false) {
get("/") {
val session = call.principal<Session>()!!
val budgetIds = permissionRepository.findAllByUserId(session.userId).map { it.budgetId }
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
val budgets = budgetRepository.findAllByIds(budgetIds).map {
BudgetResponse(it, permissionRepository.findAllByBudgetId(it.id))
}
if (call.request.contentType() == ContentType.Application.Json) {
} else {
call.respondHtml()
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
}
call.respond(budgets)
}
get("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
val users = permissionRepository.findAllByBudgetId(budget.id)
val users = permissionRepository.findAll(budgetIds = listOf(budget.id))
call.respond(BudgetResponse(budget, users))
}
}
@ -98,8 +96,8 @@ fun Application.budgetRoutes(
val description = request.description ?: budget.description
val users = request.users?.map {
permissionRepository.save(UserPermission(budget.id, it.user, it.permission))
} ?: permissionRepository.findAllByBudgetId(budget.id)
permissionRepository.findAllByBudgetId(budget.id).forEach {
} ?: permissionRepository.findAll(budgetIds = listOf(budget.id))
permissionRepository.findAll(budgetIds = listOf(budget.id)).forEach {
if (it.permission != Permission.OWNER && users.none { userPermission -> userPermission.userId == it.userId }) {
permissionRepository.delete(it)
}
@ -114,11 +112,12 @@ fun Application.budgetRoutes(
}
delete("/{id}") {
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget ->
budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.OWNER) { budget ->
budgetRepository.delete(budget)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
}
}

View file

@ -2,18 +2,11 @@ package com.wbrawner.twigs
import com.wbrawner.twigs.model.Category
data class NewCategoryRequest(
val title: String,
val description: String? = null,
val amount: Long,
val budgetId: String,
val expense: Boolean
)
data class UpdateCategoryRequest(
data class CategoryRequest(
val title: String? = null,
val description: String? = null,
val amount: Long? = null,
val budgetId: String? = null,
val expense: Boolean? = null,
val archived: Boolean? = null
)

View file

@ -0,0 +1,148 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.Category
import com.wbrawner.twigs.model.Permission
import com.wbrawner.twigs.storage.CategoryRepository
import com.wbrawner.twigs.storage.PermissionRepository
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.http.*
import io.ktor.request.*
import io.ktor.response.*
import io.ktor.routing.*
import io.ktor.util.pipeline.*
fun Application.categoryRoutes(
categoryRepository: CategoryRepository,
permissionRepository: PermissionRepository
) {
routing {
route("/api/categories") {
authenticate(optional = false) {
get("/") {
val session = call.principal<Session>()!!
call.respond(categoryRepository.findAll(
budgetIds = permissionRepository.findAll(
budgetIds = call.request.queryParameters.getAll("budgetIds"),
userId = session.userId
).map { it.budgetId },
expense = call.request.queryParameters["expense"]?.toBoolean(),
archived = call.request.queryParameters["archived"]?.toBoolean()
).map { CategoryResponse(it) })
}
get("/{id}") {
val session = call.principal<Session>()!!
call.respond(categoryRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = permissionRepository.findAll(
userId = session.userId
).map { it.budgetId }
).map { CategoryResponse(it) })
}
post("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>()
if (request.title.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Title cannot be null or empty"))
return@post
}
if (request.budgetId.isNullOrBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse("Budget ID cannot be null or empty"))
return@post
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
request.budgetId,
Permission.WRITE
) {
return@post
}
call.respond(
CategoryResponse(
categoryRepository.save(
Category(
title = request.title,
description = request.description,
amount = request.amount ?: 0L,
expense = request.expense ?: true,
)
)
)
)
}
put("/{id}") {
val session = call.principal<Session>()!!
val request = call.receive<CategoryRequest>()
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
call.respond(HttpStatusCode.NotFound)
return@put
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId!!,
Permission.WRITE
) {
return@put
}
call.respond(
CategoryResponse(
categoryRepository.save(
Category(
title = request.title ?: category.title,
description = request.description ?: category.description,
amount = request.amount ?: category.amount,
expense = request.expense ?: category.expense,
archived = request.archived ?: category.archived
)
)
)
)
}
delete("/{id}") {
val session = call.principal<Session>()!!
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
.firstOrNull()
?: run {
call.respond(HttpStatusCode.NotFound)
return@delete
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId!!,
Permission.WRITE
) {
return@delete
}
categoryRepository.delete(category)
}
}
}
}
}
suspend inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
permissionRepository: PermissionRepository,
userId: String,
budgetId: String,
permission: Permission,
otherwise: () -> Unit
) {
permissionRepository.findAll(
userId = userId,
budgetIds = listOf(budgetId)
).firstOrNull {
it.permission.isAtLeast(permission)
} ?: run {
call.respond(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
otherwise()
}
}

View file

@ -1,6 +1,7 @@
package com.wbrawner.twigs.server
import com.wbrawner.twigs.budgetRoutes
import com.wbrawner.twigs.categoryRoutes
import io.ktor.application.*
import io.ktor.auth.*
@ -10,4 +11,5 @@ fun Application.module(budgetReposi) {
install(Authentication)
budgetRoutes()
categoryRoutes()
}

View file

@ -1,171 +0,0 @@
package com.wbrawner.twigs.server.category
import com.wbrawner.twigs.ErrorResponse
import com.wbrawner.twigs.server.currentUser
import com.wbrawner.twigs.server.firstOfMonth
import com.wbrawner.twigs.server.permission.Permission
import com.wbrawner.twigs.server.permission.UserPermission
import com.wbrawner.twigs.server.permission.UserPermissionRepository
import com.wbrawner.twigs.server.transaction.Transaction
import com.wbrawner.twigs.server.transaction.TransactionRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.function.Consumer
import java.util.stream.Collectors
import javax.transaction.Transactional
@RestController
@RequestMapping(path = ["/categories"])
@Transactional
open class CategoryController(
private val categoryRepository: CategoryRepository,
private val transactionRepository: TransactionRepository,
private val userPermissionsRepository: UserPermissionRepository
) {
@GetMapping(path = [""], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategories(
@RequestParam(name = "budgetIds", required = false) budgetIds: List<String?>?,
@RequestParam(name = "isExpense", required = false) isExpense: Boolean?,
@RequestParam(name = "includeArchived", required = false) includeArchived: Boolean?,
@RequestParam(name = "count", required = false) count: Int?,
@RequestParam(name = "page", required = false) page: Int?,
@RequestParam(name = "false", required = false) sortBy: String?,
@RequestParam(name = "sortOrder", required = false) sortOrder: Sort.Direction?
): ResponseEntity<List<CategoryResponse>> {
val userPermissions: List<UserPermission>
userPermissions = if (budgetIds != null && !budgetIds.isEmpty()) {
userPermissionsRepository.findAllByUserAndBudget_IdIn(
currentUser,
budgetIds,
PageRequest.of(page ?: 0, count ?: 1000)
)
} else {
userPermissionsRepository.findAllByUser(currentUser, null)
}
val budgets = userPermissions.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val pageRequest = PageRequest.of(
Math.min(0, if (page != null) page - 1 else 0),
count ?: 1000,
sortOrder ?: Sort.Direction.ASC,
sortBy ?: "title"
)
val archived = if (includeArchived == null || includeArchived == false) false else null
val categories = categoryRepository.findAllByBudgetIn(budgets, isExpense, archived, pageRequest)
return ResponseEntity.ok(
categories.stream()
.map { category: Category -> CategoryResponse(category) }
.collect(Collectors.toList())
)
}
@GetMapping(path = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategory(@PathVariable id: String?): ResponseEntity<CategoryResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(CategoryResponse(category))
}
@GetMapping(path = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE])
open fun getCategoryBalance(@PathVariable id: String?): ResponseEntity<CategoryBalanceResponse> {
val budgets = userPermissionsRepository.findAllByUser(currentUser, null)
.stream()
.map { obj: UserPermission -> obj.budget }
.collect(Collectors.toList())
val category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null)
?: return ResponseEntity.notFound().build()
val sum = transactionRepository.sumBalanceByCategoryId(category.id, firstOfMonth)
return ResponseEntity.ok(CategoryBalanceResponse(category.id, sum))
}
@PostMapping(
path = [""],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun newCategory(@RequestBody request: NewCategoryRequest): ResponseEntity<Any> {
val userResponse = userPermissionsRepository.findByUserAndBudget_Id(currentUser, request.budgetId)
.orElse(null) ?: return ResponseEntity.badRequest().body(ErrorResponse("Invalid budget ID"))
if (userResponse.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
val budget = userResponse.budget
return ResponseEntity.ok(
CategoryResponse(
categoryRepository.save(
Category(
title = request.title,
description = request.description,
amount = request.amount,
budget = budget,
expense = request.expense
)
)
)
)
}
@PutMapping(
path = ["/{id}"],
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = [MediaType.APPLICATION_JSON_VALUE]
)
open fun updateCategory(
@PathVariable id: String,
@RequestBody request: UpdateCategoryRequest
): ResponseEntity<CategoryResponse> {
val category = categoryRepository.findById(id).orElse(null)
?: return ResponseEntity.notFound().build()
val userPermission = userPermissionsRepository.findByUserAndBudget_Id(
currentUser,
category.budget!!.id
).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
if (request.title != null) {
category.title = request.title
}
if (request.description != null) {
category.description = request.description
}
if (request.amount != null) {
category.amount = request.amount
}
if (request.expense != null) {
category.expense = request.expense
}
if (request.archived != null) {
category.archived = request.archived
}
return ResponseEntity.ok(CategoryResponse(categoryRepository.save(category)))
}
@DeleteMapping(path = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
open fun deleteCategory(@PathVariable id: String): ResponseEntity<Void> {
val category = categoryRepository.findById(id).orElse(null) ?: return ResponseEntity.notFound().build()
val userPermission =
userPermissionsRepository.findByUserAndBudget_Id(currentUser, category.budget!!.id).orElse(null)
?: return ResponseEntity.notFound().build()
if (userPermission.permission.isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build()
}
transactionRepository.findAllByBudgetAndCategory(userPermission.budget, category)
.forEach(Consumer { transaction: Transaction ->
transaction.category = null
transactionRepository.save(transaction)
})
categoryRepository.delete(category)
return ResponseEntity.ok().build()
}
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.twigs.server.category
import com.wbrawner.twigs.server.budget.Budget
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface CategoryRepository : PagingAndSortingRepository<Category, String> {
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<Category>
@Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)")
fun findAllByBudgetIn(
budgets: List<Budget?>?,
expense: Boolean?,
archived: Boolean?,
pageable: Pageable?
): List<Category>
fun findByBudgetInAndId(budgets: List<Budget?>?, id: String?): Optional<Category>
fun findByBudgetAndId(budget: Budget?, id: String?): Optional<Category>
fun findAllByBudgetInAndIdIn(budgets: List<Budget?>?, ids: List<String?>?, pageable: Pageable?): List<Category>
}

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs.server.passwordresetrequest
import org.springframework.data.repository.PagingAndSortingRepository
interface PasswordResetRequestRepository : PagingAndSortingRepository<PasswordResetRequest, Long>

View file

@ -1,15 +0,0 @@
package com.wbrawner.twigs.server.permission
import com.wbrawner.twigs.server.budget.Budget
import com.wbrawner.twigs.server.user.User
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserPermissionRepository : PagingAndSortingRepository<UserPermission, UserPermissionKey> {
fun findByUserAndBudget_Id(user: User?, budgetId: String?): Optional<UserPermission>
fun findAllByUser(user: User?, pageable: Pageable?): List<UserPermission>
fun findAllByBudget(budget: Budget?, pageable: Pageable?): List<UserPermission>
fun findAllByUserAndBudget(user: User?, budget: Budget?, pageable: Pageable?): List<UserPermission>
fun findAllByUserAndBudget_IdIn(user: User?, budgetIds: List<String?>?, pageable: Pageable?): List<UserPermission>
}

View file

@ -1,11 +0,0 @@
package com.wbrawner.twigs.server.session
import org.springframework.data.repository.PagingAndSortingRepository
import java.util.*
interface UserSessionRepository : PagingAndSortingRepository<Session, String> {
fun findByUserId(userId: String?): List<Session?>?
fun findByToken(token: String?): Optional<Session?>?
fun findByUserIdAndToken(userId: String?, token: String?): Optional<Session?>?
fun deleteAllByExpirationBefore(expiration: Date?)
}

1
core/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -7,7 +7,7 @@ data class Category(
var title: String = "",
var description: String? = null,
var amount: Long = 0L,
var budget: Budget? = null,
var budgetId: String? = null,
var expense: Boolean = true,
var archived: Boolean = false
)

View file

@ -30,6 +30,10 @@ enum class Permission {
*/
OWNER;
fun isAtLeast(wanted: Permission): Boolean {
return ordinal >= wanted.ordinal
}
fun isNotAtLeast(wanted: Permission): Boolean {
return ordinal < wanted.ordinal
}

1
storage/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
build/

View file

@ -0,0 +1,12 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.Category
interface CategoryRepository : Repository<Category> {
fun findAll(
ids: List<String>? = null,
budgetIds: List<String>? = null,
expense: Boolean? = null,
archived: Boolean? = null
): List<Category>
}

View file

@ -3,6 +3,8 @@ package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.UserPermission
interface PermissionRepository : Repository<UserPermission> {
fun findAllByBudgetId(budgetId: String): List<UserPermission>
fun findAllByUserId(userId: String): List<UserPermission>
fun findAll(
budgetIds: List<String>? = null,
userId: String? = null
): List<UserPermission>
}