Finish implementing remaining service classes
This commit is contained in:
parent
a460421497
commit
9a01fb39ec
23 changed files with 931 additions and 661 deletions
|
@ -8,8 +8,8 @@ dependencies {
|
||||||
implementation(kotlin("stdlib"))
|
implementation(kotlin("stdlib"))
|
||||||
api(project(":core"))
|
api(project(":core"))
|
||||||
implementation(project(":service"))
|
implementation(project(":service"))
|
||||||
|
implementation(project(":storage"))
|
||||||
api(libs.ktor.server.core)
|
api(libs.ktor.server.core)
|
||||||
api(libs.ktor.serialization)
|
|
||||||
api(libs.kotlinx.coroutines.core)
|
api(libs.kotlinx.coroutines.core)
|
||||||
testImplementation(project(":testhelpers"))
|
testImplementation(project(":testhelpers"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,48 +1,58 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Session
|
|
||||||
import com.wbrawner.twigs.service.budget.BudgetRequest
|
|
||||||
import com.wbrawner.twigs.service.budget.BudgetService
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
|
import com.wbrawner.twigs.service.requireSession
|
||||||
|
import com.wbrawner.twigs.service.respondCatching
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.util.*
|
||||||
|
|
||||||
fun Application.budgetRoutes(budgetService: BudgetService) {
|
fun Application.budgetRoutes(budgetService: BudgetService) {
|
||||||
routing {
|
routing {
|
||||||
route("/api/budgets") {
|
route("/api/budgets") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get {
|
get {
|
||||||
val session = requireNotNull(call.principal<Session>()) { "session is required" }
|
call.respondCatching {
|
||||||
call.respond(budgetService.budgetsForUser(userId = session.userId))
|
budgetService.budgetsForUser(userId = requireSession().userId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val session = requireNotNull(call.principal<Session>()) { "session is required" }
|
call.respondCatching {
|
||||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
budgetService.budget(
|
||||||
call.respond(budgetService.budget(budgetId = budgetId, userId = session.userId))
|
budgetId = call.parameters.getOrFail("id"),
|
||||||
|
userId = requireSession().userId
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<BudgetRequest>()
|
budgetService.save(request = call.receive(), userId = requireSession().userId)
|
||||||
call.respond(budgetService.save(request = request, userId = session.userId))
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
put("/{id}") {
|
put("/{id}") {
|
||||||
val session = requireNotNull(call.principal<Session>()) { "session was null" }
|
call.respondCatching {
|
||||||
val request = call.receive<BudgetRequest>()
|
budgetService.save(
|
||||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
request = call.receive(),
|
||||||
call.respond(budgetService.save(request = request, userId = session.id, budgetId = budgetId))
|
userId = requireSession().userId,
|
||||||
|
budgetId = call.parameters.getOrFail("id")
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = requireNotNull(call.principal<Session>()) { "session was null" }
|
call.respondCatching {
|
||||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
budgetService.delete(
|
||||||
budgetService.delete(budgetId = budgetId, userId = session.userId)
|
budgetId = call.parameters.getOrFail("id"),
|
||||||
call.respond(HttpStatusCode.NoContent)
|
userId = requireSession().userId
|
||||||
|
)
|
||||||
|
HttpStatusCode.NoContent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,143 +1,64 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Category
|
|
||||||
import com.wbrawner.twigs.model.Permission
|
|
||||||
import com.wbrawner.twigs.model.Session
|
|
||||||
import com.wbrawner.twigs.service.category.CategoryRequest
|
import com.wbrawner.twigs.service.category.CategoryRequest
|
||||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
import com.wbrawner.twigs.service.category.CategoryService
|
||||||
import com.wbrawner.twigs.service.errorResponse
|
import com.wbrawner.twigs.service.requireSession
|
||||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
import com.wbrawner.twigs.service.respondCatching
|
||||||
import com.wbrawner.twigs.storage.CategoryRepository
|
|
||||||
import com.wbrawner.twigs.storage.PermissionRepository
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.util.*
|
||||||
|
|
||||||
fun Application.categoryRoutes(
|
fun Application.categoryRoutes(categoryService: CategoryService) {
|
||||||
categoryRepository: CategoryRepository,
|
|
||||||
permissionRepository: PermissionRepository
|
|
||||||
) {
|
|
||||||
routing {
|
routing {
|
||||||
route("/api/categories") {
|
route("/api/categories") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val budgetIds = permissionRepository.findAll(
|
categoryService.categories(
|
||||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||||
userId = session.userId
|
userId = requireSession().userId,
|
||||||
).map { it.budgetId }
|
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||||
if (budgetIds.isEmpty()) {
|
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||||
call.respond(emptyList<CategoryResponse>())
|
)
|
||||||
return@get
|
|
||||||
}
|
}
|
||||||
call.respond(categoryRepository.findAll(
|
|
||||||
budgetIds = budgetIds,
|
|
||||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
|
||||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
|
||||||
).map { it.asResponse() })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
categoryService.category(
|
||||||
if (budgetIds.isEmpty()) {
|
categoryId = call.parameters.getOrFail("id"),
|
||||||
errorResponse()
|
userId = requireSession().userId
|
||||||
return@get
|
)
|
||||||
}
|
}
|
||||||
categoryRepository.findAll(
|
|
||||||
ids = call.parameters.getAll("id"),
|
|
||||||
budgetIds = budgetIds
|
|
||||||
)
|
|
||||||
.map { it.asResponse() }
|
|
||||||
.firstOrNull()?.let {
|
|
||||||
call.respond(it)
|
|
||||||
} ?: errorResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<CategoryRequest>()
|
categoryService.save(call.receive<CategoryRequest>(), requireSession().userId)
|
||||||
if (request.title.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
|
||||||
return@post
|
|
||||||
}
|
}
|
||||||
if (request.budgetId.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
request.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
call.respond(
|
|
||||||
categoryRepository.save(
|
|
||||||
Category(
|
|
||||||
title = request.title,
|
|
||||||
description = request.description,
|
|
||||||
amount = request.amount ?: 0L,
|
|
||||||
expense = request.expense ?: true,
|
|
||||||
budgetId = request.budgetId
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
put("/{id}") {
|
put("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<CategoryRequest>()
|
categoryService.save(
|
||||||
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
|
request = call.receive<CategoryRequest>(),
|
||||||
.firstOrNull()
|
userId = requireSession().userId,
|
||||||
?: run {
|
categoryId = call.parameters.getOrFail("id")
|
||||||
call.respond(HttpStatusCode.NotFound)
|
)
|
||||||
return@put
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
category.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@put
|
|
||||||
}
|
}
|
||||||
call.respond(
|
|
||||||
categoryRepository.save(
|
|
||||||
category.copy(
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val categoryId = call.parameters.entries().first().value
|
categoryService.delete(
|
||||||
val category = categoryRepository.findAll(ids = categoryId)
|
call.parameters.getOrFail("id"),
|
||||||
.firstOrNull()
|
requireSession().userId
|
||||||
?: run {
|
)
|
||||||
errorResponse(HttpStatusCode.NotFound)
|
HttpStatusCode.NoContent
|
||||||
return@delete
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
category.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@delete
|
|
||||||
}
|
}
|
||||||
categoryRepository.delete(category)
|
|
||||||
call.respond(HttpStatusCode.NoContent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,162 +1,59 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Permission
|
|
||||||
import com.wbrawner.twigs.model.RecurringTransaction
|
|
||||||
import com.wbrawner.twigs.model.Session
|
|
||||||
import com.wbrawner.twigs.service.errorResponse
|
|
||||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionRequest
|
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionRequest
|
||||||
import com.wbrawner.twigs.service.recurringtransaction.asResponse
|
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
|
||||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
import com.wbrawner.twigs.service.requireSession
|
||||||
import com.wbrawner.twigs.storage.PermissionRepository
|
import com.wbrawner.twigs.service.respondCatching
|
||||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.util.pipeline.*
|
import io.ktor.server.util.*
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
fun Application.recurringTransactionRoutes(
|
|
||||||
recurringTransactionRepository: RecurringTransactionRepository,
|
|
||||||
permissionRepository: PermissionRepository
|
|
||||||
) {
|
|
||||||
suspend fun PipelineContext<Unit, ApplicationCall>.recurringTransactionAfterPermissionCheck(
|
|
||||||
id: String?,
|
|
||||||
userId: String,
|
|
||||||
success: suspend (RecurringTransaction) -> Unit
|
|
||||||
) {
|
|
||||||
if (id.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(id)).firstOrNull()
|
|
||||||
?: run {
|
|
||||||
errorResponse()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
userId,
|
|
||||||
recurringTransaction.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
application.log.info("No permissions on budget ${recurringTransaction.budgetId}.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
success(recurringTransaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
fun Application.recurringTransactionRoutes(recurringTransactionService: RecurringTransactionService) {
|
||||||
routing {
|
routing {
|
||||||
route("/api/recurringtransactions") {
|
route("/api/recurringtransactions") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val budgetId = call.request.queryParameters["budgetId"]
|
recurringTransactionService.recurringTransactions(
|
||||||
requireBudgetWithPermission(
|
budgetId = call.request.queryParameters.getOrFail("budgetId"),
|
||||||
permissionRepository,
|
userId = requireSession().userId
|
||||||
session.userId,
|
|
||||||
budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@get
|
|
||||||
}
|
|
||||||
call.respond(
|
|
||||||
recurringTransactionRepository.findAll(
|
|
||||||
budgetId = budgetId!!
|
|
||||||
).map { it.asResponse() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get("/{id}") {
|
|
||||||
val session = call.principal<Session>()!!
|
|
||||||
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
|
|
||||||
call.respond(it.asResponse())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
val session = call.principal<Session>()!!
|
|
||||||
val request = call.receive<RecurringTransactionRequest>()
|
|
||||||
if (request.title.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
if (request.budgetId.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
request.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
call.respond(
|
|
||||||
recurringTransactionRepository.save(
|
|
||||||
RecurringTransaction(
|
|
||||||
title = request.title,
|
|
||||||
description = request.description,
|
|
||||||
amount = request.amount ?: 0L,
|
|
||||||
expense = request.expense ?: true,
|
|
||||||
budgetId = request.budgetId,
|
|
||||||
categoryId = request.categoryId,
|
|
||||||
createdBy = session.userId,
|
|
||||||
start = request.start?.toInstant() ?: Instant.now(),
|
|
||||||
finish = request.finish?.toInstant(),
|
|
||||||
frequency = request.frequency.asFrequency()
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
put("/{id}") {
|
|
||||||
val session = call.principal<Session>()!!
|
|
||||||
val request = call.receive<RecurringTransactionRequest>()
|
|
||||||
recurringTransactionAfterPermissionCheck(
|
|
||||||
call.parameters["id"]!!,
|
|
||||||
session.userId
|
|
||||||
) { recurringTransaction ->
|
|
||||||
if (request.budgetId != recurringTransaction.budgetId) {
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
request.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@recurringTransactionAfterPermissionCheck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
call.respond(
|
|
||||||
recurringTransactionRepository.save(
|
|
||||||
recurringTransaction.copy(
|
|
||||||
title = request.title ?: recurringTransaction.title,
|
|
||||||
description = request.description ?: recurringTransaction.description,
|
|
||||||
amount = request.amount ?: recurringTransaction.amount,
|
|
||||||
expense = request.expense ?: recurringTransaction.expense,
|
|
||||||
categoryId = request.categoryId ?: recurringTransaction.categoryId,
|
|
||||||
budgetId = request.budgetId ?: recurringTransaction.budgetId,
|
|
||||||
start = request.start?.toInstant() ?: recurringTransaction.start,
|
|
||||||
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
|
|
||||||
frequency = request.frequency.asFrequency()
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get("/{id}") {
|
||||||
|
call.respondCatching {
|
||||||
|
recurringTransactionService.recurringTransaction(
|
||||||
|
call.parameters.getOrFail("id"),
|
||||||
|
requireSession().userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
call.respondCatching {
|
||||||
|
recurringTransactionService.save(
|
||||||
|
request = call.receive<RecurringTransactionRequest>(),
|
||||||
|
userId = requireSession().userId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
put("/{id}") {
|
||||||
|
recurringTransactionService.save(
|
||||||
|
request = call.receive<RecurringTransactionRequest>(),
|
||||||
|
userId = requireSession().userId,
|
||||||
|
recurringTransactionId = call.parameters.getOrFail("id")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
|
recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
|
||||||
val response = if (recurringTransactionRepository.delete(it)) {
|
HttpStatusCode.NoContent
|
||||||
HttpStatusCode.NoContent
|
|
||||||
} else {
|
|
||||||
HttpStatusCode.InternalServerError
|
|
||||||
}
|
|
||||||
call.respond(response)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,173 +1,85 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Permission
|
import com.wbrawner.twigs.service.requireSession
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.service.respondCatching
|
||||||
import com.wbrawner.twigs.model.Transaction
|
|
||||||
import com.wbrawner.twigs.service.errorResponse
|
|
||||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
|
||||||
import com.wbrawner.twigs.service.transaction.BalanceResponse
|
import com.wbrawner.twigs.service.transaction.BalanceResponse
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
||||||
import com.wbrawner.twigs.storage.PermissionRepository
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
import com.wbrawner.twigs.storage.TransactionRepository
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.util.*
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
|
||||||
fun Application.transactionRoutes(
|
fun Application.transactionRoutes(transactionService: TransactionService) {
|
||||||
transactionRepository: TransactionRepository,
|
|
||||||
permissionRepository: PermissionRepository
|
|
||||||
) {
|
|
||||||
routing {
|
routing {
|
||||||
route("/api/transactions") {
|
route("/api/transactions") {
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get {
|
get {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
call.respond(
|
transactionService.transactions(
|
||||||
transactionRepository.findAll(
|
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||||
budgetIds = permissionRepository.findAll(
|
|
||||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
|
||||||
userId = session.userId
|
|
||||||
).map { it.budgetId },
|
|
||||||
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
||||||
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
||||||
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
||||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||||
).map { it.asResponse() })
|
userId = requireSession().userId
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val transaction = transactionRepository.findAll(
|
transactionService.transaction(
|
||||||
ids = call.parameters.getAll("id"),
|
transactionId = call.parameters.getOrFail("id"),
|
||||||
budgetIds = permissionRepository.findAll(
|
userId = requireSession().userId
|
||||||
userId = session.userId
|
|
||||||
)
|
)
|
||||||
.map { it.budgetId }
|
}
|
||||||
)
|
|
||||||
.map { it.asResponse() }
|
|
||||||
.firstOrNull()
|
|
||||||
transaction?.let {
|
|
||||||
call.respond(it)
|
|
||||||
} ?: errorResponse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/sum") {
|
get("/sum") {
|
||||||
val categoryId = call.request.queryParameters["categoryId"]
|
call.respondCatching {
|
||||||
val budgetId = call.request.queryParameters["budgetId"]
|
BalanceResponse(
|
||||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
|
transactionService.sum(
|
||||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
budgetId = call.request.queryParameters["budgetId"],
|
||||||
val balance = if (!categoryId.isNullOrBlank()) {
|
categoryId = call.request.queryParameters["categoryId"],
|
||||||
if (!budgetId.isNullOrBlank()) {
|
from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth,
|
||||||
errorResponse(
|
to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth,
|
||||||
HttpStatusCode.BadRequest,
|
userId = requireSession().userId,
|
||||||
"budgetId and categoryId cannot be provided together"
|
|
||||||
)
|
)
|
||||||
return@get
|
)
|
||||||
}
|
|
||||||
transactionRepository.sumByCategory(categoryId, from, to)
|
|
||||||
} else if (!budgetId.isNullOrBlank()) {
|
|
||||||
transactionRepository.sumByBudget(budgetId, from, to)
|
|
||||||
} else {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
|
|
||||||
return@get
|
|
||||||
}
|
}
|
||||||
call.respond(BalanceResponse(balance))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<TransactionRequest>()
|
transactionService.save(
|
||||||
if (request.title.isNullOrBlank()) {
|
request = call.receive<TransactionRequest>(),
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
userId = requireSession().userId
|
||||||
return@post
|
)
|
||||||
}
|
}
|
||||||
if (request.budgetId.isNullOrBlank()) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
request.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
call.respond(
|
|
||||||
transactionRepository.save(
|
|
||||||
Transaction(
|
|
||||||
title = request.title,
|
|
||||||
description = request.description,
|
|
||||||
amount = request.amount ?: 0L,
|
|
||||||
expense = request.expense ?: true,
|
|
||||||
budgetId = request.budgetId,
|
|
||||||
categoryId = request.categoryId,
|
|
||||||
createdBy = session.userId,
|
|
||||||
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
put("/{id}") {
|
put("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<TransactionRequest>()
|
transactionService.save(
|
||||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
request = call.receive<TransactionRequest>(),
|
||||||
.firstOrNull()
|
userId = requireSession().userId,
|
||||||
?: run {
|
transactionId = call.parameters.getOrFail("id")
|
||||||
errorResponse()
|
)
|
||||||
return@put
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
transaction.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@put
|
|
||||||
}
|
}
|
||||||
call.respond(
|
|
||||||
transactionRepository.save(
|
|
||||||
transaction.copy(
|
|
||||||
title = request.title ?: transaction.title,
|
|
||||||
description = request.description ?: transaction.description,
|
|
||||||
amount = request.amount ?: transaction.amount,
|
|
||||||
expense = request.expense ?: transaction.expense,
|
|
||||||
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
|
|
||||||
categoryId = request.categoryId ?: transaction.categoryId,
|
|
||||||
budgetId = request.budgetId ?: transaction.budgetId,
|
|
||||||
createdBy = transaction.createdBy,
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
transactionService.delete(
|
||||||
.firstOrNull()
|
transactionId = call.parameters.getOrFail("id"),
|
||||||
?: run {
|
userId = requireSession().userId
|
||||||
errorResponse()
|
)
|
||||||
return@delete
|
|
||||||
}
|
|
||||||
requireBudgetWithPermission(
|
|
||||||
permissionRepository,
|
|
||||||
session.userId,
|
|
||||||
transaction.budgetId,
|
|
||||||
Permission.WRITE
|
|
||||||
) {
|
|
||||||
return@delete
|
|
||||||
}
|
|
||||||
val response = if (transactionRepository.delete(transaction)) {
|
|
||||||
HttpStatusCode.NoContent
|
HttpStatusCode.NoContent
|
||||||
} else {
|
|
||||||
HttpStatusCode.InternalServerError
|
|
||||||
}
|
}
|
||||||
call.respond(response)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,195 +1,84 @@
|
||||||
package com.wbrawner.twigs
|
package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.PasswordResetToken
|
import com.wbrawner.twigs.service.requireSession
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.service.respondCatching
|
||||||
import com.wbrawner.twigs.model.User
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.service.errorResponse
|
|
||||||
import com.wbrawner.twigs.service.user.LoginRequest
|
|
||||||
import com.wbrawner.twigs.service.user.PasswordResetRequest
|
|
||||||
import com.wbrawner.twigs.service.user.ResetPasswordRequest
|
|
||||||
import com.wbrawner.twigs.service.user.UserRequest
|
|
||||||
import com.wbrawner.twigs.storage.*
|
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
import io.ktor.server.auth.*
|
import io.ktor.server.auth.*
|
||||||
import io.ktor.server.request.*
|
import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import java.time.Instant
|
import io.ktor.server.util.*
|
||||||
|
|
||||||
fun Application.userRoutes(
|
fun Application.userRoutes(userService: UserService) {
|
||||||
emailService: EmailService,
|
|
||||||
passwordResetRepository: PasswordResetRepository,
|
|
||||||
permissionRepository: PermissionRepository,
|
|
||||||
sessionRepository: SessionRepository,
|
|
||||||
userRepository: UserRepository,
|
|
||||||
passwordHasher: PasswordHasher
|
|
||||||
) {
|
|
||||||
routing {
|
routing {
|
||||||
route("/api/users") {
|
route("/api/users") {
|
||||||
post("/login") {
|
post("/login") {
|
||||||
val request = call.receive<LoginRequest>()
|
call.respondCatching {
|
||||||
val user =
|
userService.login(call.receive())
|
||||||
userRepository.findAll(
|
}
|
||||||
nameOrEmail = request.username,
|
|
||||||
password = passwordHasher.hash(request.password)
|
|
||||||
)
|
|
||||||
.firstOrNull()
|
|
||||||
?: userRepository.findAll(
|
|
||||||
nameOrEmail = request.username,
|
|
||||||
password = passwordHasher.hash(request.password)
|
|
||||||
)
|
|
||||||
.firstOrNull()
|
|
||||||
?: run {
|
|
||||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
val session = sessionRepository.save(Session(userId = user.id))
|
|
||||||
call.respond(session.asResponse())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post("/register") {
|
post("/register") {
|
||||||
val request = call.receive<UserRequest>()
|
call.respondCatching {
|
||||||
if (request.username.isNullOrBlank()) {
|
userService.register(call.receive())
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
|
||||||
return@post
|
|
||||||
}
|
}
|
||||||
if (request.password.isNullOrBlank()) {
|
}
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
|
|
||||||
return@post
|
|
||||||
}
|
route("/resetpassword") {
|
||||||
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
|
post {
|
||||||
?: request.email?.let {
|
call.respondCatching {
|
||||||
return@let if (it.isBlank()) {
|
userService.requestPasswordResetEmail(call.receive())
|
||||||
null
|
HttpStatusCode.Accepted
|
||||||
} else {
|
}
|
||||||
userRepository.findAll(nameOrEmail = it).firstOrNull()
|
}
|
||||||
}
|
|
||||||
|
put {
|
||||||
|
call.respondCatching {
|
||||||
|
userService.resetPassword(call.receive())
|
||||||
|
HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
existingUser?.let {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
|
|
||||||
return@post
|
|
||||||
}
|
}
|
||||||
call.respond(
|
|
||||||
userRepository.save(
|
|
||||||
User(
|
|
||||||
name = request.username,
|
|
||||||
password = passwordHasher.hash(request.password),
|
|
||||||
email = if (request.email.isNullOrBlank()) "" else request.email
|
|
||||||
)
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
authenticate(optional = false) {
|
authenticate(optional = false) {
|
||||||
get {
|
get {
|
||||||
val query = call.request.queryParameters["query"]
|
call.respondCatching {
|
||||||
val budgetIds = call.request.queryParameters.getAll("budgetId")
|
userService.users(
|
||||||
if (query != null) {
|
query = call.request.queryParameters["query"],
|
||||||
if (query.isBlank()) {
|
budgetIds = call.request.queryParameters.getAll("budgetId"),
|
||||||
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty")
|
requestingUserId = requireSession().userId
|
||||||
}
|
)
|
||||||
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
|
|
||||||
return@get
|
|
||||||
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
|
|
||||||
errorResponse(HttpStatusCode.BadRequest, "query or budgetId required but absent")
|
|
||||||
}
|
}
|
||||||
permissionRepository.findAll(budgetIds = budgetIds)
|
|
||||||
.mapNotNull {
|
|
||||||
userRepository.findAll(ids = listOf(it.userId))
|
|
||||||
.firstOrNull()
|
|
||||||
?.asResponse()
|
|
||||||
}.run { call.respond(this) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get("/{id}") {
|
get("/{id}") {
|
||||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
call.respondCatching {
|
||||||
.firstOrNull()
|
userService.user(call.parameters.getOrFail("id"))
|
||||||
?.asResponse()
|
}
|
||||||
?.let { call.respond(it) }
|
|
||||||
?: errorResponse(HttpStatusCode.NotFound)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
put("/{id}") {
|
put("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
val request = call.receive<UserRequest>()
|
userService.save(
|
||||||
// TODO: Add some kind of admin denotation to allow admins to edit other users
|
request = call.receive(),
|
||||||
if (call.parameters["id"] != session.userId) {
|
targetUserId = call.parameters.getOrFail("id"),
|
||||||
errorResponse(HttpStatusCode.Forbidden)
|
requestingUserId = requireSession().userId
|
||||||
return@put
|
)
|
||||||
}
|
}
|
||||||
call.respond(
|
|
||||||
userRepository.save(
|
|
||||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
|
||||||
.first()
|
|
||||||
.run {
|
|
||||||
copy(
|
|
||||||
name = request.username ?: name,
|
|
||||||
password = request.password?.let { passwordHasher.hash(it) } ?: password,
|
|
||||||
email = request.email ?: email
|
|
||||||
)
|
|
||||||
}
|
|
||||||
).asResponse()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete("/{id}") {
|
delete("/{id}") {
|
||||||
val session = call.principal<Session>()!!
|
call.respondCatching {
|
||||||
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
userService.delete(
|
||||||
val user = userRepository.findAll(call.parameters.entries().first().value).firstOrNull()
|
targetUserId = call.parameters.getOrFail("id"),
|
||||||
if (user == null) {
|
requestingUserId = requireSession().userId
|
||||||
errorResponse()
|
)
|
||||||
return@delete
|
HttpStatusCode.NoContent
|
||||||
}
|
}
|
||||||
if (user.id != session.userId) {
|
|
||||||
errorResponse(HttpStatusCode.Forbidden)
|
|
||||||
return@delete
|
|
||||||
}
|
|
||||||
userRepository.delete(user)
|
|
||||||
call.respond(HttpStatusCode.NoContent)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
route("/api/resetpassword") {
|
|
||||||
post {
|
|
||||||
val request = call.receive<ResetPasswordRequest>()
|
|
||||||
userRepository.findAll(nameOrEmail = request.username)
|
|
||||||
.firstOrNull()
|
|
||||||
?.let {
|
|
||||||
val email = it.email
|
|
||||||
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
|
||||||
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
|
||||||
}
|
|
||||||
call.respond(HttpStatusCode.Accepted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
route("/api/passwordreset") {
|
|
||||||
post {
|
|
||||||
val request = call.receive<PasswordResetRequest>()
|
|
||||||
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
|
|
||||||
.firstOrNull()
|
|
||||||
?: run {
|
|
||||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid token")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
if (passwordResetToken.expiration.isBefore(Instant.now())) {
|
|
||||||
errorResponse(HttpStatusCode.Unauthorized, "Token expired")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
userRepository.findAll(listOf(passwordResetToken.userId))
|
|
||||||
.firstOrNull()
|
|
||||||
?.let {
|
|
||||||
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
|
|
||||||
passwordResetRepository.delete(passwordResetToken)
|
|
||||||
}
|
|
||||||
?: run {
|
|
||||||
errorResponse(HttpStatusCode.InternalServerError, "Invalid token")
|
|
||||||
return@post
|
|
||||||
}
|
|
||||||
call.respond(HttpStatusCode.NoContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ dependencies {
|
||||||
implementation(libs.bcrypt)
|
implementation(libs.bcrypt)
|
||||||
implementation(libs.logback)
|
implementation(libs.logback)
|
||||||
implementation(libs.mail)
|
implementation(libs.mail)
|
||||||
|
implementation(project(mapOf("path" to ":service")))
|
||||||
testImplementation(project(":testhelpers"))
|
testImplementation(project(":testhelpers"))
|
||||||
testImplementation(libs.ktor.client.content.negotiation)
|
testImplementation(libs.ktor.client.content.negotiation)
|
||||||
testImplementation(libs.ktor.server.test)
|
testImplementation(libs.ktor.server.test)
|
||||||
|
|
|
@ -5,7 +5,17 @@ import ch.qos.logback.classic.Level
|
||||||
import com.wbrawner.twigs.*
|
import com.wbrawner.twigs.*
|
||||||
import com.wbrawner.twigs.db.*
|
import com.wbrawner.twigs.db.*
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.model.Session
|
||||||
import com.wbrawner.twigs.storage.*
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
|
import com.wbrawner.twigs.service.budget.DefaultBudgetService
|
||||||
|
import com.wbrawner.twigs.service.category.CategoryService
|
||||||
|
import com.wbrawner.twigs.service.category.DefaultCategoryService
|
||||||
|
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
|
||||||
|
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
|
||||||
|
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
|
||||||
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
|
import com.wbrawner.twigs.service.user.DefaultUserService
|
||||||
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
|
import com.wbrawner.twigs.storage.PasswordHasher
|
||||||
import com.wbrawner.twigs.web.webRoutes
|
import com.wbrawner.twigs.web.webRoutes
|
||||||
import com.zaxxer.hikari.HikariConfig
|
import com.zaxxer.hikari.HikariConfig
|
||||||
import com.zaxxer.hikari.HikariDataSource
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
|
@ -81,42 +91,78 @@ fun Application.module() {
|
||||||
metadata
|
metadata
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val budgetRepository = JdbcBudgetRepository(it)
|
||||||
|
val categoryRepository = JdbcCategoryRepository(it)
|
||||||
|
val permissionRepository = JdbcPermissionRepository(it)
|
||||||
|
val passwordResetRepository = JdbcPasswordResetRepository(it)
|
||||||
|
val passwordHasher = PasswordHasher { password ->
|
||||||
|
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
|
||||||
|
}
|
||||||
|
val recurringTransactionRepository = JdbcRecurringTransactionRepository(it)
|
||||||
|
val sessionRepository = JdbcSessionRepository(it)
|
||||||
|
val transactionRepository = JdbcTransactionRepository(it)
|
||||||
|
val userRepository = JdbcUserRepository(it)
|
||||||
|
val emailService = SmtpEmailService(
|
||||||
|
from = System.getenv("TWIGS_SMTP_FROM"),
|
||||||
|
host = System.getenv("TWIGS_SMTP_HOST"),
|
||||||
|
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
|
||||||
|
username = System.getenv("TWIGS_SMTP_USER"),
|
||||||
|
password = System.getenv("TWIGS_SMTP_PASS"),
|
||||||
|
)
|
||||||
|
val jobs = listOf(
|
||||||
|
SessionCleanupJob(sessionRepository),
|
||||||
|
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||||
|
)
|
||||||
|
val sessionValidator: suspend ApplicationCall.(Session) -> Principal? = validate@{ session ->
|
||||||
|
application.environment.log.info("Validating session")
|
||||||
|
val storedSession = sessionRepository.findAll(session.token)
|
||||||
|
.firstOrNull()
|
||||||
|
if (storedSession == null) {
|
||||||
|
application.environment.log.info("Did not find session!")
|
||||||
|
return@validate null
|
||||||
|
} else {
|
||||||
|
application.environment.log.info("Found session!")
|
||||||
|
}
|
||||||
|
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
||||||
|
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
moduleWithDependencies(
|
moduleWithDependencies(
|
||||||
emailService = SmtpEmailService(
|
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
|
||||||
from = System.getenv("TWIGS_SMTP_FROM"),
|
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
|
||||||
host = System.getenv("TWIGS_SMTP_HOST"),
|
recurringTransactionService = DefaultRecurringTransactionService(
|
||||||
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
|
recurringTransactionRepository,
|
||||||
username = System.getenv("TWIGS_SMTP_USER"),
|
permissionRepository
|
||||||
password = System.getenv("TWIGS_SMTP_PASS"),
|
|
||||||
),
|
),
|
||||||
metadataRepository = JdbcMetadataRepository(it),
|
transactionService = DefaultTransactionService(
|
||||||
budgetRepository = JdbcBudgetRepository(it),
|
transactionRepository,
|
||||||
categoryRepository = JdbcCategoryRepository(it),
|
categoryRepository,
|
||||||
passwordResetRepository = JdbcPasswordResetRepository(it),
|
permissionRepository
|
||||||
passwordHasher = { password ->
|
),
|
||||||
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
|
userService = DefaultUserService(
|
||||||
},
|
emailService,
|
||||||
permissionRepository = JdbcPermissionRepository(it),
|
passwordResetRepository,
|
||||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
permissionRepository,
|
||||||
sessionRepository = JdbcSessionRepository(it),
|
sessionRepository,
|
||||||
transactionRepository = JdbcTransactionRepository(it),
|
userRepository,
|
||||||
userRepository = JdbcUserRepository(it)
|
passwordHasher
|
||||||
|
),
|
||||||
|
jobs = jobs,
|
||||||
|
sessionValidator = sessionValidator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Application.moduleWithDependencies(
|
fun Application.moduleWithDependencies(
|
||||||
emailService: EmailService,
|
budgetService: BudgetService,
|
||||||
metadataRepository: MetadataRepository,
|
categoryService: CategoryService,
|
||||||
budgetRepository: BudgetRepository,
|
recurringTransactionService: RecurringTransactionService,
|
||||||
categoryRepository: CategoryRepository,
|
transactionService: TransactionService,
|
||||||
passwordResetRepository: PasswordResetRepository,
|
userService: UserService,
|
||||||
passwordHasher: PasswordHasher,
|
jobs: List<Job>,
|
||||||
permissionRepository: PermissionRepository,
|
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
|
||||||
recurringTransactionRepository: RecurringTransactionRepository,
|
|
||||||
sessionRepository: SessionRepository,
|
|
||||||
transactionRepository: TransactionRepository,
|
|
||||||
userRepository: UserRepository
|
|
||||||
) {
|
) {
|
||||||
install(CallLogging)
|
install(CallLogging)
|
||||||
install(Authentication) {
|
install(Authentication) {
|
||||||
|
@ -124,22 +170,7 @@ fun Application.moduleWithDependencies(
|
||||||
challenge {
|
challenge {
|
||||||
call.respond(HttpStatusCode.Unauthorized)
|
call.respond(HttpStatusCode.Unauthorized)
|
||||||
}
|
}
|
||||||
validate { session ->
|
validate(sessionValidator)
|
||||||
application.environment.log.info("Validating session")
|
|
||||||
val storedSession = sessionRepository.findAll(session.token)
|
|
||||||
.firstOrNull()
|
|
||||||
if (storedSession == null) {
|
|
||||||
application.environment.log.info("Did not find session!")
|
|
||||||
return@validate null
|
|
||||||
} else {
|
|
||||||
application.environment.log.info("Found session!")
|
|
||||||
}
|
|
||||||
return@validate if (twoWeeksFromNow.isAfter(storedSession.expiration)) {
|
|
||||||
sessionRepository.save(storedSession.copy(expiration = twoWeeksFromNow))
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
install(Sessions) {
|
install(Sessions) {
|
||||||
|
@ -170,6 +201,8 @@ fun Application.moduleWithDependencies(
|
||||||
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
||||||
allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable
|
allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable
|
||||||
allowMethod(HttpMethod.Options)
|
allowMethod(HttpMethod.Options)
|
||||||
|
allowMethod(HttpMethod.Get)
|
||||||
|
allowMethod(HttpMethod.Post)
|
||||||
allowMethod(HttpMethod.Put)
|
allowMethod(HttpMethod.Put)
|
||||||
allowMethod(HttpMethod.Delete)
|
allowMethod(HttpMethod.Delete)
|
||||||
allowHeader(HttpHeaders.Authorization)
|
allowHeader(HttpHeaders.Authorization)
|
||||||
|
@ -192,17 +225,13 @@ fun Application.moduleWithDependencies(
|
||||||
allowHeader("DNT")
|
allowHeader("DNT")
|
||||||
allowCredentials = true
|
allowCredentials = true
|
||||||
}
|
}
|
||||||
budgetRoutes(budgetRepository, permissionRepository)
|
budgetRoutes(budgetService)
|
||||||
categoryRoutes(categoryRepository, permissionRepository)
|
categoryRoutes(categoryService)
|
||||||
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
recurringTransactionRoutes(recurringTransactionService)
|
||||||
transactionRoutes(transactionRepository, permissionRepository)
|
transactionRoutes(transactionService)
|
||||||
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher)
|
userRoutes(userService)
|
||||||
webRoutes()
|
webRoutes()
|
||||||
launch {
|
launch {
|
||||||
val jobs = listOf(
|
|
||||||
SessionCleanupJob(sessionRepository),
|
|
||||||
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
|
||||||
)
|
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
jobs.forEach { it.run() }
|
jobs.forEach { it.run() }
|
||||||
delay(TimeUnit.HOURS.toMillis(1))
|
delay(TimeUnit.HOURS.toMillis(1))
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
package com.wbrawner.twigs.server.api
|
package com.wbrawner.twigs.server.api
|
||||||
|
|
||||||
import com.wbrawner.twigs.server.moduleWithDependencies
|
import com.wbrawner.twigs.server.moduleWithDependencies
|
||||||
|
import com.wbrawner.twigs.service.budget.DefaultBudgetService
|
||||||
|
import com.wbrawner.twigs.service.category.DefaultCategoryService
|
||||||
|
import com.wbrawner.twigs.service.recurringtransaction.DefaultRecurringTransactionService
|
||||||
|
import com.wbrawner.twigs.service.transaction.DefaultTransactionService
|
||||||
|
import com.wbrawner.twigs.service.user.DefaultUserService
|
||||||
import com.wbrawner.twigs.test.helpers.FakeEmailService
|
import com.wbrawner.twigs.test.helpers.FakeEmailService
|
||||||
import com.wbrawner.twigs.test.helpers.repository.*
|
import com.wbrawner.twigs.test.helpers.repository.*
|
||||||
import io.ktor.client.*
|
import io.ktor.client.*
|
||||||
|
@ -38,17 +43,29 @@ open class ApiTest {
|
||||||
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
|
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
|
||||||
application {
|
application {
|
||||||
moduleWithDependencies(
|
moduleWithDependencies(
|
||||||
emailService = emailService,
|
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
|
||||||
metadataRepository = metadataRepository,
|
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
|
||||||
budgetRepository = budgetRepository,
|
recurringTransactionService = DefaultRecurringTransactionService(
|
||||||
categoryRepository = categoryRepository,
|
recurringTransactionRepository,
|
||||||
passwordHasher = { it },
|
permissionRepository
|
||||||
passwordResetRepository = passwordResetRepository,
|
),
|
||||||
permissionRepository = permissionRepository,
|
transactionService = DefaultTransactionService(
|
||||||
recurringTransactionRepository = recurringTransactionRepository,
|
transactionRepository,
|
||||||
sessionRepository = sessionRepository,
|
categoryRepository,
|
||||||
transactionRepository = transactionRepository,
|
permissionRepository
|
||||||
userRepository = userRepository
|
),
|
||||||
|
userService = DefaultUserService(
|
||||||
|
emailService,
|
||||||
|
passwordResetRepository,
|
||||||
|
permissionRepository,
|
||||||
|
sessionRepository,
|
||||||
|
userRepository,
|
||||||
|
{ it }
|
||||||
|
),
|
||||||
|
jobs = listOf(),
|
||||||
|
sessionValidator = {
|
||||||
|
sessionRepository.findAll(it.token).firstOrNull()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val client = createClient {
|
val client = createClient {
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
package com.wbrawner.twigs.server.api
|
package com.wbrawner.twigs.server.api
|
||||||
|
|
||||||
import com.wbrawner.twigs.BudgetRequest
|
|
||||||
import com.wbrawner.twigs.BudgetResponse
|
|
||||||
import com.wbrawner.twigs.UserPermissionRequest
|
|
||||||
import com.wbrawner.twigs.UserPermissionResponse
|
|
||||||
import com.wbrawner.twigs.model.*
|
import com.wbrawner.twigs.model.*
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetRequest
|
||||||
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
|
import com.wbrawner.twigs.service.user.UserPermissionRequest
|
||||||
|
import com.wbrawner.twigs.service.user.UserPermissionResponse
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Disabled
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
class BudgetRouteTest : ApiTest() {
|
class BudgetRouteTest : ApiTest() {
|
||||||
|
@ -228,9 +227,8 @@ class BudgetRouteTest : ApiTest() {
|
||||||
assertEquals(expectedUsers, updatedUsers)
|
assertEquals(expectedUsers, updatedUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Disabled("Will be fixed with service layer refactor")
|
|
||||||
@Test
|
@Test
|
||||||
fun `updating budgets returns not found for users with no access`() = apiTest { client ->
|
fun `updating budgets returns forbidden for users with no access`() = apiTest { client ->
|
||||||
val users = listOf(
|
val users = listOf(
|
||||||
User(name = "testuser", password = "testpassword"),
|
User(name = "testuser", password = "testpassword"),
|
||||||
User(name = "otheruser", password = "otherpassword"),
|
User(name = "otheruser", password = "otherpassword"),
|
||||||
|
@ -254,7 +252,6 @@ class BudgetRouteTest : ApiTest() {
|
||||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Disabled("Will be fixed with service layer refactor")
|
|
||||||
@Test
|
@Test
|
||||||
fun `updating non-existent budgets returns not found`() = apiTest { client ->
|
fun `updating non-existent budgets returns not found`() = apiTest { client ->
|
||||||
val users = listOf(
|
val users = listOf(
|
||||||
|
@ -273,7 +270,6 @@ class BudgetRouteTest : ApiTest() {
|
||||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Disabled("Will be fixed with service layer refactor")
|
|
||||||
@Test
|
@Test
|
||||||
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
|
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
|
||||||
apiTest { client ->
|
apiTest { client ->
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
package com.wbrawner.twigs.server.api
|
package com.wbrawner.twigs.server.api
|
||||||
|
|
||||||
import com.wbrawner.twigs.ErrorResponse
|
|
||||||
import com.wbrawner.twigs.PasswordResetRequest
|
|
||||||
import com.wbrawner.twigs.ResetPasswordRequest
|
|
||||||
import com.wbrawner.twigs.model.PasswordResetToken
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
import com.wbrawner.twigs.randomString
|
import com.wbrawner.twigs.randomString
|
||||||
|
import com.wbrawner.twigs.service.ErrorResponse
|
||||||
|
import com.wbrawner.twigs.service.user.PasswordResetRequest
|
||||||
|
import com.wbrawner.twigs.service.user.ResetPasswordRequest
|
||||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
|
@ -18,7 +18,7 @@ class PasswordResetRouteTest : ApiTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `reset password with invalid username returns 202`() = apiTest { client ->
|
fun `reset password with invalid username returns 202`() = apiTest { client ->
|
||||||
val request = ResetPasswordRequest(username = "invaliduser")
|
val request = ResetPasswordRequest(username = "invaliduser")
|
||||||
val response = client.post("/api/resetpassword") {
|
val response = client.post("/api/users/resetpassword") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ class PasswordResetRouteTest : ApiTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `reset password with valid username returns 202`() = apiTest { client ->
|
fun `reset password with valid username returns 202`() = apiTest { client ->
|
||||||
val request = ResetPasswordRequest(username = "testuser")
|
val request = ResetPasswordRequest(username = "testuser")
|
||||||
val response = client.post("/api/resetpassword") {
|
val response = client.post("/api/users/resetpassword") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
@ -43,9 +43,9 @@ class PasswordResetRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `password reset with invalid token returns 400`() = apiTest { client ->
|
fun `password reset with invalid token returns 401`() = apiTest { client ->
|
||||||
val request = PasswordResetRequest(token = randomString(), password = "newpass")
|
val request = PasswordResetRequest(token = randomString(), password = "newpass")
|
||||||
val response = client.post("/api/passwordreset") {
|
val response = client.put("/api/users/resetpassword") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
@ -55,10 +55,10 @@ class PasswordResetRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `password reset with expired token returns 400`() = apiTest { client ->
|
fun `password reset with expired token returns 401`() = apiTest { client ->
|
||||||
val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo))
|
val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo))
|
||||||
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
||||||
val response = client.post("/api/passwordreset") {
|
val response = client.put("/api/users/resetpassword") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
@ -68,10 +68,11 @@ class PasswordResetRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `password reset with valid token returns 200`() = apiTest { client ->
|
fun `password reset with valid token returns 204`() = apiTest { client ->
|
||||||
val token = passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
val token =
|
||||||
|
passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
||||||
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
||||||
val response = client.post("/api/passwordreset") {
|
val response = client.put("/api/users/resetpassword") {
|
||||||
header("Content-Type", "application/json")
|
header("Content-Type", "application/json")
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package com.wbrawner.twigs.server.api
|
package com.wbrawner.twigs.server.api
|
||||||
|
|
||||||
import com.wbrawner.twigs.*
|
|
||||||
import com.wbrawner.twigs.model.Session
|
import com.wbrawner.twigs.model.Session
|
||||||
import com.wbrawner.twigs.model.User
|
import com.wbrawner.twigs.model.User
|
||||||
|
import com.wbrawner.twigs.service.ErrorResponse
|
||||||
|
import com.wbrawner.twigs.service.user.*
|
||||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER
|
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER
|
||||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
|
@ -96,7 +97,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Username must not be null or blank", errorBody.message)
|
assertEquals("username must not be null or blank", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -108,7 +109,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Username must not be null or blank", errorBody.message)
|
assertEquals("username must not be null or blank", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -120,7 +121,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Password must not be null or blank", errorBody.message)
|
assertEquals("password must not be null or blank", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -132,7 +133,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Password must not be null or blank", errorBody.message)
|
assertEquals("password must not be null or blank", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -144,7 +145,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Username or email already taken", errorBody.message)
|
assertEquals("username or email already taken", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -156,7 +157,7 @@ class UserRouteTest : ApiTest() {
|
||||||
}
|
}
|
||||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||||
val errorBody = response.body<ErrorResponse>()
|
val errorBody = response.body<ErrorResponse>()
|
||||||
assertEquals("Username or email already taken", errorBody.message)
|
assertEquals("username or email already taken", errorBody.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.wbrawner.twigs
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Frequency
|
import com.wbrawner.twigs.model.Frequency
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
private val CALENDAR_FIELDS = intArrayOf(
|
private val CALENDAR_FIELDS = intArrayOf(
|
||||||
|
@ -52,4 +53,10 @@ fun randomString(length: Int = 32): String {
|
||||||
|
|
||||||
fun String.toInstant(): Instant = Instant.parse(this)
|
fun String.toInstant(): Instant = Instant.parse(this)
|
||||||
|
|
||||||
|
fun String.toInstantOrNull(): Instant? = try {
|
||||||
|
Instant.parse(this)
|
||||||
|
} catch (e: DateTimeParseException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
fun String.asFrequency(): Frequency = Frequency.parse(this)
|
fun String.asFrequency(): Frequency = Frequency.parse(this)
|
||||||
|
|
|
@ -13,7 +13,7 @@ class JdbcPermissionRepository(dataSource: DataSource) :
|
||||||
override val conflictFields: Collection<String> =
|
override val conflictFields: Collection<String> =
|
||||||
listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase())
|
listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase())
|
||||||
|
|
||||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||||
dataSource.connection.use { conn ->
|
dataSource.connection.use { conn ->
|
||||||
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
|
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
|
||||||
throw Error("budgetIds or userId must be provided")
|
throw Error("budgetIds or userId must be provided")
|
||||||
|
|
1
service/.gitignore
vendored
Normal file
1
service/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
build/
|
|
@ -1,21 +1,59 @@
|
||||||
package com.wbrawner.twigs.service
|
package com.wbrawner.twigs.service
|
||||||
|
|
||||||
import com.wbrawner.twigs.model.Permission
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.model.Session
|
||||||
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||||
import com.wbrawner.twigs.storage.BudgetRepository
|
import com.wbrawner.twigs.storage.BudgetRepository
|
||||||
import com.wbrawner.twigs.storage.PermissionRepository
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
import io.ktor.server.application.*
|
||||||
|
import io.ktor.server.auth.*
|
||||||
|
import io.ktor.server.response.*
|
||||||
|
import io.ktor.util.pipeline.*
|
||||||
|
|
||||||
|
suspend fun PermissionRepository.requirePermission(
|
||||||
|
userId: String,
|
||||||
|
budgetIds: List<String>,
|
||||||
|
permission: Permission
|
||||||
|
): List<UserPermission> {
|
||||||
|
val uniqueBudgetIds = budgetIds.toSet()
|
||||||
|
val allPermissions = findAll(budgetIds = uniqueBudgetIds.toList(), userId = userId)
|
||||||
|
if (allPermissions.size != uniqueBudgetIds.size) {
|
||||||
|
throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
} else if (allPermissions.any { !it.permission.isAtLeast(permission) }) {
|
||||||
|
throw HttpException(HttpStatusCode.Forbidden)
|
||||||
|
}
|
||||||
|
return allPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun PermissionRepository.requirePermission(
|
||||||
|
userId: String,
|
||||||
|
budgetId: String,
|
||||||
|
permission: Permission
|
||||||
|
): List<UserPermission> = requirePermission(userId, listOf(budgetId), permission)
|
||||||
|
|
||||||
suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission(
|
suspend fun Pair<BudgetRepository, PermissionRepository>.budgetWithPermission(
|
||||||
userId: String,
|
userId: String,
|
||||||
budgetId: String,
|
budgetId: String,
|
||||||
permission: Permission
|
permission: Permission
|
||||||
): BudgetResponse {
|
): BudgetResponse {
|
||||||
val allPermissions = second.findAll(budgetIds = listOf(budgetId))
|
val budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
val userPermission = allPermissions.firstOrNull { it.userId == userId }
|
return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission))
|
||||||
?: throw HttpException(HttpStatusCode.NotFound)
|
}
|
||||||
if (!userPermission.permission.isAtLeast(permission)) {
|
|
||||||
throw HttpException(HttpStatusCode.Forbidden)
|
fun PipelineContext<Unit, ApplicationCall>.requireSession() = requireNotNull(call.principal<Session>()) {
|
||||||
|
"Session required but was null"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend inline fun <reified T : Any> ApplicationCall.respondCatching(block: () -> T) =
|
||||||
|
try {
|
||||||
|
val response = block()
|
||||||
|
if (response is HttpStatusCode) {
|
||||||
|
respond(status = response, message = Unit)
|
||||||
|
} else {
|
||||||
|
respond(HttpStatusCode.OK, response)
|
||||||
|
}
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
respond(e.statusCode, e.toResponse())
|
||||||
}
|
}
|
||||||
return BudgetResponse(first.findAll(ids = listOf(budgetId)).first(), allPermissions)
|
|
||||||
}
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
package com.wbrawner.twigs.service.category
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.model.Category
|
||||||
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
import com.wbrawner.twigs.service.requirePermission
|
||||||
|
import com.wbrawner.twigs.storage.CategoryRepository
|
||||||
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
|
import io.ktor.http.*
|
||||||
|
|
||||||
|
interface CategoryService {
|
||||||
|
suspend fun categories(
|
||||||
|
budgetIds: List<String>,
|
||||||
|
userId: String,
|
||||||
|
expense: Boolean? = null,
|
||||||
|
archived: Boolean? = null,
|
||||||
|
): List<CategoryResponse>
|
||||||
|
|
||||||
|
suspend fun category(categoryId: String, userId: String): CategoryResponse
|
||||||
|
|
||||||
|
suspend fun save(request: CategoryRequest, userId: String, categoryId: String? = null): CategoryResponse
|
||||||
|
|
||||||
|
suspend fun delete(categoryId: String, userId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultCategoryService(
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val permissionRepository: PermissionRepository
|
||||||
|
) : CategoryService {
|
||||||
|
|
||||||
|
override suspend fun categories(
|
||||||
|
budgetIds: List<String>,
|
||||||
|
userId: String,
|
||||||
|
expense: Boolean?,
|
||||||
|
archived: Boolean?,
|
||||||
|
): List<CategoryResponse> {
|
||||||
|
val validBudgetIds = permissionRepository.findAll(
|
||||||
|
budgetIds = budgetIds,
|
||||||
|
userId = userId
|
||||||
|
).map { it.budgetId }
|
||||||
|
if (validBudgetIds.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return categoryRepository.findAll(
|
||||||
|
budgetIds = budgetIds,
|
||||||
|
expense = expense,
|
||||||
|
archived = archived
|
||||||
|
).map { it.asResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun category(categoryId: String, userId: String): CategoryResponse {
|
||||||
|
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
|
||||||
|
if (budgetIds.isEmpty()) {
|
||||||
|
throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
return categoryRepository.findAll(
|
||||||
|
ids = listOf(categoryId),
|
||||||
|
budgetIds = budgetIds
|
||||||
|
)
|
||||||
|
.map { it.asResponse() }
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(request: CategoryRequest, userId: String, categoryId: String?): CategoryResponse {
|
||||||
|
val category = categoryId?.let {
|
||||||
|
categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
} ?: run {
|
||||||
|
if (request.title.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
|
||||||
|
}
|
||||||
|
if (request.budgetId.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
|
||||||
|
}
|
||||||
|
Category(
|
||||||
|
title = request.title,
|
||||||
|
description = request.description,
|
||||||
|
amount = request.amount ?: 0L,
|
||||||
|
expense = request.expense ?: true,
|
||||||
|
budgetId = request.budgetId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
|
||||||
|
return categoryRepository.save(
|
||||||
|
category.copy(
|
||||||
|
title = request.title?.ifBlank { category.title } ?: category.title,
|
||||||
|
description = request.description ?: category.description,
|
||||||
|
amount = request.amount ?: category.amount,
|
||||||
|
expense = request.expense ?: category.expense,
|
||||||
|
archived = request.archived ?: category.archived,
|
||||||
|
budgetId = request.budgetId?.ifBlank { category.budgetId } ?: category.budgetId
|
||||||
|
)
|
||||||
|
).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(categoryId: String, userId: String) {
|
||||||
|
val category = categoryRepository.findAll(ids = listOf(categoryId))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(userId, category.budgetId, Permission.WRITE)
|
||||||
|
categoryRepository.delete(category)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
package com.wbrawner.twigs.service.recurringtransaction
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.asFrequency
|
||||||
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.model.RecurringTransaction
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
import com.wbrawner.twigs.service.requirePermission
|
||||||
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
|
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||||
|
import com.wbrawner.twigs.toInstant
|
||||||
|
import io.ktor.http.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface RecurringTransactionService {
|
||||||
|
suspend fun recurringTransactions(
|
||||||
|
budgetId: String,
|
||||||
|
userId: String,
|
||||||
|
): List<RecurringTransactionResponse>
|
||||||
|
|
||||||
|
suspend fun recurringTransaction(recurringTransactionId: String, userId: String): RecurringTransactionResponse
|
||||||
|
|
||||||
|
suspend fun save(
|
||||||
|
request: RecurringTransactionRequest,
|
||||||
|
userId: String,
|
||||||
|
recurringTransactionId: String? = null
|
||||||
|
): RecurringTransactionResponse
|
||||||
|
|
||||||
|
suspend fun delete(recurringTransactionId: String, userId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultRecurringTransactionService(
|
||||||
|
private val recurringTransactionRepository: RecurringTransactionRepository,
|
||||||
|
private val permissionRepository: PermissionRepository
|
||||||
|
) : RecurringTransactionService {
|
||||||
|
override suspend fun recurringTransactions(
|
||||||
|
budgetId: String,
|
||||||
|
userId: String
|
||||||
|
): List<RecurringTransactionResponse> {
|
||||||
|
permissionRepository.requirePermission(userId, budgetId, Permission.READ)
|
||||||
|
return recurringTransactionRepository.findAll(budgetId = budgetId)
|
||||||
|
.map { it.asResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun recurringTransaction(
|
||||||
|
recurringTransactionId: String,
|
||||||
|
userId: String
|
||||||
|
): RecurringTransactionResponse {
|
||||||
|
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.READ)
|
||||||
|
return recurringTransaction.asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(
|
||||||
|
request: RecurringTransactionRequest,
|
||||||
|
userId: String,
|
||||||
|
recurringTransactionId: String?
|
||||||
|
): RecurringTransactionResponse {
|
||||||
|
val recurringTransaction = recurringTransactionId?.let {
|
||||||
|
recurringTransactionRepository.findAll(ids = listOf(it))
|
||||||
|
.firstOrNull()
|
||||||
|
?.also { recurringTransaction ->
|
||||||
|
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
|
||||||
|
}
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
} ?: run {
|
||||||
|
if (request.title.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
|
||||||
|
}
|
||||||
|
if (request.budgetId.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
|
||||||
|
}
|
||||||
|
RecurringTransaction(
|
||||||
|
title = request.title,
|
||||||
|
description = request.description,
|
||||||
|
amount = request.amount ?: 0L,
|
||||||
|
expense = request.expense ?: true,
|
||||||
|
budgetId = request.budgetId,
|
||||||
|
categoryId = request.categoryId,
|
||||||
|
createdBy = userId,
|
||||||
|
start = request.start?.toInstant() ?: Instant.now(),
|
||||||
|
finish = request.finish?.toInstant(),
|
||||||
|
frequency = request.frequency.asFrequency()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
|
||||||
|
return recurringTransactionRepository.save(
|
||||||
|
recurringTransaction.copy(
|
||||||
|
title = request.title?.ifBlank { recurringTransaction.title } ?: recurringTransaction.title,
|
||||||
|
description = request.description ?: recurringTransaction.description,
|
||||||
|
amount = request.amount ?: recurringTransaction.amount,
|
||||||
|
expense = request.expense ?: recurringTransaction.expense,
|
||||||
|
budgetId = request.budgetId?.ifBlank { recurringTransaction.budgetId } ?: recurringTransaction.budgetId,
|
||||||
|
categoryId = request.categoryId ?: recurringTransaction.categoryId,
|
||||||
|
start = request.start?.toInstant() ?: recurringTransaction.start,
|
||||||
|
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
|
||||||
|
frequency = request.frequency.asFrequency()
|
||||||
|
)
|
||||||
|
).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(recurringTransactionId: String, userId: String) {
|
||||||
|
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(recurringTransactionId))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(userId, recurringTransaction.budgetId, Permission.WRITE)
|
||||||
|
recurringTransactionRepository.delete(recurringTransaction)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,167 @@
|
||||||
|
package com.wbrawner.twigs.service.transaction
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.endOfMonth
|
||||||
|
import com.wbrawner.twigs.firstOfMonth
|
||||||
|
import com.wbrawner.twigs.model.Permission
|
||||||
|
import com.wbrawner.twigs.model.Transaction
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
import com.wbrawner.twigs.service.requirePermission
|
||||||
|
import com.wbrawner.twigs.storage.CategoryRepository
|
||||||
|
import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
|
import com.wbrawner.twigs.storage.TransactionRepository
|
||||||
|
import com.wbrawner.twigs.toInstant
|
||||||
|
import com.wbrawner.twigs.toInstantOrNull
|
||||||
|
import io.ktor.http.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface TransactionService {
|
||||||
|
suspend fun transactions(
|
||||||
|
budgetIds: List<String>,
|
||||||
|
categoryIds: List<String>?,
|
||||||
|
from: Instant?,
|
||||||
|
to: Instant?,
|
||||||
|
expense: Boolean?,
|
||||||
|
userId: String,
|
||||||
|
): List<TransactionResponse>
|
||||||
|
|
||||||
|
suspend fun transaction(transactionId: String, userId: String): TransactionResponse
|
||||||
|
|
||||||
|
suspend fun sum(
|
||||||
|
budgetId: String?,
|
||||||
|
categoryId: String?,
|
||||||
|
from: Instant?,
|
||||||
|
to: Instant?,
|
||||||
|
userId: String,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
suspend fun save(
|
||||||
|
request: TransactionRequest,
|
||||||
|
userId: String,
|
||||||
|
transactionId: String? = null
|
||||||
|
): TransactionResponse
|
||||||
|
|
||||||
|
suspend fun delete(transactionId: String, userId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultTransactionService(
|
||||||
|
private val transactionRepository: TransactionRepository,
|
||||||
|
private val categoryRepository: CategoryRepository,
|
||||||
|
private val permissionRepository: PermissionRepository
|
||||||
|
) : TransactionService {
|
||||||
|
override suspend fun transactions(
|
||||||
|
budgetIds: List<String>,
|
||||||
|
categoryIds: List<String>?,
|
||||||
|
from: Instant?,
|
||||||
|
to: Instant?,
|
||||||
|
expense: Boolean?,
|
||||||
|
userId: String
|
||||||
|
): List<TransactionResponse> {
|
||||||
|
permissionRepository.requirePermission(userId, budgetIds, Permission.READ)
|
||||||
|
return transactionRepository.findAll(
|
||||||
|
budgetIds = budgetIds,
|
||||||
|
categoryIds = categoryIds,
|
||||||
|
from = from,
|
||||||
|
to = to,
|
||||||
|
expense = expense
|
||||||
|
).map { it.asResponse() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transaction(
|
||||||
|
transactionId: String,
|
||||||
|
userId: String
|
||||||
|
): TransactionResponse {
|
||||||
|
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.READ)
|
||||||
|
return transaction.asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sum(
|
||||||
|
budgetId: String?,
|
||||||
|
categoryId: String?,
|
||||||
|
from: Instant?,
|
||||||
|
to: Instant?,
|
||||||
|
userId: String
|
||||||
|
): Long {
|
||||||
|
if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum")
|
||||||
|
}
|
||||||
|
if (budgetId?.isNotBlank() == true && categoryId?.isNotBlank() == true) {
|
||||||
|
throw HttpException(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
message = "budgetId and categoryId cannot be provided together"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return if (!categoryId.isNullOrBlank()) {
|
||||||
|
val category = categoryRepository.findAll(ids = listOf(categoryId)).firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(
|
||||||
|
userId = userId,
|
||||||
|
budgetId = category.budgetId,
|
||||||
|
permission = Permission.READ
|
||||||
|
)
|
||||||
|
transactionRepository.sumByCategory(category.id, from ?: firstOfMonth, to ?: endOfMonth)
|
||||||
|
} else if (!budgetId.isNullOrBlank()) {
|
||||||
|
permissionRepository.requirePermission(userId = userId, budgetId = budgetId, permission = Permission.READ)
|
||||||
|
transactionRepository.sumByBudget(budgetId, from ?: firstOfMonth, to ?: endOfMonth)
|
||||||
|
} else {
|
||||||
|
error("Somehow we didn't return either a budget or category sum")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(
|
||||||
|
request: TransactionRequest,
|
||||||
|
userId: String,
|
||||||
|
transactionId: String?
|
||||||
|
): TransactionResponse {
|
||||||
|
val transaction = transactionId?.let {
|
||||||
|
transactionRepository.findAll(ids = listOf(it))
|
||||||
|
.firstOrNull()
|
||||||
|
?.also { transaction ->
|
||||||
|
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
|
||||||
|
}
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
} ?: run {
|
||||||
|
if (request.title.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "title cannot be null or empty")
|
||||||
|
}
|
||||||
|
if (request.budgetId.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "budgetId cannot be null or empty")
|
||||||
|
}
|
||||||
|
if (request.date?.toInstantOrNull() == null) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "invalid date")
|
||||||
|
}
|
||||||
|
Transaction(
|
||||||
|
title = request.title,
|
||||||
|
description = request.description,
|
||||||
|
amount = request.amount ?: 0L,
|
||||||
|
expense = request.expense ?: true,
|
||||||
|
budgetId = request.budgetId,
|
||||||
|
categoryId = request.categoryId,
|
||||||
|
date = request.date.toInstant(),
|
||||||
|
createdBy = userId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
permissionRepository.requirePermission(userId, request.budgetId ?: transaction.budgetId, Permission.WRITE)
|
||||||
|
return transactionRepository.save(
|
||||||
|
transaction.copy(
|
||||||
|
title = request.title?.ifBlank { transaction.title } ?: transaction.title,
|
||||||
|
description = request.description ?: transaction.description,
|
||||||
|
amount = request.amount ?: transaction.amount,
|
||||||
|
expense = request.expense ?: transaction.expense,
|
||||||
|
budgetId = request.budgetId?.ifBlank { transaction.budgetId } ?: transaction.budgetId,
|
||||||
|
categoryId = request.categoryId ?: transaction.categoryId,
|
||||||
|
date = request.date?.toInstantOrNull() ?: transaction.date
|
||||||
|
)
|
||||||
|
).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(transactionId: String, userId: String) {
|
||||||
|
val transaction = transactionRepository.findAll(ids = listOf(transactionId))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
permissionRepository.requirePermission(userId, transaction.budgetId, Permission.WRITE)
|
||||||
|
transactionRepository.delete(transaction)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
package com.wbrawner.twigs.service.user
|
||||||
|
|
||||||
|
import com.wbrawner.twigs.EmailService
|
||||||
|
import com.wbrawner.twigs.model.PasswordResetToken
|
||||||
|
import com.wbrawner.twigs.model.Session
|
||||||
|
import com.wbrawner.twigs.model.User
|
||||||
|
import com.wbrawner.twigs.service.HttpException
|
||||||
|
import com.wbrawner.twigs.storage.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface UserService {
|
||||||
|
|
||||||
|
suspend fun login(request: LoginRequest): SessionResponse
|
||||||
|
|
||||||
|
suspend fun register(request: UserRequest): UserResponse
|
||||||
|
|
||||||
|
suspend fun requestPasswordResetEmail(request: ResetPasswordRequest)
|
||||||
|
|
||||||
|
suspend fun resetPassword(request: PasswordResetRequest)
|
||||||
|
suspend fun users(query: String?, budgetIds: List<String>?, requestingUserId: String): List<UserResponse>
|
||||||
|
|
||||||
|
suspend fun user(userId: String): UserResponse
|
||||||
|
|
||||||
|
suspend fun save(request: UserRequest, targetUserId: String, requestingUserId: String): UserResponse
|
||||||
|
|
||||||
|
suspend fun delete(targetUserId: String, requestingUserId: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultUserService(
|
||||||
|
private val emailService: EmailService,
|
||||||
|
private val passwordResetRepository: PasswordResetRepository,
|
||||||
|
private val permissionRepository: PermissionRepository,
|
||||||
|
private val sessionRepository: SessionRepository,
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val passwordHasher: PasswordHasher
|
||||||
|
) : UserService {
|
||||||
|
|
||||||
|
override suspend fun login(request: LoginRequest): SessionResponse {
|
||||||
|
val user = userRepository.findAll(
|
||||||
|
nameOrEmail = request.username,
|
||||||
|
password = passwordHasher.hash(request.password)
|
||||||
|
)
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid credentials")
|
||||||
|
return sessionRepository.save(Session(userId = user.id)).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun register(request: UserRequest): UserResponse {
|
||||||
|
if (request.username.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "username must not be null or blank")
|
||||||
|
}
|
||||||
|
if (request.password.isNullOrBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "password must not be null or blank")
|
||||||
|
}
|
||||||
|
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
|
||||||
|
?: request.email?.let {
|
||||||
|
if (it.isBlank()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
userRepository.findAll(nameOrEmail = it).firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existingUser?.let {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "username or email already taken")
|
||||||
|
}
|
||||||
|
return userRepository.save(
|
||||||
|
User(
|
||||||
|
name = request.username,
|
||||||
|
password = passwordHasher.hash(request.password),
|
||||||
|
email = if (request.email.isNullOrBlank()) "" else request.email
|
||||||
|
)
|
||||||
|
).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun requestPasswordResetEmail(request: ResetPasswordRequest) {
|
||||||
|
userRepository.findAll(nameOrEmail = request.username)
|
||||||
|
.firstOrNull()
|
||||||
|
?.let {
|
||||||
|
val email = it.email
|
||||||
|
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
||||||
|
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun resetPassword(request: PasswordResetRequest) {
|
||||||
|
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
|
||||||
|
.firstOrNull()
|
||||||
|
?: throw HttpException(HttpStatusCode.Unauthorized, message = "Invalid token")
|
||||||
|
if (passwordResetToken.expiration.isBefore(Instant.now())) {
|
||||||
|
throw HttpException(HttpStatusCode.Unauthorized, message = "Token expired")
|
||||||
|
}
|
||||||
|
if (request.password.isBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "password cannot be empty")
|
||||||
|
}
|
||||||
|
userRepository.findAll(listOf(passwordResetToken.userId))
|
||||||
|
.firstOrNull()
|
||||||
|
?.let {
|
||||||
|
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
|
||||||
|
passwordResetRepository.delete(passwordResetToken)
|
||||||
|
}
|
||||||
|
?: throw HttpException(HttpStatusCode.InternalServerError, message = "Invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun users(
|
||||||
|
query: String?,
|
||||||
|
budgetIds: List<String>?,
|
||||||
|
requestingUserId: String
|
||||||
|
): List<UserResponse> {
|
||||||
|
if (query != null) {
|
||||||
|
if (query.isBlank()) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "query cannot be empty")
|
||||||
|
}
|
||||||
|
return userRepository.findAll(nameLike = query).map { it.asResponse() }
|
||||||
|
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
|
||||||
|
throw HttpException(HttpStatusCode.BadRequest, message = "query or budgetId required but absent")
|
||||||
|
}
|
||||||
|
return permissionRepository.findAll(budgetIds = budgetIds, userId = requestingUserId)
|
||||||
|
.mapNotNull {
|
||||||
|
userRepository.findAll(ids = listOf(it.userId))
|
||||||
|
.firstOrNull()
|
||||||
|
?.asResponse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun user(
|
||||||
|
userId: String
|
||||||
|
): UserResponse {
|
||||||
|
return userRepository.findAll(ids = listOf(userId))
|
||||||
|
.firstOrNull()
|
||||||
|
?.asResponse()
|
||||||
|
?: throw HttpException(HttpStatusCode.NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun save(
|
||||||
|
request: UserRequest,
|
||||||
|
targetUserId: String,
|
||||||
|
requestingUserId: String,
|
||||||
|
): UserResponse {
|
||||||
|
// TODO: Add some kind of admin denotation to allow admins to edit other users
|
||||||
|
if (targetUserId != requestingUserId) {
|
||||||
|
throw HttpException(HttpStatusCode.Forbidden)
|
||||||
|
}
|
||||||
|
return userRepository.save(
|
||||||
|
userRepository.findAll(ids = listOf(targetUserId))
|
||||||
|
.first()
|
||||||
|
.run {
|
||||||
|
val newPassword = if (request.password.isNullOrBlank()) {
|
||||||
|
password
|
||||||
|
} else {
|
||||||
|
passwordHasher.hash(request.password)
|
||||||
|
}
|
||||||
|
copy(
|
||||||
|
name = request.username ?: name,
|
||||||
|
password = newPassword,
|
||||||
|
email = request.email ?: email
|
||||||
|
)
|
||||||
|
}
|
||||||
|
).asResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(targetUserId: String, requestingUserId: String) {
|
||||||
|
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
||||||
|
if (targetUserId != requestingUserId) {
|
||||||
|
throw HttpException(HttpStatusCode.Forbidden)
|
||||||
|
}
|
||||||
|
userRepository.delete(userRepository.findAll(targetUserId).first())
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ package com.wbrawner.twigs.storage
|
||||||
import com.wbrawner.twigs.model.UserPermission
|
import com.wbrawner.twigs.model.UserPermission
|
||||||
|
|
||||||
interface PermissionRepository : Repository<UserPermission> {
|
interface PermissionRepository : Repository<UserPermission> {
|
||||||
fun findAll(
|
suspend fun findAll(
|
||||||
budgetIds: List<String>? = null,
|
budgetIds: List<String>? = null,
|
||||||
userId: String? = null
|
userId: String? = null
|
||||||
): List<UserPermission>
|
): List<UserPermission>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository
|
||||||
|
|
||||||
class FakePermissionRepository : PermissionRepository {
|
class FakePermissionRepository : PermissionRepository {
|
||||||
val permissions: MutableList<UserPermission> = mutableListOf()
|
val permissions: MutableList<UserPermission> = mutableListOf()
|
||||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||||
permissions.filter { userPermission ->
|
permissions.filter { userPermission ->
|
||||||
budgetIds?.contains(userPermission.budgetId) ?: true
|
budgetIds?.contains(userPermission.budgetId) ?: true
|
||||||
&& userId?.let { it == userPermission.userId } ?: true
|
&& userId?.let { it == userPermission.userId } ?: true
|
||||||
|
|
Loading…
Reference in a new issue