Finish implementing remaining service classes

This commit is contained in:
William Brawner 2024-03-27 16:50:06 -06:00
parent a460421497
commit 9a01fb39ec
23 changed files with 931 additions and 661 deletions

View file

@ -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"))
}

View file

@ -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
}
}
}
}

View file

@ -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.respond(categoryRepository.findAll(
budgetIds = budgetIds,
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()
).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
}
categoryRepository.findAll(
ids = call.parameters.getAll("id"),
budgetIds = budgetIds
call.respondCatching {
categoryService.category(
categoryId = call.parameters.getOrFail("id"),
userId = requireSession().userId
)
.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.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()
call.respondCatching {
categoryService.save(
request = call.receive<CategoryRequest>(),
userId = requireSession().userId,
categoryId = call.parameters.getOrFail("id")
)
}
}
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
call.respondCatching {
categoryService.delete(
call.parameters.getOrFail("id"),
requireSession().userId
)
HttpStatusCode.NoContent
}
requireBudgetWithPermission(
permissionRepository,
session.userId,
category.budgetId,
Permission.WRITE
) {
return@delete
}
categoryRepository.delete(category)
call.respond(HttpStatusCode.NoContent)
}
}
}

View file

@ -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() }
call.respondCatching {
recurringTransactionService.recurringTransactions(
budgetId = call.request.queryParameters.getOrFail("budgetId"),
userId = requireSession().userId
)
}
}
get("/{id}") {
val session = call.principal<Session>()!!
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
call.respond(it.asResponse())
call.respondCatching {
recurringTransactionService.recurringTransaction(
call.parameters.getOrFail("id"),
requireSession().userId
)
}
}
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()
call.respondCatching {
recurringTransactionService.save(
request = call.receive<RecurringTransactionRequest>(),
userId = requireSession().userId
)
}
}
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()
recurringTransactionService.save(
request = call.receive<RecurringTransactionRequest>(),
userId = requireSession().userId,
recurringTransactionId = call.parameters.getOrFail("id")
)
).asResponse()
)
}
}
delete("/{id}") {
val session = call.principal<Session>()!!
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
val response = if (recurringTransactionRepository.delete(it)) {
call.respondCatching {
recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
HttpStatusCode.NoContent
} else {
HttpStatusCode.InternalServerError
}
call.respond(response)
}
}
}

View file

@ -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
}
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()
call.respondCatching {
transactionService.save(
request = call.receive<TransactionRequest>(),
userId = requireSession().userId
)
}
}
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.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()
call.respondCatching {
transactionService.save(
request = call.receive<TransactionRequest>(),
userId = requireSession().userId,
transactionId = call.parameters.getOrFail("id")
)
}
}
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)
}
}
}

View file

@ -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
call.respondCatching {
userService.login(call.receive())
}
val session = sessionRepository.save(Session(userId = user.id))
call.respond(session.asResponse())
}
post("/register") {
val request = call.receive<UserRequest>()
if (request.username.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
return@post
}
if (request.password.isNullOrBlank()) {
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
return@post
}
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
?: request.email?.let {
return@let if (it.isBlank()) {
null
} else {
userRepository.findAll(nameOrEmail = it).firstOrNull()
call.respondCatching {
userService.register(call.receive())
}
}
existingUser?.let {
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
return@post
route("/resetpassword") {
post {
call.respondCatching {
userService.requestPasswordResetEmail(call.receive())
HttpStatusCode.Accepted
}
}
put {
call.respondCatching {
userService.resetPassword(call.receive())
HttpStatusCode.NoContent
}
}
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.respondCatching {
userService.users(
query = call.request.queryParameters["query"],
budgetIds = call.request.queryParameters.getAll("budgetId"),
requestingUserId = requireSession().userId
)
}
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
return@get
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
errorResponse(HttpStatusCode.BadRequest, "query or budgetId required but absent")
}
permissionRepository.findAll(budgetIds = budgetIds)
.mapNotNull {
userRepository.findAll(ids = listOf(it.userId))
.firstOrNull()
?.asResponse()
}.run { call.respond(this) }
}
get("/{id}") {
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.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
call.respondCatching {
userService.save(
request = call.receive(),
targetUserId = call.parameters.getOrFail("id"),
requestingUserId = requireSession().userId
)
}
).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
}
if (user.id != session.userId) {
errorResponse(HttpStatusCode.Forbidden)
return@delete
}
userRepository.delete(user)
call.respond(HttpStatusCode.NoContent)
call.respondCatching {
userService.delete(
targetUserId = call.parameters.getOrFail("id"),
requestingUserId = requireSession().userId
)
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)
}
}
}
}

View file

@ -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)

View file

@ -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,50 +91,29 @@ fun Application.module() {
metadata
}
}
moduleWithDependencies(
emailService = SmtpEmailService(
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"),
),
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)
)
}
}
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
) {
install(CallLogging)
install(Authentication) {
session<Session> {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
validate { session ->
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()
@ -140,6 +129,48 @@ fun Application.moduleWithDependencies(
null
}
}
moduleWithDependencies(
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
recurringTransactionService = DefaultRecurringTransactionService(
recurringTransactionRepository,
permissionRepository
),
transactionService = DefaultTransactionService(
transactionRepository,
categoryRepository,
permissionRepository
),
userService = DefaultUserService(
emailService,
passwordResetRepository,
permissionRepository,
sessionRepository,
userRepository,
passwordHasher
),
jobs = jobs,
sessionValidator = sessionValidator
)
}
}
fun Application.moduleWithDependencies(
budgetService: BudgetService,
categoryService: CategoryService,
recurringTransactionService: RecurringTransactionService,
transactionService: TransactionService,
userService: UserService,
jobs: List<Job>,
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
) {
install(CallLogging)
install(Authentication) {
session<Session> {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
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))

View file

@ -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 {

View file

@ -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 ->

View file

@ -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)
}

View file

@ -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

View file

@ -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)

View file

@ -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
View file

@ -0,0 +1 @@
build/

View file

@ -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())
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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>

View file

@ -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