From 7e49dbeb3199c1b8828d6839b0dce3ca6c474e5c Mon Sep 17 00:00:00 2001 From: William Brawner Date: Sat, 30 Mar 2024 11:33:29 -0600 Subject: [PATCH] Add some budget pages I also added titles to the other pages, but it needs styling --- .../com/wbrawner/twigs/server/Application.kt | 8 +- .../twigs/service/budget/BudgetService.kt | 8 +- .../service/transaction/TransactionService.kt | 20 +-- .../kotlin/com/wbrawner/twigs/web/Page.kt | 11 +- .../com/wbrawner/twigs/web/WebRoutes.kt | 6 + .../wbrawner/twigs/web/budget/BudgetPages.kt | 41 +++++ .../twigs/web/budget/BudgetWebRoutes.kt | 145 ++++++++++++++++++ .../com/wbrawner/twigs/web/user/UserPages.kt | 8 +- .../templates/budget-details.mustache | 14 ++ .../resources/templates/budget-form.mustache | 22 +++ .../main/resources/templates/budgets.mustache | 14 ++ .../main/resources/templates/login.mustache | 1 + .../templates/partials/budget-list.mustache | 13 ++ .../resources/templates/register.mustache | 1 + 14 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt create mode 100644 web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt create mode 100644 web/src/main/resources/templates/budget-details.mustache create mode 100644 web/src/main/resources/templates/budget-form.mustache create mode 100644 web/src/main/resources/templates/budgets.mustache create mode 100644 web/src/main/resources/templates/partials/budget-list.mustache 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 7eb9913..631b7cb 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -178,6 +178,12 @@ fun Application.moduleWithDependencies( } validate(sessionValidator) } + session(TWIGS_SESSION_COOKIE) { + challenge { + call.respond(HttpStatusCode.Unauthorized) + } + validate(sessionValidator) + } } install(Sessions) { header("Authorization") { @@ -254,7 +260,7 @@ fun Application.moduleWithDependencies( recurringTransactionRoutes(recurringTransactionService) transactionRoutes(transactionService) userRoutes(userService) - webRoutes(budgetService, userService) + webRoutes(budgetService, categoryService, transactionService, userService) launch { while (currentCoroutineContext().isActive) { jobs.forEach { it.run() } diff --git a/service/src/main/java/com/wbrawner/twigs/service/budget/BudgetService.kt b/service/src/main/java/com/wbrawner/twigs/service/budget/BudgetService.kt index ec31e90..a0ba7d5 100644 --- a/service/src/main/java/com/wbrawner/twigs/service/budget/BudgetService.kt +++ b/service/src/main/java/com/wbrawner/twigs/service/budget/BudgetService.kt @@ -39,19 +39,19 @@ class DefaultBudgetService( budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ) override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse { - val budget = budgetId?.let { + val budget = if (budgetId?.isNotBlank() == true) { budgetPermissionRepository.budgetWithPermission( - budgetId = it, + budgetId = budgetId, userId = userId, permission = Permission.MANAGE ).run { Budget( - id = it, + id = budgetId, name = request.name ?: name, description = request.description ?: description ) } - } ?: run { + } else { if (request.name.isNullOrBlank()) { throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null") } 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 index 8aaec5c..45fba0b 100644 --- a/service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt +++ b/service/src/main/java/com/wbrawner/twigs/service/transaction/TransactionService.kt @@ -17,21 +17,21 @@ import java.time.Instant interface TransactionService { suspend fun transactions( budgetIds: List, - categoryIds: List?, - from: Instant?, - to: Instant?, - expense: Boolean?, + categoryIds: List? = null, + from: Instant? = null, + to: Instant? = null, + expense: Boolean? = null, userId: String, ): List suspend fun transaction(transactionId: String, userId: String): TransactionResponse suspend fun sum( - budgetId: String?, - categoryId: String?, - from: Instant?, - to: Instant?, userId: String, + budgetId: String? = null, + categoryId: String? = null, + from: Instant? = null, + to: Instant? = null, ): Long suspend fun save( @@ -78,11 +78,11 @@ class DefaultTransactionService( } override suspend fun sum( + userId: String, budgetId: String?, categoryId: String?, from: Instant?, - to: Instant?, - userId: String + to: Instant? ): Long { if (budgetId.isNullOrBlank() && categoryId.isNullOrBlank()) { throw HttpException(HttpStatusCode.BadRequest, message = "budgetId or categoryId must be provided to sum") diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt index 6bcfde3..b3ef296 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt @@ -1,3 +1,12 @@ package com.wbrawner.twigs.web -open class Page(val title: String) \ No newline at end of file +import com.wbrawner.twigs.service.user.UserResponse + +interface Page { + val title: String + val error: String? +} + +interface AuthenticatedPage : Page { + val user: UserResponse +} \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt index e515546..d84996c 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt @@ -2,7 +2,10 @@ package com.wbrawner.twigs.web import com.wbrawner.twigs.model.CookieSession import com.wbrawner.twigs.service.budget.BudgetService +import com.wbrawner.twigs.service.category.CategoryService +import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.user.UserService +import com.wbrawner.twigs.web.budget.budgetWebRoutes import com.wbrawner.twigs.web.user.userWebRoutes import io.ktor.server.application.* import io.ktor.server.http.content.* @@ -13,6 +16,8 @@ import io.ktor.server.sessions.* fun Application.webRoutes( budgetService: BudgetService, + categoryService: CategoryService, + transactionService: TransactionService, userService: UserService ) { routing { @@ -30,5 +35,6 @@ fun Application.webRoutes( } ?: call.respond(MustacheContent("index.mustache", null)) } } + budgetWebRoutes(budgetService, categoryService, transactionService, userService) userWebRoutes(userService) } \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt new file mode 100644 index 0000000..4609fdc --- /dev/null +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt @@ -0,0 +1,41 @@ +package com.wbrawner.twigs.web.budget + +import com.wbrawner.twigs.service.budget.BudgetResponse +import com.wbrawner.twigs.service.category.CategoryResponse +import com.wbrawner.twigs.service.transaction.BalanceResponse +import com.wbrawner.twigs.service.user.UserResponse +import com.wbrawner.twigs.web.AuthenticatedPage + +data class BudgetListPage( + val budgets: List, + override val user: UserResponse, + override val error: String? = null +) : AuthenticatedPage { + override val title: String = "Budgets" +} + +data class BudgetDetailsPage( + val budget: BudgetResponse, + val balance: BalanceResponse, + val categories: List, + val archivedCategories: List, + val transactionCount: Long, + override val user: UserResponse, + override val error: String? = null +) : AuthenticatedPage { + override val title: String = "Budgets" + + data class CategoryWithBalanceResponse(val category: CategoryResponse, val balance: BalanceResponse) +} + +data class BudgetFormPage( + val budget: BudgetResponse, + override val user: UserResponse, + override val error: String? = null +) : AuthenticatedPage { + override val title: String = if (budget.id.isBlank()) { + "New Budget" + } else { + "Edit Budget" + } +} \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt new file mode 100644 index 0000000..50a8fa2 --- /dev/null +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt @@ -0,0 +1,145 @@ +package com.wbrawner.twigs.web.budget + +import com.wbrawner.twigs.endOfMonth +import com.wbrawner.twigs.firstOfMonth +import com.wbrawner.twigs.service.HttpException +import com.wbrawner.twigs.service.budget.BudgetRequest +import com.wbrawner.twigs.service.budget.BudgetResponse +import com.wbrawner.twigs.service.budget.BudgetService +import com.wbrawner.twigs.service.category.CategoryService +import com.wbrawner.twigs.service.requireSession +import com.wbrawner.twigs.service.transaction.BalanceResponse +import com.wbrawner.twigs.service.transaction.TransactionService +import com.wbrawner.twigs.service.user.UserService +import com.wbrawner.twigs.toInstantOrNull +import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.mustache.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.util.* + +fun Application.budgetWebRoutes( + budgetService: BudgetService, + categoryService: CategoryService, + transactionService: TransactionService, + userService: UserService +) { + routing { + authenticate(TWIGS_SESSION_COOKIE) { + route("/budgets") { + get { + val user = userService.user(requireSession().userId) + val budgets = budgetService.budgetsForUser(user.id) + call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user))) + } + + route("/new") { + get { + val user = userService.user(requireSession().userId) + call.respond( + MustacheContent( + "budget-form.mustache", + BudgetFormPage( + BudgetResponse( + id = "", + name = "", + description = "", + users = listOf() + ), + user + ) + ) + ) + } + + post { + val user = userService.user(requireSession().userId) + try { + val request = call.receiveParameters().toBudgetRequest() + val budget = budgetService.save(request, user.id) + call.respondRedirect("/budgets/${budget.id}") + } catch (e: HttpException) { + call.respond( + status = e.statusCode, + MustacheContent( + "budget-form.mustache", + BudgetFormPage( + BudgetResponse( + id = "", + name = call.parameters["name"].orEmpty(), + description = call.parameters["description"].orEmpty(), + users = listOf() + ), + user, + e.message + ) + ) + ) + } + } + } + + route("/{id}") { + + get { + val user = userService.user(requireSession().userId) + val budget = budgetService.budget(budgetId = call.parameters.getOrFail("id"), userId = user.id) + val balance = BalanceResponse(transactionService.sum(budgetId = budget.id, userId = user.id)) + val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id) + .map { category -> + BudgetDetailsPage.CategoryWithBalanceResponse( + category, + BalanceResponse(transactionService.sum(categoryId = category.id, userId = user.id)) + ) + } + // TODO: Add a count method so we don't have to do this + val transactions = transactionService.transactions( + budgetIds = listOf(budget.id), + from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth, + to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth, + userId = user.id + ) + call.respond( + MustacheContent( + "budget-details.mustache", BudgetDetailsPage( + budget = budget, + balance = balance, + categories = categories.filter { !it.category.archived }, + archivedCategories = categories.filter { it.category.archived }, + transactionCount = transactions.size.toLong(), + user = user + ) + ) + ) + } + + route("/edit") { + get { + val user = userService.user(requireSession().userId) + val budget = budgetService.budget( + budgetId = call.parameters.getOrFail("id"), + userId = user.id + ) + call.respond( + MustacheContent( + "budget-form.mustache", + BudgetFormPage(budget, user) + ) + ) + } + } + } + } + } + } +} + +private fun Parameters.toBudgetRequest() = BudgetRequest( + name = get("name"), + description = get("description"), + users = setOf() // TODO: Enable adding users at budget creation +) diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/user/UserPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/user/UserPages.kt index 7af1121..b71a400 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/user/UserPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/user/UserPages.kt @@ -2,6 +2,10 @@ package com.wbrawner.twigs.web.user import com.wbrawner.twigs.web.Page -data class LoginPage(val username: String = "", val error: String? = null) : Page("Login") +data class LoginPage(val username: String = "", override val error: String? = null) : Page { + override val title: String = "Login" +} -data class RegisterPage(val username: String = "", val email: String = "", val error: String? = null) : Page("Register") \ No newline at end of file +data class RegisterPage(val username: String = "", val email: String = "", override val error: String? = null) : Page { + override val title: String = "Register" +} \ No newline at end of file diff --git a/web/src/main/resources/templates/budget-details.mustache b/web/src/main/resources/templates/budget-details.mustache new file mode 100644 index 0000000..86dac7d --- /dev/null +++ b/web/src/main/resources/templates/budget-details.mustache @@ -0,0 +1,14 @@ + + +{{> partials/head }} + +
+
+
+ {{>partials/budget-list}} +
+
+
+{{>partials/foot}} + + \ No newline at end of file diff --git a/web/src/main/resources/templates/budget-form.mustache b/web/src/main/resources/templates/budget-form.mustache new file mode 100644 index 0000000..67c2056 --- /dev/null +++ b/web/src/main/resources/templates/budget-form.mustache @@ -0,0 +1,22 @@ + + +{{> partials/head }} + +
+

{{title}}

+
+ {{#error }} +

{{error}}

+ {{/error}} +
+ + + + + +
+
+
+{{>partials/foot}} + + \ No newline at end of file diff --git a/web/src/main/resources/templates/budgets.mustache b/web/src/main/resources/templates/budgets.mustache new file mode 100644 index 0000000..86dac7d --- /dev/null +++ b/web/src/main/resources/templates/budgets.mustache @@ -0,0 +1,14 @@ + + +{{> partials/head }} + +
+
+
+ {{>partials/budget-list}} +
+
+
+{{>partials/foot}} + + \ No newline at end of file diff --git a/web/src/main/resources/templates/login.mustache b/web/src/main/resources/templates/login.mustache index c8f83a6..db92e57 100644 --- a/web/src/main/resources/templates/login.mustache +++ b/web/src/main/resources/templates/login.mustache @@ -3,6 +3,7 @@ {{> partials/head }}
+

{{title}}

{{#error }} diff --git a/web/src/main/resources/templates/partials/budget-list.mustache b/web/src/main/resources/templates/partials/budget-list.mustache new file mode 100644 index 0000000..3a749f2 --- /dev/null +++ b/web/src/main/resources/templates/partials/budget-list.mustache @@ -0,0 +1,13 @@ +
    + {{#budgets}} +
  • + {{name}} + {{description}} +
  • + {{/budgets}} +
+{{^budgets}} +

Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the + foundation for planning and growing your finances. To get started with Twigs, create a + new budget now!

+{{/budgets}} diff --git a/web/src/main/resources/templates/register.mustache b/web/src/main/resources/templates/register.mustache index 262f261..1b62416 100644 --- a/web/src/main/resources/templates/register.mustache +++ b/web/src/main/resources/templates/register.mustache @@ -3,6 +3,7 @@ {{> partials/head }}
+

{{title}}

{{#error }}