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"))
|
||||
api(project(":core"))
|
||||
implementation(project(":service"))
|
||||
implementation(project(":storage"))
|
||||
api(libs.ktor.server.core)
|
||||
api(libs.ktor.serialization)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
testImplementation(project(":testhelpers"))
|
||||
}
|
||||
|
|
|
@ -1,48 +1,58 @@
|
|||
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.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.budgetRoutes(budgetService: BudgetService) {
|
||||
routing {
|
||||
route("/api/budgets") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = requireNotNull(call.principal<Session>()) { "session is required" }
|
||||
call.respond(budgetService.budgetsForUser(userId = session.userId))
|
||||
call.respondCatching {
|
||||
budgetService.budgetsForUser(userId = requireSession().userId)
|
||||
}
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = requireNotNull(call.principal<Session>()) { "session is required" }
|
||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
||||
call.respond(budgetService.budget(budgetId = budgetId, userId = session.userId))
|
||||
call.respondCatching {
|
||||
budgetService.budget(
|
||||
budgetId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<BudgetRequest>()
|
||||
call.respond(budgetService.save(request = request, userId = session.userId))
|
||||
call.respondCatching {
|
||||
budgetService.save(request = call.receive(), userId = requireSession().userId)
|
||||
}
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = requireNotNull(call.principal<Session>()) { "session was null" }
|
||||
val request = call.receive<BudgetRequest>()
|
||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
||||
call.respond(budgetService.save(request = request, userId = session.id, budgetId = budgetId))
|
||||
call.respondCatching {
|
||||
budgetService.save(
|
||||
request = call.receive(),
|
||||
userId = requireSession().userId,
|
||||
budgetId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
val session = requireNotNull(call.principal<Session>()) { "session was null" }
|
||||
val budgetId = requireNotNull(call.parameters["id"]) { "budgetId is required" }
|
||||
budgetService.delete(budgetId = budgetId, userId = session.userId)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
call.respondCatching {
|
||||
budgetService.delete(
|
||||
budgetId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,143 +1,64 @@
|
|||
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.CategoryResponse
|
||||
import com.wbrawner.twigs.service.errorResponse
|
||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
||||
import com.wbrawner.twigs.storage.CategoryRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.service.category.CategoryService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.categoryRoutes(
|
||||
categoryRepository: CategoryRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
fun Application.categoryRoutes(categoryService: CategoryService) {
|
||||
routing {
|
||||
route("/api/categories") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<CategoryResponse>())
|
||||
return@get
|
||||
call.respondCatching {
|
||||
categoryService.categories(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||
userId = requireSession().userId,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
)
|
||||
}
|
||||
call.respond(categoryRepository.findAll(
|
||||
budgetIds = budgetIds,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
).map { it.asResponse() })
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
errorResponse()
|
||||
return@get
|
||||
call.respondCatching {
|
||||
categoryService.category(
|
||||
categoryId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
categoryRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = budgetIds
|
||||
)
|
||||
.map { it.asResponse() }
|
||||
.firstOrNull()?.let {
|
||||
call.respond(it)
|
||||
} ?: errorResponse()
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
categoryService.save(call.receive<CategoryRequest>(), requireSession().userId)
|
||||
}
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
call.respondCatching {
|
||||
categoryService.save(
|
||||
request = call.receive<CategoryRequest>(),
|
||||
userId = requireSession().userId,
|
||||
categoryId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val categoryId = call.parameters.entries().first().value
|
||||
val category = categoryRepository.findAll(ids = categoryId)
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.NotFound)
|
||||
return@delete
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@delete
|
||||
call.respondCatching {
|
||||
categoryService.delete(
|
||||
call.parameters.getOrFail("id"),
|
||||
requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
categoryRepository.delete(category)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,162 +1,59 @@
|
|||
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.asResponse
|
||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.pipeline.*
|
||||
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)
|
||||
}
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.recurringTransactionRoutes(recurringTransactionService: RecurringTransactionService) {
|
||||
routing {
|
||||
route("/api/recurringtransactions") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetId = call.request.queryParameters["budgetId"]
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
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()
|
||||
call.respondCatching {
|
||||
recurringTransactionService.recurringTransactions(
|
||||
budgetId = call.request.queryParameters.getOrFail("budgetId"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
|
||||
val response = if (recurringTransactionRepository.delete(it)) {
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
call.respondCatching {
|
||||
recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,173 +1,85 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.service.errorResponse
|
||||
import com.wbrawner.twigs.service.requireBudgetWithPermission
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import com.wbrawner.twigs.service.transaction.BalanceResponse
|
||||
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import java.time.Instant
|
||||
|
||||
fun Application.transactionRoutes(
|
||||
transactionRepository: TransactionRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
fun Application.transactionRoutes(transactionService: TransactionService) {
|
||||
routing {
|
||||
route("/api/transactions") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
call.respond(
|
||||
transactionRepository.findAll(
|
||||
budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId },
|
||||
call.respondCatching {
|
||||
transactionService.transactions(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
||||
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
||||
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
).map { it.asResponse() })
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val transaction = transactionRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = permissionRepository.findAll(
|
||||
userId = session.userId
|
||||
call.respondCatching {
|
||||
transactionService.transaction(
|
||||
transactionId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
.map { it.budgetId }
|
||||
)
|
||||
.map { it.asResponse() }
|
||||
.firstOrNull()
|
||||
transaction?.let {
|
||||
call.respond(it)
|
||||
} ?: errorResponse()
|
||||
}
|
||||
}
|
||||
|
||||
get("/sum") {
|
||||
val categoryId = call.request.queryParameters["categoryId"]
|
||||
val budgetId = call.request.queryParameters["budgetId"]
|
||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
|
||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
||||
val balance = if (!categoryId.isNullOrBlank()) {
|
||||
if (!budgetId.isNullOrBlank()) {
|
||||
errorResponse(
|
||||
HttpStatusCode.BadRequest,
|
||||
"budgetId and categoryId cannot be provided together"
|
||||
call.respondCatching {
|
||||
BalanceResponse(
|
||||
transactionService.sum(
|
||||
budgetId = call.request.queryParameters["budgetId"],
|
||||
categoryId = call.request.queryParameters["categoryId"],
|
||||
from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth,
|
||||
to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth,
|
||||
userId = requireSession().userId,
|
||||
)
|
||||
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 {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
transactionService.save(
|
||||
request = call.receive<TransactionRequest>(),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
transaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
call.respondCatching {
|
||||
transactionService.save(
|
||||
request = call.receive<TransactionRequest>(),
|
||||
userId = requireSession().userId,
|
||||
transactionId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return@delete
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
transaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@delete
|
||||
}
|
||||
val response = if (transactionRepository.delete(transaction)) {
|
||||
call.respondCatching {
|
||||
transactionService.delete(
|
||||
transactionId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,195 +1,84 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.User
|
||||
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 com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import java.time.Instant
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.userRoutes(
|
||||
emailService: EmailService,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
userRepository: UserRepository,
|
||||
passwordHasher: PasswordHasher
|
||||
) {
|
||||
fun Application.userRoutes(userService: UserService) {
|
||||
routing {
|
||||
route("/api/users") {
|
||||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
val user =
|
||||
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())
|
||||
call.respondCatching {
|
||||
userService.login(call.receive())
|
||||
}
|
||||
}
|
||||
|
||||
post("/register") {
|
||||
val request = call.receive<UserRequest>()
|
||||
if (request.username.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
userService.register(call.receive())
|
||||
}
|
||||
if (request.password.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
|
||||
?: request.email?.let {
|
||||
return@let if (it.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
userRepository.findAll(nameOrEmail = it).firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
route("/resetpassword") {
|
||||
post {
|
||||
call.respondCatching {
|
||||
userService.requestPasswordResetEmail(call.receive())
|
||||
HttpStatusCode.Accepted
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
get {
|
||||
val query = call.request.queryParameters["query"]
|
||||
val budgetIds = call.request.queryParameters.getAll("budgetId")
|
||||
if (query != null) {
|
||||
if (query.isBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty")
|
||||
}
|
||||
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")
|
||||
call.respondCatching {
|
||||
userService.users(
|
||||
query = call.request.queryParameters["query"],
|
||||
budgetIds = call.request.queryParameters.getAll("budgetId"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
}
|
||||
permissionRepository.findAll(budgetIds = budgetIds)
|
||||
.mapNotNull {
|
||||
userRepository.findAll(ids = listOf(it.userId))
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
}.run { call.respond(this) }
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
?.let { call.respond(it) }
|
||||
?: errorResponse(HttpStatusCode.NotFound)
|
||||
call.respondCatching {
|
||||
userService.user(call.parameters.getOrFail("id"))
|
||||
}
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<UserRequest>()
|
||||
// TODO: Add some kind of admin denotation to allow admins to edit other users
|
||||
if (call.parameters["id"] != session.userId) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return@put
|
||||
call.respondCatching {
|
||||
userService.save(
|
||||
request = call.receive(),
|
||||
targetUserId = call.parameters.getOrFail("id"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
}
|
||||
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}") {
|
||||
val session = call.principal<Session>()!!
|
||||
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
||||
val user = userRepository.findAll(call.parameters.entries().first().value).firstOrNull()
|
||||
if (user == null) {
|
||||
errorResponse()
|
||||
return@delete
|
||||
call.respondCatching {
|
||||
userService.delete(
|
||||
targetUserId = call.parameters.getOrFail("id"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
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.logback)
|
||||
implementation(libs.mail)
|
||||
implementation(project(mapOf("path" to ":service")))
|
||||
testImplementation(project(":testhelpers"))
|
||||
testImplementation(libs.ktor.client.content.negotiation)
|
||||
testImplementation(libs.ktor.server.test)
|
||||
|
|
|
@ -5,7 +5,17 @@ import ch.qos.logback.classic.Level
|
|||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.db.*
|
||||
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.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
|
@ -81,42 +91,78 @@ fun Application.module() {
|
|||
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(
|
||||
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"),
|
||||
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
|
||||
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
|
||||
recurringTransactionService = DefaultRecurringTransactionService(
|
||||
recurringTransactionRepository,
|
||||
permissionRepository
|
||||
),
|
||||
metadataRepository = JdbcMetadataRepository(it),
|
||||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
passwordResetRepository = JdbcPasswordResetRepository(it),
|
||||
passwordHasher = { password ->
|
||||
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
|
||||
},
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
transactionRepository = JdbcTransactionRepository(it),
|
||||
userRepository = JdbcUserRepository(it)
|
||||
transactionService = DefaultTransactionService(
|
||||
transactionRepository,
|
||||
categoryRepository,
|
||||
permissionRepository
|
||||
),
|
||||
userService = DefaultUserService(
|
||||
emailService,
|
||||
passwordResetRepository,
|
||||
permissionRepository,
|
||||
sessionRepository,
|
||||
userRepository,
|
||||
passwordHasher
|
||||
),
|
||||
jobs = jobs,
|
||||
sessionValidator = sessionValidator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.moduleWithDependencies(
|
||||
emailService: EmailService,
|
||||
metadataRepository: MetadataRepository,
|
||||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
passwordHasher: PasswordHasher,
|
||||
permissionRepository: PermissionRepository,
|
||||
recurringTransactionRepository: RecurringTransactionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
transactionRepository: TransactionRepository,
|
||||
userRepository: UserRepository
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
recurringTransactionService: RecurringTransactionService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService,
|
||||
jobs: List<Job>,
|
||||
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
|
||||
) {
|
||||
install(CallLogging)
|
||||
install(Authentication) {
|
||||
|
@ -124,22 +170,7 @@ fun Application.moduleWithDependencies(
|
|||
challenge {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
validate(sessionValidator)
|
||||
}
|
||||
}
|
||||
install(Sessions) {
|
||||
|
@ -170,6 +201,8 @@ fun Application.moduleWithDependencies(
|
|||
allowHost("twigs.wbrawner.com", listOf("http", "https")) // TODO: Make configurable
|
||||
allowHost("localhost:4200", listOf("http", "https")) // TODO: Make configurable
|
||||
allowMethod(HttpMethod.Options)
|
||||
allowMethod(HttpMethod.Get)
|
||||
allowMethod(HttpMethod.Post)
|
||||
allowMethod(HttpMethod.Put)
|
||||
allowMethod(HttpMethod.Delete)
|
||||
allowHeader(HttpHeaders.Authorization)
|
||||
|
@ -192,17 +225,13 @@ fun Application.moduleWithDependencies(
|
|||
allowHeader("DNT")
|
||||
allowCredentials = true
|
||||
}
|
||||
budgetRoutes(budgetRepository, permissionRepository)
|
||||
categoryRoutes(categoryRepository, permissionRepository)
|
||||
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
||||
transactionRoutes(transactionRepository, permissionRepository)
|
||||
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository, passwordHasher)
|
||||
budgetRoutes(budgetService)
|
||||
categoryRoutes(categoryService)
|
||||
recurringTransactionRoutes(recurringTransactionService)
|
||||
transactionRoutes(transactionService)
|
||||
userRoutes(userService)
|
||||
webRoutes()
|
||||
launch {
|
||||
val jobs = listOf(
|
||||
SessionCleanupJob(sessionRepository),
|
||||
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
delay(TimeUnit.HOURS.toMillis(1))
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
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.repository.*
|
||||
import io.ktor.client.*
|
||||
|
@ -38,17 +43,29 @@ open class ApiTest {
|
|||
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
|
||||
application {
|
||||
moduleWithDependencies(
|
||||
emailService = emailService,
|
||||
metadataRepository = metadataRepository,
|
||||
budgetRepository = budgetRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
passwordHasher = { it },
|
||||
passwordResetRepository = passwordResetRepository,
|
||||
permissionRepository = permissionRepository,
|
||||
recurringTransactionRepository = recurringTransactionRepository,
|
||||
sessionRepository = sessionRepository,
|
||||
transactionRepository = transactionRepository,
|
||||
userRepository = userRepository
|
||||
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
|
||||
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
|
||||
recurringTransactionService = DefaultRecurringTransactionService(
|
||||
recurringTransactionRepository,
|
||||
permissionRepository
|
||||
),
|
||||
transactionService = DefaultTransactionService(
|
||||
transactionRepository,
|
||||
categoryRepository,
|
||||
permissionRepository
|
||||
),
|
||||
userService = DefaultUserService(
|
||||
emailService,
|
||||
passwordResetRepository,
|
||||
permissionRepository,
|
||||
sessionRepository,
|
||||
userRepository,
|
||||
{ it }
|
||||
),
|
||||
jobs = listOf(),
|
||||
sessionValidator = {
|
||||
sessionRepository.findAll(it.token).firstOrNull()
|
||||
}
|
||||
)
|
||||
}
|
||||
val client = createClient {
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
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.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.request.*
|
||||
import io.ktor.http.*
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BudgetRouteTest : ApiTest() {
|
||||
|
@ -228,9 +227,8 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(expectedUsers, updatedUsers)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@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(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
|
@ -254,7 +252,6 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@Test
|
||||
fun `updating non-existent budgets returns not found`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
|
@ -273,7 +270,6 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@Test
|
||||
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
|
||||
apiTest { client ->
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
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.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 io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
|
@ -18,7 +18,7 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
@Test
|
||||
fun `reset password with invalid username returns 202`() = apiTest { client ->
|
||||
val request = ResetPasswordRequest(username = "invaliduser")
|
||||
val response = client.post("/api/resetpassword") {
|
||||
val response = client.post("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
@Test
|
||||
fun `reset password with valid username returns 202`() = apiTest { client ->
|
||||
val request = ResetPasswordRequest(username = "testuser")
|
||||
val response = client.post("/api/resetpassword") {
|
||||
val response = client.post("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -43,9 +43,9 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@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 response = client.post("/api/passwordreset") {
|
||||
val response = client.put("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -55,10 +55,10 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@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 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")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -68,10 +68,11 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `password reset with valid token returns 200`() = apiTest { client ->
|
||||
val token = passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
||||
fun `password reset with valid token returns 204`() = apiTest { client ->
|
||||
val token =
|
||||
passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
||||
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")
|
||||
setBody(request)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.model.Session
|
||||
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.TEST_USER
|
||||
import io.ktor.client.call.*
|
||||
|
@ -96,7 +97,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
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
|
||||
|
@ -108,7 +109,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
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
|
||||
|
@ -120,7 +121,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
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
|
||||
|
@ -132,7 +133,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
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
|
||||
|
@ -144,7 +145,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
val errorBody = response.body<ErrorResponse>()
|
||||
assertEquals("Username or email already taken", errorBody.message)
|
||||
assertEquals("username or email already taken", errorBody.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -156,7 +157,7 @@ class UserRouteTest : ApiTest() {
|
|||
}
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
val errorBody = response.body<ErrorResponse>()
|
||||
assertEquals("Username or email already taken", errorBody.message)
|
||||
assertEquals("username or email already taken", errorBody.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.wbrawner.twigs
|
|||
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import java.time.Instant
|
||||
import java.time.format.DateTimeParseException
|
||||
import java.util.*
|
||||
|
||||
private val CALENDAR_FIELDS = intArrayOf(
|
||||
|
@ -52,4 +53,10 @@ fun randomString(length: Int = 32): String {
|
|||
|
||||
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)
|
||||
|
|
|
@ -13,7 +13,7 @@ class JdbcPermissionRepository(dataSource: DataSource) :
|
|||
override val conflictFields: Collection<String> =
|
||||
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 ->
|
||||
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
|
||||
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
|
||||
|
||||
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.storage.BudgetRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
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(
|
||||
userId: String,
|
||||
budgetId: String,
|
||||
permission: Permission
|
||||
): BudgetResponse {
|
||||
val allPermissions = second.findAll(budgetIds = listOf(budgetId))
|
||||
val userPermission = allPermissions.firstOrNull { it.userId == userId }
|
||||
?: throw HttpException(HttpStatusCode.NotFound)
|
||||
if (!userPermission.permission.isAtLeast(permission)) {
|
||||
throw HttpException(HttpStatusCode.Forbidden)
|
||||
}
|
||||
return BudgetResponse(first.findAll(ids = listOf(budgetId)).first(), allPermissions)
|
||||
val budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound)
|
||||
return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
interface PermissionRepository : Repository<UserPermission> {
|
||||
fun findAll(
|
||||
suspend fun findAll(
|
||||
budgetIds: List<String>? = null,
|
||||
userId: String? = null
|
||||
): List<UserPermission>
|
||||
|
|
|
@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository
|
|||
|
||||
class FakePermissionRepository : PermissionRepository {
|
||||
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 ->
|
||||
budgetIds?.contains(userPermission.budgetId) ?: true
|
||||
&& userId?.let { it == userPermission.userId } ?: true
|
||||
|
|
Loading…
Reference in a new issue