Add some budget pages

I also added titles to the other pages, but it needs styling
This commit is contained in:
William Brawner 2024-03-30 11:33:29 -06:00
parent a2579dbd6d
commit 7e49dbeb31
14 changed files with 294 additions and 18 deletions

View file

@ -178,6 +178,12 @@ fun Application.moduleWithDependencies(
}
validate(sessionValidator)
}
session<CookieSession>(TWIGS_SESSION_COOKIE) {
challenge {
call.respond(HttpStatusCode.Unauthorized)
}
validate(sessionValidator)
}
}
install(Sessions) {
header<Session>("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() }

View file

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

View file

@ -17,21 +17,21 @@ import java.time.Instant
interface TransactionService {
suspend fun transactions(
budgetIds: List<String>,
categoryIds: List<String>?,
from: Instant?,
to: Instant?,
expense: Boolean?,
categoryIds: List<String>? = null,
from: Instant? = null,
to: Instant? = null,
expense: Boolean? = null,
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,
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")

View file

@ -1,3 +1,12 @@
package com.wbrawner.twigs.web
open class Page(val title: String)
import com.wbrawner.twigs.service.user.UserResponse
interface Page {
val title: String
val error: String?
}
interface AuthenticatedPage : Page {
val user: UserResponse
}

View file

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

View file

@ -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<BudgetResponse>,
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<CategoryWithBalanceResponse>,
val archivedCategories: List<CategoryWithBalanceResponse>,
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"
}
}

View file

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

View file

@ -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")
data class RegisterPage(val username: String = "", val email: String = "", override val error: String? = null) : Page {
override val title: String = "Register"
}

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<div class="center">
<div class="flex-full-width">
{{>partials/budget-list}}
</div>
</div>
</div>
{{>partials/foot}}
</body>
</html>

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<h1>{{title}}</h1>
<div class="center">
{{#error }}
<p class="error">{{error}}</p>
{{/error}}
<form method="post">
<label for="name">Name</label>
<input id="name" type="text" name="name" value="{{ budget.name }}"/>
<label for="description">Description</label>
<textarea id="description" name="description">{{ budget.description }}</textarea>
<input id="submit" type="submit" class="button button-primary" value="Save"/>
</form>
</div>
</div>
{{>partials/foot}}
</body>
</html>

View file

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
{{> partials/head }}
<body>
<div id="app">
<div class="center">
<div class="flex-full-width">
{{>partials/budget-list}}
</div>
</div>
</div>
{{>partials/foot}}
</body>
</html>

View file

@ -3,6 +3,7 @@
{{> partials/head }}
<body>
<div id="app">
<h1>{{title}}</h1>
<div class="center">
<div class="logo"></div>
{{#error }}

View file

@ -0,0 +1,13 @@
<ul>
{{#budgets}}
<li>
<span class="budget-name">{{name}}</span>
<span class="budget-description">{{description}}</span>
</li>
{{/budgets}}
</ul>
{{^budgets}}
<p style="text-align: center;">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, <a href="/budgets/new">create a
new budget</a> now!</p>
{{/budgets}}

View file

@ -3,6 +3,7 @@
{{> partials/head }}
<body>
<div id="app">
<h1>{{title}}</h1>
<div class="center">
<div class="logo"></div>
{{#error }}