From 9a01fb39ecee8a8077c128ef5755d3d2b55f5a9c Mon Sep 17 00:00:00 2001 From: William Brawner Date: Wed, 27 Mar 2024 16:50:06 -0600 Subject: [PATCH] Finish implementing remaining service classes --- api/build.gradle.kts | 2 +- .../kotlin/com/wbrawner/twigs/BudgetRoutes.kt | 48 +++-- .../com/wbrawner/twigs/CategoryRoutes.kt | 141 +++--------- .../twigs/RecurringTransactionRoutes.kt | 179 ++++----------- .../com/wbrawner/twigs/TransactionRoutes.kt | 170 ++++----------- .../kotlin/com/wbrawner/twigs/UserRoutes.kt | 203 ++++-------------- app/build.gradle.kts | 1 + .../com/wbrawner/twigs/server/Application.kt | 139 +++++++----- .../com/wbrawner/twigs/server/api/ApiTest.kt | 39 +++- .../twigs/server/api/BudgetRouteTest.kt | 14 +- .../server/api/PasswordResetRouteTest.kt | 25 +-- .../twigs/server/api/UserRouteTest.kt | 15 +- .../main/kotlin/com/wbrawner/twigs/Utils.kt | 7 + .../twigs/db/JdbcPermissionRepository.kt | 2 +- service/.gitignore | 1 + .../com/wbrawner/twigs/service/ApiUtils.kt | 52 ++++- .../twigs/service/category/CategoryService.kt | 104 +++++++++ ...tionsApi.kt => RecurringTransactionApi.kt} | 0 .../RecurringTransactionService.kt | 110 ++++++++++ .../service/transaction/TransactionService.kt | 167 ++++++++++++++ .../twigs/service/user/UserService.kt | 169 +++++++++++++++ .../twigs/storage/PermissionRepository.kt | 2 +- .../repository/FakePermissionRepository.kt | 2 +- 23 files changed, 931 insertions(+), 661 deletions(-) create mode 100644 service/.gitignore create mode 100644 service/src/main/java/com/wbrawner/twigs/service/category/CategoryService.kt rename service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/{RecurringTransactionsApi.kt => RecurringTransactionApi.kt} (100%) create mode 100644 service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionService.kt create mode 100644 service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt create mode 100644 service/src/main/java/com/wbrawner/twigs/service/user/UserService.kt diff --git a/api/build.gradle.kts b/api/build.gradle.kts index cc041ed..31a0a80 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -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")) } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt index 3cc5898..cfee9c9 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt @@ -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 is required" } - call.respond(budgetService.budgetsForUser(userId = session.userId)) + call.respondCatching { + budgetService.budgetsForUser(userId = requireSession().userId) + } } get("/{id}") { - val session = requireNotNull(call.principal()) { "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()!! - val request = call.receive() - 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 was null" } - val request = call.receive() - 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 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 + } } } } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt index 2cee3e7..dd86e47 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/CategoryRoutes.kt @@ -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()!! - val budgetIds = permissionRepository.findAll( - budgetIds = call.request.queryParameters.getAll("budgetIds"), - userId = session.userId - ).map { it.budgetId } - if (budgetIds.isEmpty()) { - call.respond(emptyList()) - 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()!! - 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()!! - val request = call.receive() - if (request.title.isNullOrBlank()) { - errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty") - return@post + call.respondCatching { + categoryService.save(call.receive(), 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()!! - val request = call.receive() - 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(), + 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()!! - 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) } } } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt index 452a94a..13b2e82 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/RecurringTransactionRoutes.kt @@ -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.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()!! - 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()!! - recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) { - call.respond(it.asResponse()) - } - } - - post { - val session = call.principal()!! - val request = call.receive() - 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()!! - val request = call.receive() - 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(), + userId = requireSession().userId + ) + } + } + + put("/{id}") { + recurringTransactionService.save( + request = call.receive(), + userId = requireSession().userId, + recurringTransactionId = call.parameters.getOrFail("id") + ) + } + delete("/{id}") { - val session = call.principal()!! - 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 } } } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt index 61bb24b..32ddcb0 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/TransactionRoutes.kt @@ -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()!! - 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()!! - 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()!! - val request = call.receive() - if (request.title.isNullOrBlank()) { - errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty") - return@post + call.respondCatching { + transactionService.save( + request = call.receive(), + 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()!! - val request = call.receive() - 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(), + 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()!! - 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) } } } diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt index 083072a..e4c05c9 100644 --- a/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserRoutes.kt @@ -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() - 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() - 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()!! - val request = call.receive() - // 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()!! - // 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() - 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() - 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) - } - } } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7575e13..6cee192 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index a614196..3716def 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -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, + 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)) diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt index 668d318..fca73fd 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/ApiTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt index c7f1837..800a1fe 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/BudgetRouteTest.kt @@ -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 -> diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/PasswordResetRouteTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/PasswordResetRouteTest.kt index 811d53a..789c9b2 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/PasswordResetRouteTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/PasswordResetRouteTest.kt @@ -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) } diff --git a/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt b/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt index 38902e4..b49c173 100644 --- a/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt +++ b/app/src/test/kotlin/com/wbrawner/twigs/server/api/UserRouteTest.kt @@ -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() - 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() - 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() - 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() - 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() - 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() - assertEquals("Username or email already taken", errorBody.message) + assertEquals("username or email already taken", errorBody.message) } @Test diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt index e2d2105..4922308 100644 --- a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt +++ b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt @@ -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) diff --git a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt index 9a8832f..aff524c 100644 --- a/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt +++ b/db/src/main/kotlin/com/wbrawner/twigs/db/JdbcPermissionRepository.kt @@ -13,7 +13,7 @@ class JdbcPermissionRepository(dataSource: DataSource) : override val conflictFields: Collection = listOf(Fields.USER_ID.name.lowercase(), Fields.BUDGET_ID.name.lowercase()) - override fun findAll(budgetIds: List?, userId: String?): List = + override suspend fun findAll(budgetIds: List?, userId: String?): List = dataSource.connection.use { conn -> if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) { throw Error("budgetIds or userId must be provided") diff --git a/service/.gitignore b/service/.gitignore new file mode 100644 index 0000000..d163863 --- /dev/null +++ b/service/.gitignore @@ -0,0 +1 @@ +build/ \ No newline at end of file diff --git a/service/src/main/java/com/wbrawner/twigs/service/ApiUtils.kt b/service/src/main/java/com/wbrawner/twigs/service/ApiUtils.kt index 2d0e605..8847134 100644 --- a/service/src/main/java/com/wbrawner/twigs/service/ApiUtils.kt +++ b/service/src/main/java/com/wbrawner/twigs/service/ApiUtils.kt @@ -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, + permission: Permission +): List { + 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 = requirePermission(userId, listOf(budgetId), permission) suspend fun Pair.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) + val budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound) + return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission)) +} + +fun PipelineContext.requireSession() = requireNotNull(call.principal()) { + "Session required but was null" +} + +suspend inline fun 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) -} \ No newline at end of file diff --git a/service/src/main/java/com/wbrawner/twigs/service/category/CategoryService.kt b/service/src/main/java/com/wbrawner/twigs/service/category/CategoryService.kt new file mode 100644 index 0000000..57c9e46 --- /dev/null +++ b/service/src/main/java/com/wbrawner/twigs/service/category/CategoryService.kt @@ -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, + userId: String, + expense: Boolean? = null, + archived: Boolean? = null, + ): List + + 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, + userId: String, + expense: Boolean?, + archived: Boolean?, + ): List { + 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) + } +} \ No newline at end of file diff --git a/service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionsApi.kt b/service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionApi.kt similarity index 100% rename from service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionsApi.kt rename to service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionApi.kt diff --git a/service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionService.kt b/service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionService.kt new file mode 100644 index 0000000..c8ca193 --- /dev/null +++ b/service/src/main/java/com/wbrawner/twigs/service/recurringtransaction/RecurringTransactionService.kt @@ -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 + + 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 { + 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) + } +} \ No newline at end of file diff --git a/service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt b/service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt new file mode 100644 index 0000000..8aaec5c --- /dev/null +++ b/service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt @@ -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, + categoryIds: List?, + from: Instant?, + to: Instant?, + expense: Boolean?, + userId: String, + ): List + + 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, + categoryIds: List?, + from: Instant?, + to: Instant?, + expense: Boolean?, + userId: String + ): List { + 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) + } +} \ No newline at end of file diff --git a/service/src/main/java/com/wbrawner/twigs/service/user/UserService.kt b/service/src/main/java/com/wbrawner/twigs/service/user/UserService.kt new file mode 100644 index 0000000..61fa844 --- /dev/null +++ b/service/src/main/java/com/wbrawner/twigs/service/user/UserService.kt @@ -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?, requestingUserId: String): List + + 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?, + requestingUserId: String + ): List { + 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()) + } +} \ No newline at end of file diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt index 1687e74..b5e4713 100644 --- a/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt @@ -3,7 +3,7 @@ package com.wbrawner.twigs.storage import com.wbrawner.twigs.model.UserPermission interface PermissionRepository : Repository { - fun findAll( + suspend fun findAll( budgetIds: List? = null, userId: String? = null ): List diff --git a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt index 4b44d31..95d7795 100644 --- a/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt +++ b/testhelpers/src/main/kotlin/com/wbrawner/twigs/test/helpers/repository/FakePermissionRepository.kt @@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository class FakePermissionRepository : PermissionRepository { val permissions: MutableList = mutableListOf() - override fun findAll(budgetIds: List?, userId: String?): List = + override suspend fun findAll(budgetIds: List?, userId: String?): List = permissions.filter { userPermission -> budgetIds?.contains(userPermission.budgetId) ?: true && userId?.let { it == userPermission.userId } ?: true