Compare commits
90 commits
pr-workflo
...
main
Author | SHA1 | Date | |
---|---|---|---|
ff03e8331d | |||
41748200cc | |||
67944d6017 | |||
9537d8b6bc | |||
8d68d54a31 | |||
1e33bedfff | |||
6020fd647f | |||
a9165e9867 | |||
5918e351c2 | |||
c4fb0b3391 | |||
00afb3f4bf | |||
374b2a79b0 | |||
7175036135 | |||
5ae22d6d36 | |||
d04dfa1bd5 | |||
a5039c27b1 | |||
a5600fee0a | |||
ea188430d0 | |||
cbfe0b2131 | |||
58bcd48ea7 | |||
9843046db7 | |||
a737f40398 | |||
84ad21011a | |||
3bb586bf14 | |||
b58cbfc388 | |||
92bb5a4827 | |||
287576f3c2 | |||
14861078c0 | |||
6ab4f20137 | |||
4f2c1f067a | |||
134814c515 | |||
b9e212c2c0 | |||
3434bf099c | |||
5aef4630a6 | |||
d13b784826 | |||
3d5bf4af1f | |||
d1ef0bfb1c | |||
fbc936e2bb | |||
b761256862 | |||
02b60757bb | |||
0ecc077b9d | |||
2d3c987be0 | |||
4b5827b315 | |||
6c96c6f2c2 | |||
0b26bf9240 | |||
a66c934d79 | |||
d5a449c719 | |||
d1d3814839 | |||
d062fa056e | |||
21cc7ff0ce | |||
d391c60f91 | |||
|
2dc415553d | ||
|
960723c6e4 | ||
|
cb8e9ecf7a | ||
|
426a522683 | ||
|
b6dc6693e2 | ||
|
488193bba8 | ||
|
96f563af56 | ||
c60eff2cbb | |||
e92ffd376a | |||
22061e3f4a | |||
955ee0f126 | |||
197fee1033 | |||
f1dc6bbca3 | |||
c4fea5a06f | |||
b1267dd4cf | |||
d8b89bc8d2 | |||
689dbc39e2 | |||
ef1deaf19b | |||
59aa48817a | |||
64e7cb9d52 | |||
5a9b988c63 | |||
961c60a183 | |||
fed06cd155 | |||
e8d3719adc | |||
978de59d36 | |||
7e49dbeb31 | |||
a2579dbd6d | |||
7a8c9c1035 | |||
1e14057dfa | |||
|
040af1ba36 | ||
|
3c4fc35daa | ||
|
e537b1b32a | ||
|
9877d9288b | ||
dfe81b7a2f | |||
76237b331a | |||
5e8337da29 | |||
9a01fb39ec | |||
a460421497 | |||
2b66ea916b |
47
.forgejo/workflows/docker-image.yml
Normal file
|
@ -0,0 +1,47 @@
|
|||
name: Publish Docker image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Forgejo Packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
git.wbrawner.com/wbrawner/twigs
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Set up Docker context
|
||||
run: docker context create builders
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.wbrawner.com
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.FORGEJO_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
26
.forgejo/workflows/pull-request.yml
Normal file
|
@ -0,0 +1,26 @@
|
|||
name: Pull request workflow
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: https://git.wbrawner.com/actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: https://git.wbrawner.com/gradle/actions/wrapper-validation@v3
|
||||
- name: Build with Gradle
|
||||
uses: https://git.wbrawner.com/gradle/actions/setup-gradle@v3
|
||||
with:
|
||||
arguments: --stacktrace check
|
||||
- name: Publish JUnit Results
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: Unit Test Results
|
||||
path: "*/build/test-results/test/*.xml"
|
||||
if-no-files-found: error
|
6
.github/dependabot.yml
vendored
|
@ -1,6 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gradle"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
12
.github/workflows/docker-image.yml
vendored
|
@ -10,10 +10,10 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/wbrawner/twigs
|
||||
|
@ -26,17 +26,17 @@ jobs:
|
|||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
|
21
.github/workflows/pull-request.yml
vendored
|
@ -2,8 +2,7 @@ name: Pull request workflow
|
|||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
|
@ -11,15 +10,15 @@ jobs:
|
|||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-java@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@v1
|
||||
uses: gradle/wrapper-validation-action@v3
|
||||
- name: Build with Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
arguments: --stacktrace build
|
||||
- name: Publish JUnit Results
|
||||
|
@ -30,13 +29,3 @@ jobs:
|
|||
path: "*/build/test-results/test/*.xml"
|
||||
reporter: java-junit
|
||||
fail-on-error: true
|
||||
automerge:
|
||||
name: Enable automerge
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'wbrawner' || github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- name: Enable auto-merge
|
||||
run: gh pr merge --auto --rebase "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GITHUB_TOKEN: ${{secrets.GH_TOKEN}}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
FROM openjdk:17-jdk as builder
|
||||
FROM ibm-semeru-runtimes:open-21-jdk as builder
|
||||
MAINTAINER William Brawner <me@wbrawner.com>
|
||||
|
||||
RUN groupadd --system --gid 1000 gradle \
|
||||
|
@ -8,10 +8,10 @@ COPY --chown=gradle:gradle . /home/gradle/src
|
|||
WORKDIR /home/gradle/src
|
||||
RUN /home/gradle/src/gradlew --console=plain --no-daemon shadowJar
|
||||
|
||||
FROM openjdk:17-slim
|
||||
FROM ibm-semeru-runtimes:open-21-jre
|
||||
EXPOSE 8080
|
||||
RUN groupadd --system --gid 1000 twigs \
|
||||
&& useradd --system --gid twigs --uid 1000 --create-home twigs
|
||||
COPY --from=builder --chown=twigs:twigs /home/gradle/src/app/build/libs/twigs.jar twigs.jar
|
||||
USER twigs
|
||||
CMD /usr/local/openjdk-17/bin/java $JVM_ARGS -jar /twigs.jar
|
||||
CMD /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs.jar
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
# Twigs Server
|
||||
# Twigs
|
||||
|
||||
This is the backend application that powers the [Android](../../../twigs-android), [iOS](../../../twigs-ios),
|
||||
and [web](../../../twigs-web) applications for Twigs, a personal finance/budgeting app. None of these apps are complete,
|
||||
so expect bugs, and they are all in various stages of development, so expect some feature disparity between platforms.
|
||||
Twigs is a personal finance application tailored to individuals and small groups that want robust budgeting features
|
||||
without needing to pay a monthly subscription.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- JDK 14 or newer
|
||||
- JDK 17 or newer
|
||||
- PostgreSQL 13 or newer
|
||||
- (optional) Docker
|
||||
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
`java-library`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(project(":core"))
|
||||
implementation(project(":service"))
|
||||
implementation(project(":storage"))
|
||||
api(libs.ktor.server.core)
|
||||
api(libs.ktor.serialization)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
testImplementation(project(":testhelpers"))
|
||||
}
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.Session
|
||||
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 inline fun PipelineContext<Unit, ApplicationCall>.requireBudgetWithPermission(
|
||||
permissionRepository: PermissionRepository,
|
||||
userId: String,
|
||||
budgetId: String?,
|
||||
permission: Permission,
|
||||
otherwise: () -> Unit
|
||||
) {
|
||||
if (budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "budgetId is required")
|
||||
return
|
||||
}
|
||||
permissionRepository.findAll(
|
||||
userId = userId,
|
||||
budgetIds = listOf(budgetId)
|
||||
).firstOrNull {
|
||||
it.permission.isAtLeast(permission)
|
||||
} ?: run {
|
||||
errorResponse(HttpStatusCode.Forbidden, "Insufficient permissions on budget $budgetId")
|
||||
otherwise()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PipelineContext<Unit, ApplicationCall>.budgetWithPermission(
|
||||
budgetRepository: BudgetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
budgetId: String,
|
||||
permission: Permission,
|
||||
block: suspend (Budget) -> Unit
|
||||
) {
|
||||
val session = call.principal<Session>()!!
|
||||
val userPermission = permissionRepository.findAll(
|
||||
userId = session.userId,
|
||||
budgetIds = listOf(budgetId)
|
||||
).firstOrNull()
|
||||
if (userPermission?.permission?.isAtLeast(permission) != true) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return
|
||||
}
|
||||
block(budgetRepository.findAll(ids = listOf(budgetId)).first())
|
||||
}
|
||||
|
||||
suspend inline fun PipelineContext<Unit, ApplicationCall>.errorResponse(
|
||||
httpStatusCode: HttpStatusCode = HttpStatusCode.NotFound,
|
||||
message: String? = null
|
||||
) {
|
||||
message?.let {
|
||||
call.respond(httpStatusCode, ErrorResponse(message))
|
||||
} ?: call.respond(httpStatusCode)
|
||||
}
|
|
@ -1,122 +1,57 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.storage.BudgetRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
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(
|
||||
budgetRepository: BudgetRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
fun Application.budgetRoutes(budgetService: BudgetService) {
|
||||
routing {
|
||||
route("/api/budgets") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<BudgetResponse>())
|
||||
return@get
|
||||
call.respondCatching {
|
||||
budgetService.budgetsForUser(userId = requireSession().userId)
|
||||
}
|
||||
val budgets = budgetRepository.findAll(ids = budgetIds).map {
|
||||
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
|
||||
}
|
||||
call.respond(budgets)
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
budgetWithPermission(
|
||||
budgetRepository,
|
||||
permissionRepository,
|
||||
call.parameters["id"]!!,
|
||||
Permission.READ
|
||||
) { budget ->
|
||||
val users = permissionRepository.findAll(budgetIds = listOf(budget.id))
|
||||
call.respond(BudgetResponse(budget, users))
|
||||
call.respondCatching {
|
||||
budgetService.budget(
|
||||
budgetId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<BudgetRequest>()
|
||||
if (request.name.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Name cannot be empty or null")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
budgetService.save(request = call.receive(), userId = requireSession().userId)
|
||||
}
|
||||
val budget = budgetRepository.save(
|
||||
Budget(
|
||||
name = request.name,
|
||||
description = request.description
|
||||
)
|
||||
)
|
||||
val users = request.users?.map {
|
||||
permissionRepository.save(
|
||||
UserPermission(
|
||||
budgetId = budget.id,
|
||||
userId = it.user,
|
||||
permission = it.permission
|
||||
)
|
||||
)
|
||||
}?.toMutableSet() ?: mutableSetOf()
|
||||
if (users.none { it.userId == session.userId }) {
|
||||
users.add(
|
||||
permissionRepository.save(
|
||||
UserPermission(
|
||||
budgetId = budget.id,
|
||||
userId = session.userId,
|
||||
permission = Permission.OWNER
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
call.respond(BudgetResponse(budget, users))
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
budgetWithPermission(
|
||||
budgetRepository,
|
||||
permissionRepository,
|
||||
call.parameters["id"]!!,
|
||||
Permission.MANAGE
|
||||
) { budget ->
|
||||
val request = call.receive<BudgetRequest>()
|
||||
val name = request.name ?: budget.name
|
||||
val description = request.description ?: budget.description
|
||||
val users = request.users?.map {
|
||||
permissionRepository.save(UserPermission(budget.id, it.user, it.permission))
|
||||
} ?: permissionRepository.findAll(budgetIds = listOf(budget.id))
|
||||
permissionRepository.findAll(budgetIds = listOf(budget.id)).forEach {
|
||||
if (it.permission != Permission.OWNER && users.none { userPermission -> userPermission.userId == it.userId }) {
|
||||
permissionRepository.delete(it)
|
||||
}
|
||||
}
|
||||
call.respond(
|
||||
BudgetResponse(
|
||||
budgetRepository.save(budget.copy(name = name, description = description)),
|
||||
users
|
||||
)
|
||||
call.respondCatching {
|
||||
budgetService.save(
|
||||
request = call.receive(),
|
||||
userId = requireSession().userId,
|
||||
budgetId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
budgetWithPermission(
|
||||
budgetRepository,
|
||||
permissionRepository,
|
||||
budgetId = call.parameters["id"]!!,
|
||||
Permission.OWNER
|
||||
) { budget ->
|
||||
budgetRepository.delete(budget)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
call.respondCatching {
|
||||
budgetService.delete(
|
||||
budgetId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,139 +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.storage.CategoryRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.service.category.CategoryRequest
|
||||
import com.wbrawner.twigs.service.category.CategoryService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.categoryRoutes(
|
||||
categoryRepository: CategoryRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
fun Application.categoryRoutes(categoryService: CategoryService) {
|
||||
routing {
|
||||
route("/api/categories") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<CategoryResponse>())
|
||||
return@get
|
||||
call.respondCatching {
|
||||
categoryService.categories(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||
userId = requireSession().userId,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
)
|
||||
}
|
||||
call.respond(categoryRepository.findAll(
|
||||
budgetIds = budgetIds,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
).map { it.asResponse() })
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
errorResponse()
|
||||
return@get
|
||||
call.respondCatching {
|
||||
categoryService.category(
|
||||
categoryId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
categoryRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = budgetIds
|
||||
)
|
||||
.map { it.asResponse() }
|
||||
.firstOrNull()?.let {
|
||||
call.respond(it)
|
||||
} ?: errorResponse()
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
categoryService.save(call.receive<CategoryRequest>(), requireSession().userId)
|
||||
}
|
||||
if (request.budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
categoryRepository.save(
|
||||
Category(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
call.respondCatching {
|
||||
categoryService.save(
|
||||
request = call.receive<CategoryRequest>(),
|
||||
userId = requireSession().userId,
|
||||
categoryId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
call.respond(
|
||||
categoryRepository.save(
|
||||
category.copy(
|
||||
title = request.title ?: category.title,
|
||||
description = request.description ?: category.description,
|
||||
amount = request.amount ?: category.amount,
|
||||
expense = request.expense ?: category.expense,
|
||||
archived = request.archived ?: category.archived,
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val categoryId = call.parameters.entries().first().value
|
||||
val category = categoryRepository.findAll(ids = categoryId)
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.NotFound)
|
||||
return@delete
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@delete
|
||||
call.respondCatching {
|
||||
categoryService.delete(
|
||||
call.parameters.getOrFail("id"),
|
||||
requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
categoryRepository.delete(category)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(val message: String)
|
|
@ -1,158 +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.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionRequest
|
||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.util.pipeline.*
|
||||
import java.time.Instant
|
||||
|
||||
fun Application.recurringTransactionRoutes(
|
||||
recurringTransactionRepository: RecurringTransactionRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
suspend fun PipelineContext<Unit, ApplicationCall>.recurringTransactionAfterPermissionCheck(
|
||||
id: String?,
|
||||
userId: String,
|
||||
success: suspend (RecurringTransaction) -> Unit
|
||||
) {
|
||||
if (id.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "id is required")
|
||||
return
|
||||
}
|
||||
val recurringTransaction = recurringTransactionRepository.findAll(ids = listOf(id)).firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
userId,
|
||||
recurringTransaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
application.log.info("No permissions on budget ${recurringTransaction.budgetId}.")
|
||||
return
|
||||
}
|
||||
success(recurringTransaction)
|
||||
}
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.recurringTransactionRoutes(recurringTransactionService: RecurringTransactionService) {
|
||||
routing {
|
||||
route("/api/recurringtransactions") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetId = call.request.queryParameters["budgetId"]
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@get
|
||||
}
|
||||
call.respond(
|
||||
recurringTransactionRepository.findAll(
|
||||
budgetId = budgetId!!
|
||||
).map { it.asResponse() }
|
||||
)
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
|
||||
call.respond(it.asResponse())
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<RecurringTransactionRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
if (request.budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId,
|
||||
categoryId = request.categoryId,
|
||||
createdBy = session.userId,
|
||||
start = request.start?.toInstant() ?: Instant.now(),
|
||||
finish = request.finish?.toInstant(),
|
||||
frequency = request.frequency.asFrequency()
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<RecurringTransactionRequest>()
|
||||
recurringTransactionAfterPermissionCheck(
|
||||
call.parameters["id"]!!,
|
||||
session.userId
|
||||
) { recurringTransaction ->
|
||||
if (request.budgetId != recurringTransaction.budgetId) {
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@recurringTransactionAfterPermissionCheck
|
||||
}
|
||||
}
|
||||
call.respond(
|
||||
recurringTransactionRepository.save(
|
||||
recurringTransaction.copy(
|
||||
title = request.title ?: recurringTransaction.title,
|
||||
description = request.description ?: recurringTransaction.description,
|
||||
amount = request.amount ?: recurringTransaction.amount,
|
||||
expense = request.expense ?: recurringTransaction.expense,
|
||||
categoryId = request.categoryId ?: recurringTransaction.categoryId,
|
||||
budgetId = request.budgetId ?: recurringTransaction.budgetId,
|
||||
start = request.start?.toInstant() ?: recurringTransaction.start,
|
||||
finish = request.finish?.toInstant() ?: recurringTransaction.finish,
|
||||
frequency = request.frequency.asFrequency()
|
||||
)
|
||||
).asResponse()
|
||||
call.respondCatching {
|
||||
recurringTransactionService.recurringTransactions(
|
||||
budgetId = call.request.queryParameters.getOrFail("budgetId"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
call.respondCatching {
|
||||
recurringTransactionService.recurringTransaction(
|
||||
call.parameters.getOrFail("id"),
|
||||
requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
call.respondCatching {
|
||||
recurringTransactionService.save(
|
||||
request = call.receive<RecurringTransactionRequest>(),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
recurringTransactionService.save(
|
||||
request = call.receive<RecurringTransactionRequest>(),
|
||||
userId = requireSession().userId,
|
||||
recurringTransactionId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
recurringTransactionAfterPermissionCheck(call.parameters["id"]!!, session.userId) {
|
||||
val response = if (recurringTransactionRepository.delete(it)) {
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
call.respondCatching {
|
||||
recurringTransactionService.delete(call.parameters.getOrFail("id"), requireSession().userId)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,169 +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.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
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.service.transaction.TransactionService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import java.time.Instant
|
||||
|
||||
fun Application.transactionRoutes(
|
||||
transactionRepository: TransactionRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
fun Application.transactionRoutes(transactionService: TransactionService) {
|
||||
routing {
|
||||
route("/api/transactions") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
call.respond(
|
||||
transactionRepository.findAll(
|
||||
budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId },
|
||||
call.respondCatching {
|
||||
transactionService.transactions(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds").orEmpty(),
|
||||
categoryIds = call.request.queryParameters.getAll("categoryIds"),
|
||||
from = call.request.queryParameters["from"]?.let { Instant.parse(it) },
|
||||
to = call.request.queryParameters["to"]?.let { Instant.parse(it) },
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
).map { it.asResponse() })
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val transaction = transactionRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = permissionRepository.findAll(
|
||||
userId = session.userId
|
||||
call.respondCatching {
|
||||
transactionService.transaction(
|
||||
transactionId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
.map { it.budgetId }
|
||||
)
|
||||
.map { it.asResponse() }
|
||||
.firstOrNull()
|
||||
transaction?.let {
|
||||
call.respond(it)
|
||||
} ?: errorResponse()
|
||||
}
|
||||
}
|
||||
|
||||
get("/sum") {
|
||||
val categoryId = call.request.queryParameters["categoryId"]
|
||||
val budgetId = call.request.queryParameters["budgetId"]
|
||||
val from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth
|
||||
val to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth
|
||||
val balance = if (!categoryId.isNullOrBlank()) {
|
||||
if (!budgetId.isNullOrBlank()) {
|
||||
errorResponse(
|
||||
HttpStatusCode.BadRequest,
|
||||
"budgetId and categoryId cannot be provided together"
|
||||
call.respondCatching {
|
||||
BalanceResponse(
|
||||
transactionService.sum(
|
||||
budgetId = call.request.queryParameters["budgetId"],
|
||||
categoryId = call.request.queryParameters["categoryId"],
|
||||
from = call.request.queryParameters["from"]?.toInstant() ?: firstOfMonth,
|
||||
to = call.request.queryParameters["to"]?.toInstant() ?: endOfMonth,
|
||||
userId = requireSession().userId,
|
||||
)
|
||||
return@get
|
||||
}
|
||||
transactionRepository.sumByCategory(categoryId, from, to)
|
||||
} else if (!budgetId.isNullOrBlank()) {
|
||||
transactionRepository.sumByBudget(budgetId, from, to)
|
||||
} else {
|
||||
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
|
||||
return@get
|
||||
)
|
||||
}
|
||||
call.respond(BalanceResponse(balance))
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
transactionService.save(
|
||||
request = call.receive<TransactionRequest>(),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
}
|
||||
if (request.budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
transactionRepository.save(
|
||||
Transaction(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId,
|
||||
categoryId = request.categoryId,
|
||||
createdBy = session.userId,
|
||||
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
transaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
call.respondCatching {
|
||||
transactionService.save(
|
||||
request = call.receive<TransactionRequest>(),
|
||||
userId = requireSession().userId,
|
||||
transactionId = call.parameters.getOrFail("id")
|
||||
)
|
||||
}
|
||||
call.respond(
|
||||
transactionRepository.save(
|
||||
transaction.copy(
|
||||
title = request.title ?: transaction.title,
|
||||
description = request.description ?: transaction.description,
|
||||
amount = request.amount ?: transaction.amount,
|
||||
expense = request.expense ?: transaction.expense,
|
||||
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
|
||||
categoryId = request.categoryId ?: transaction.categoryId,
|
||||
budgetId = request.budgetId ?: transaction.budgetId,
|
||||
createdBy = transaction.createdBy,
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return@delete
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
transaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@delete
|
||||
}
|
||||
val response = if (transactionRepository.delete(transaction)) {
|
||||
call.respondCatching {
|
||||
transactionService.delete(
|
||||
transactionId = call.parameters.getOrFail("id"),
|
||||
userId = requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,190 +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.storage.*
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.respondCatching
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import java.time.Instant
|
||||
import io.ktor.server.util.*
|
||||
|
||||
fun Application.userRoutes(
|
||||
emailService: EmailService,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
userRepository: UserRepository,
|
||||
passwordHasher: PasswordHasher
|
||||
) {
|
||||
fun Application.userRoutes(userService: UserService) {
|
||||
routing {
|
||||
route("/api/users") {
|
||||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
val user =
|
||||
userRepository.findAll(
|
||||
nameOrEmail = request.username,
|
||||
password = passwordHasher.hash(request.password)
|
||||
)
|
||||
.firstOrNull()
|
||||
?: userRepository.findAll(
|
||||
nameOrEmail = request.username,
|
||||
password = passwordHasher.hash(request.password)
|
||||
)
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
||||
return@post
|
||||
}
|
||||
val session = sessionRepository.save(Session(userId = user.id))
|
||||
call.respond(session.asResponse())
|
||||
call.respondCatching {
|
||||
userService.login(call.receive())
|
||||
}
|
||||
}
|
||||
|
||||
post("/register") {
|
||||
val request = call.receive<UserRequest>()
|
||||
if (request.username.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||
return@post
|
||||
call.respondCatching {
|
||||
userService.register(call.receive())
|
||||
}
|
||||
if (request.password.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
|
||||
?: request.email?.let {
|
||||
return@let if (it.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
userRepository.findAll(nameOrEmail = it).firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
route("/resetpassword") {
|
||||
post {
|
||||
call.respondCatching {
|
||||
userService.requestPasswordResetEmail(call.receive())
|
||||
HttpStatusCode.Accepted
|
||||
}
|
||||
}
|
||||
|
||||
put {
|
||||
call.respondCatching {
|
||||
userService.resetPassword(call.receive())
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
existingUser?.let {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
userRepository.save(
|
||||
User(
|
||||
name = request.username,
|
||||
password = passwordHasher.hash(request.password),
|
||||
email = if (request.email.isNullOrBlank()) "" else request.email
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val query = call.request.queryParameters["query"]
|
||||
val budgetIds = call.request.queryParameters.getAll("budgetId")
|
||||
if (query != null) {
|
||||
if (query.isBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "query cannot be empty")
|
||||
}
|
||||
call.respond(userRepository.findAll(nameLike = query).map { it.asResponse() })
|
||||
return@get
|
||||
} else if (budgetIds == null || budgetIds.all { it.isBlank() }) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "query or budgetId required but absent")
|
||||
call.respondCatching {
|
||||
userService.users(
|
||||
query = call.request.queryParameters["query"],
|
||||
budgetIds = call.request.queryParameters.getAll("budgetId"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
}
|
||||
permissionRepository.findAll(budgetIds = budgetIds)
|
||||
.mapNotNull {
|
||||
userRepository.findAll(ids = listOf(it.userId))
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
}.run { call.respond(this) }
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
?.let { call.respond(it) }
|
||||
?: errorResponse(HttpStatusCode.NotFound)
|
||||
call.respondCatching {
|
||||
userService.user(call.parameters.getOrFail("id"))
|
||||
}
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<UserRequest>()
|
||||
// TODO: Add some kind of admin denotation to allow admins to edit other users
|
||||
if (call.parameters["id"] != session.userId) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return@put
|
||||
call.respondCatching {
|
||||
userService.save(
|
||||
request = call.receive(),
|
||||
targetUserId = call.parameters.getOrFail("id"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
}
|
||||
call.respond(
|
||||
userRepository.save(
|
||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.first()
|
||||
.run {
|
||||
copy(
|
||||
name = request.username ?: name,
|
||||
password = request.password?.let { passwordHasher.hash(it) } ?: password,
|
||||
email = request.email ?: email
|
||||
)
|
||||
}
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
// TODO: Add some kind of admin denotation to allow admins to delete other users
|
||||
val user = userRepository.findAll(call.parameters.entries().first().value).firstOrNull()
|
||||
if (user == null) {
|
||||
errorResponse()
|
||||
return@delete
|
||||
call.respondCatching {
|
||||
userService.delete(
|
||||
targetUserId = call.parameters.getOrFail("id"),
|
||||
requestingUserId = requireSession().userId
|
||||
)
|
||||
HttpStatusCode.NoContent
|
||||
}
|
||||
if (user.id != session.userId) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return@delete
|
||||
}
|
||||
userRepository.delete(user)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/api/resetpassword") {
|
||||
post {
|
||||
val request = call.receive<ResetPasswordRequest>()
|
||||
userRepository.findAll(nameOrEmail = request.username)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
val email = it.email
|
||||
val passwordResetToken = passwordResetRepository.save(PasswordResetToken(userId = it.id))
|
||||
emailService.sendPasswordResetEmail(passwordResetToken, email)
|
||||
}
|
||||
call.respond(HttpStatusCode.Accepted)
|
||||
}
|
||||
}
|
||||
|
||||
route("/api/passwordreset") {
|
||||
post {
|
||||
val request = call.receive<PasswordResetRequest>()
|
||||
val passwordResetToken = passwordResetRepository.findAll(listOf(request.token))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid token")
|
||||
return@post
|
||||
}
|
||||
if (passwordResetToken.expiration.isBefore(Instant.now())) {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Token expired")
|
||||
return@post
|
||||
}
|
||||
userRepository.findAll(listOf(passwordResetToken.userId))
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
userRepository.save(it.copy(password = passwordHasher.hash(request.password)))
|
||||
passwordResetRepository.delete(passwordResetToken)
|
||||
}
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.InternalServerError, "Invalid token")
|
||||
return@post
|
||||
}
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ dependencies {
|
|||
implementation(libs.bcrypt)
|
||||
implementation(libs.logback)
|
||||
implementation(libs.mail)
|
||||
implementation(project(mapOf("path" to ":service")))
|
||||
testImplementation(project(":testhelpers"))
|
||||
testImplementation(libs.ktor.client.content.negotiation)
|
||||
testImplementation(libs.ktor.server.test)
|
||||
|
|
|
@ -1,28 +1,43 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import ch.qos.logback.classic.Level
|
||||
import com.github.mustachejava.DefaultMustacheFactory
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.db.*
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.model.HeaderSession
|
||||
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.user.TWIGS_SESSION_COOKIE
|
||||
import com.wbrawner.twigs.web.webRoutes
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import io.ktor.client.request.forms.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.auth.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.plugins.callloging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.plugins.forwardedheaders.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.sessions.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun main() {
|
||||
|
@ -56,7 +71,6 @@ fun Application.module() {
|
|||
throw RuntimeException("Unsupported DB type: $dbType")
|
||||
}
|
||||
}
|
||||
(LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.DEBUG
|
||||
HikariDataSource(HikariConfig().apply {
|
||||
setJdbcUrl(jdbcUrl)
|
||||
username = dbUser
|
||||
|
@ -81,78 +95,120 @@ 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.updateExpiration(newExpiration = twoWeeksFromNow))
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
moduleWithDependencies(
|
||||
emailService = SmtpEmailService(
|
||||
from = System.getenv("TWIGS_SMTP_FROM"),
|
||||
host = System.getenv("TWIGS_SMTP_HOST"),
|
||||
port = System.getenv("TWIGS_SMTP_PORT")?.toIntOrNull(),
|
||||
username = System.getenv("TWIGS_SMTP_USER"),
|
||||
password = System.getenv("TWIGS_SMTP_PASS"),
|
||||
budgetService = DefaultBudgetService(budgetRepository, permissionRepository),
|
||||
categoryService = DefaultCategoryService(categoryRepository, permissionRepository),
|
||||
recurringTransactionService = DefaultRecurringTransactionService(
|
||||
recurringTransactionRepository,
|
||||
permissionRepository
|
||||
),
|
||||
metadataRepository = JdbcMetadataRepository(it),
|
||||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
passwordResetRepository = JdbcPasswordResetRepository(it),
|
||||
passwordHasher = { password ->
|
||||
String(BCrypt.withDefaults().hash(10, metadata.salt.toByteArray(), password.toByteArray()))
|
||||
},
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
transactionRepository = JdbcTransactionRepository(it),
|
||||
userRepository = JdbcUserRepository(it)
|
||||
transactionService = DefaultTransactionService(
|
||||
transactionRepository,
|
||||
categoryRepository,
|
||||
permissionRepository
|
||||
),
|
||||
userService = DefaultUserService(
|
||||
emailService,
|
||||
passwordResetRepository,
|
||||
permissionRepository,
|
||||
sessionRepository,
|
||||
userRepository,
|
||||
passwordHasher
|
||||
),
|
||||
jobs = jobs,
|
||||
sessionValidator = sessionValidator
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.moduleWithDependencies(
|
||||
emailService: EmailService,
|
||||
metadataRepository: MetadataRepository,
|
||||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
passwordHasher: PasswordHasher,
|
||||
permissionRepository: PermissionRepository,
|
||||
recurringTransactionRepository: RecurringTransactionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
transactionRepository: TransactionRepository,
|
||||
userRepository: UserRepository
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
recurringTransactionService: RecurringTransactionService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService,
|
||||
jobs: List<Job>,
|
||||
sessionValidator: suspend ApplicationCall.(Session) -> Principal?
|
||||
) {
|
||||
install(XForwardedHeaders)
|
||||
install(CallLogging)
|
||||
install(Authentication) {
|
||||
session<Session> {
|
||||
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)
|
||||
}
|
||||
session<CookieSession>(TWIGS_SESSION_COOKIE) {
|
||||
challenge {
|
||||
call.respond(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
validate(sessionValidator)
|
||||
}
|
||||
}
|
||||
install(Sessions) {
|
||||
header<Session>("Authorization") {
|
||||
serializer = object : SessionSerializer<Session> {
|
||||
override fun deserialize(text: String): Session {
|
||||
override fun deserialize(text: String): HeaderSession {
|
||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
||||
return Session(token = text.substringAfter("Bearer "))
|
||||
return HeaderSession(token = text.substringAfter("Bearer "))
|
||||
}
|
||||
|
||||
override fun serialize(session: Session): String = session.token
|
||||
}
|
||||
}
|
||||
|
||||
cookie<CookieSession>(TWIGS_SESSION_COOKIE) {
|
||||
serializer = object : SessionSerializer<CookieSession> {
|
||||
override fun deserialize(text: String): CookieSession {
|
||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
||||
return CookieSession(token = text)
|
||||
}
|
||||
|
||||
override fun serialize(session: CookieSession): String = session.token
|
||||
}
|
||||
cookie.httpOnly = true
|
||||
cookie.secure = true
|
||||
cookie.extensions["SameSite"] = "Strict"
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(json = Json {
|
||||
|
@ -165,11 +221,15 @@ fun Application.moduleWithDependencies(
|
|||
prettyPrint = false
|
||||
useArrayPolymorphism = true
|
||||
})
|
||||
|
||||
formData()
|
||||
}
|
||||
install(CORS) {
|
||||
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 +252,16 @@ 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)
|
||||
webRoutes()
|
||||
install(Mustache) {
|
||||
mustacheFactory = DefaultMustacheFactory("templates")
|
||||
}
|
||||
budgetRoutes(budgetService)
|
||||
categoryRoutes(categoryService)
|
||||
recurringTransactionRoutes(recurringTransactionService)
|
||||
transactionRoutes(transactionService)
|
||||
userRoutes(userService)
|
||||
webRoutes(budgetService, categoryService, transactionService, userService)
|
||||
launch {
|
||||
val jobs = listOf(
|
||||
SessionCleanupJob(sessionRepository),
|
||||
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
delay(TimeUnit.HOURS.toMillis(1))
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
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.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
|
||||
open class ApiTest {
|
||||
|
@ -38,22 +44,37 @@ 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 {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
json(json = Json {
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
})
|
||||
}
|
||||
}
|
||||
test(client)
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.BudgetRequest
|
||||
import com.wbrawner.twigs.BudgetResponse
|
||||
import com.wbrawner.twigs.UserPermissionRequest
|
||||
import com.wbrawner.twigs.UserPermissionResponse
|
||||
import com.wbrawner.twigs.model.*
|
||||
import com.wbrawner.twigs.service.budget.BudgetRequest
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.user.UserPermissionRequest
|
||||
import com.wbrawner.twigs.service.user.UserPermissionResponse
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.http.*
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Disabled
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BudgetRouteTest : ApiTest() {
|
||||
|
@ -228,9 +227,8 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(expectedUsers, updatedUsers)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@Test
|
||||
fun `updating budgets returns not found for users with no access`() = apiTest { client ->
|
||||
fun `updating budgets returns forbidden for users with no access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
|
@ -254,7 +252,6 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@Test
|
||||
fun `updating non-existent budgets returns not found`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
|
@ -273,7 +270,6 @@ class BudgetRouteTest : ApiTest() {
|
|||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Disabled("Will be fixed with service layer refactor")
|
||||
@Test
|
||||
fun `updating budgets returns forbidden for users with manage access attempting to remove owner`() =
|
||||
apiTest { client ->
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.ErrorResponse
|
||||
import com.wbrawner.twigs.PasswordResetRequest
|
||||
import com.wbrawner.twigs.ResetPasswordRequest
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.randomString
|
||||
import com.wbrawner.twigs.service.ErrorResponse
|
||||
import com.wbrawner.twigs.service.user.PasswordResetRequest
|
||||
import com.wbrawner.twigs.service.user.ResetPasswordRequest
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
|
@ -18,7 +18,7 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
@Test
|
||||
fun `reset password with invalid username returns 202`() = apiTest { client ->
|
||||
val request = ResetPasswordRequest(username = "invaliduser")
|
||||
val response = client.post("/api/resetpassword") {
|
||||
val response = client.post("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
@Test
|
||||
fun `reset password with valid username returns 202`() = apiTest { client ->
|
||||
val request = ResetPasswordRequest(username = "testuser")
|
||||
val response = client.post("/api/resetpassword") {
|
||||
val response = client.post("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -43,9 +43,9 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `password reset with invalid token returns 400`() = apiTest { client ->
|
||||
fun `password reset with invalid token returns 401`() = apiTest { client ->
|
||||
val request = PasswordResetRequest(token = randomString(), password = "newpass")
|
||||
val response = client.post("/api/passwordreset") {
|
||||
val response = client.put("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -55,10 +55,10 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `password reset with expired token returns 400`() = apiTest { client ->
|
||||
fun `password reset with expired token returns 401`() = apiTest { client ->
|
||||
val token = passwordResetRepository.save(PasswordResetToken(expiration = twoWeeksAgo))
|
||||
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
||||
val response = client.post("/api/passwordreset") {
|
||||
val response = client.put("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
@ -68,10 +68,11 @@ class PasswordResetRouteTest : ApiTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `password reset with valid token returns 200`() = apiTest { client ->
|
||||
val token = passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
||||
fun `password reset with valid token returns 204`() = apiTest { client ->
|
||||
val token =
|
||||
passwordResetRepository.save(PasswordResetToken(userId = userRepository.findAll("testuser").first().id))
|
||||
val request = PasswordResetRequest(token = token.id, password = "newpass")
|
||||
val response = client.post("/api/passwordreset") {
|
||||
val response = client.put("/api/users/resetpassword") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.service.ErrorResponse
|
||||
import com.wbrawner.twigs.service.user.*
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.OTHER_USER
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeUserRepository.Companion.TEST_USER
|
||||
import io.ktor.client.call.*
|
||||
|
@ -76,7 +77,7 @@ class UserRouteTest : ApiTest() {
|
|||
|
||||
@Test
|
||||
fun `login with valid email and password returns 200`() = apiTest { client ->
|
||||
val request = LoginRequest(TEST_USER.email, TEST_USER.password)
|
||||
val request = LoginRequest(requireNotNull(TEST_USER.email), TEST_USER.password)
|
||||
val response = client.post("/api/users/login") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
|
@ -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
|
||||
|
@ -171,14 +172,14 @@ class UserRouteTest : ApiTest() {
|
|||
val userResponse = response.body<UserResponse>()
|
||||
assert(userResponse.id.isNotBlank())
|
||||
assertEquals(request.username, userResponse.username)
|
||||
assertEquals("", userResponse.email)
|
||||
assertEquals(null, userResponse.email)
|
||||
assertEquals(initialUserCount + 1, userRepository.entities.size)
|
||||
val savedUser: User? = userRepository.findAll("newuser").firstOrNull()
|
||||
assertNotNull(savedUser)
|
||||
requireNotNull(savedUser)
|
||||
assertEquals(userResponse.id, savedUser.id)
|
||||
assertEquals(request.username, savedUser.name)
|
||||
assertEquals("", savedUser.email)
|
||||
assertEquals(null, savedUser.email)
|
||||
assertEquals("newpass", savedUser.password)
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ buildscript {
|
|||
plugins {
|
||||
java
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
val javaVersion = JavaVersion.VERSION_17
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -6,9 +6,20 @@ import com.wbrawner.twigs.twoWeeksFromNow
|
|||
import io.ktor.server.auth.*
|
||||
import java.time.Instant
|
||||
|
||||
data class Session(
|
||||
open class Session(
|
||||
override val id: String = randomString(),
|
||||
val userId: String = "",
|
||||
val token: String = randomString(255),
|
||||
var expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable
|
||||
open val token: String = randomString(255),
|
||||
val expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable {
|
||||
fun updateExpiration(newExpiration: Instant) = Session(
|
||||
id = id,
|
||||
userId = userId,
|
||||
token = token,
|
||||
expiration = newExpiration
|
||||
)
|
||||
}
|
||||
|
||||
data class HeaderSession(override val token: String) : Session(token = token)
|
||||
|
||||
data class CookieSession(override val token: String) : Session(token = token)
|
|
@ -8,7 +8,7 @@ data class User(
|
|||
override val id: String = randomString(),
|
||||
val name: String = "",
|
||||
val password: String = "",
|
||||
val email: String = ""
|
||||
val email: String? = null
|
||||
) : Principal, Identifiable
|
||||
|
||||
enum class Permission {
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -9,11 +9,12 @@ services:
|
|||
- db
|
||||
environment:
|
||||
TWIGS_DB_HOST: db
|
||||
TWIGS_DB_TYPE: postgresql
|
||||
networks:
|
||||
- twigs
|
||||
|
||||
db:
|
||||
image: postgres:13
|
||||
image: postgres:17
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
[versions]
|
||||
bcrypt = "0.10.2"
|
||||
hikari = "5.1.0"
|
||||
junit = "5.10.2"
|
||||
kotlin = "1.9.23"
|
||||
kotlinx-coroutines = "1.8.0"
|
||||
ktor = "2.3.9"
|
||||
logback = "1.5.3"
|
||||
hikari = "6.2.1"
|
||||
junit = "5.11.3"
|
||||
kotlin = "2.0.21"
|
||||
kotlinx-coroutines = "1.9.0"
|
||||
ktor = "2.3.13"
|
||||
logback = "1.5.12"
|
||||
mail = "1.6.2"
|
||||
postgres = "42.7.2"
|
||||
postgres = "42.7.4"
|
||||
shadow = "8.1.1"
|
||||
sqlite = "3.45.1.0"
|
||||
sqlite = "3.47.0.0"
|
||||
|
||||
[libraries]
|
||||
bcrypt = { module = "at.favre.lib:bcrypt", version.ref = "bcrypt" }
|
||||
|
@ -29,6 +29,8 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" }
|
|||
ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
|
||||
ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
|
||||
ktor-server-cors = { module = "io.ktor:ktor-server-cors", version.ref = "ktor" }
|
||||
ktor-server-forwarded-headers = { module = "io.ktor:ktor-server-forwarded-header", version.ref = "ktor" }
|
||||
ktor-server-mustache = { module = "io.ktor:ktor-server-mustache", version.ref = "ktor" }
|
||||
ktor-server-sessions = { module = "io.ktor:ktor-server-sessions", version.ref = "ktor" }
|
||||
ktor-server-test = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
|
||||
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
|
||||
|
@ -43,6 +45,7 @@ ktor-server = [
|
|||
"ktor-server-content-negotiation",
|
||||
"ktor-server-core",
|
||||
"ktor-server-cors",
|
||||
"ktor-server-forwarded-headers",
|
||||
"ktor-server-sessions"
|
||||
]
|
||||
|
||||
|
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
7
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,6 +1,7 @@
|
|||
#Fri Feb 07 18:11:46 CST 2020
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-all.zip
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
|
297
gradlew
vendored
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
|
@ -15,80 +15,116 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
@ -97,87 +133,120 @@ Please set the JAVA_HOME variable in your environment to match the
|
|||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
|
194
gradlew.bat
vendored
|
@ -1,100 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
3
renovate.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
1
service/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
build/
|
19
service/build.gradle.kts
Normal file
|
@ -0,0 +1,19 @@
|
|||
plugins {
|
||||
`java-library`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(libs.ktor.server.core)
|
||||
implementation(project(":storage"))
|
||||
api(libs.ktor.serialization)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
59
service/src/main/java/com/wbrawner/twigs/service/ApiUtils.kt
Normal file
|
@ -0,0 +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 budget = first.findAll(ids = listOf(budgetId)).firstOrNull() ?: throw HttpException(HttpStatusCode.NotFound)
|
||||
return BudgetResponse(budget, second.requirePermission(userId, budgetId, permission))
|
||||
}
|
||||
|
||||
fun PipelineContext<Unit, ApplicationCall>.requireSession() = requireNotNull(call.principal<Session>()) {
|
||||
"Session required but was null"
|
||||
}
|
||||
|
||||
suspend inline fun <reified T : Any> ApplicationCall.respondCatching(block: () -> T) =
|
||||
try {
|
||||
val response = block()
|
||||
if (response is HttpStatusCode) {
|
||||
respond(status = response, message = Unit)
|
||||
} else {
|
||||
respond(HttpStatusCode.OK, response)
|
||||
}
|
||||
} catch (e: HttpException) {
|
||||
respond(e.statusCode, e.toResponse())
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
package com.wbrawner.twigs.service
|
||||
|
||||
import io.ktor.http.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ErrorResponse(val message: String)
|
||||
|
||||
class HttpException(
|
||||
val statusCode: HttpStatusCode,
|
||||
override val cause: Throwable? = null,
|
||||
override val message: String? = null
|
||||
) : Throwable() {
|
||||
constructor(statusCode: HttpStatusCode) : this(statusCode = statusCode, message = statusCode.description)
|
||||
|
||||
fun toResponse(): ErrorResponse =
|
||||
ErrorResponse(requireNotNull(message) { "Cannot send error to client without message" })
|
||||
}
|
|
@ -1,9 +1,10 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.twigs.service.budget
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.service.user.UserPermissionRequest
|
||||
import com.wbrawner.twigs.service.user.UserPermissionResponse
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class BudgetRequest(
|
||||
|
@ -20,7 +21,7 @@ data class BudgetResponse(
|
|||
val users: List<UserPermissionResponse>
|
||||
) {
|
||||
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
|
||||
Objects.requireNonNull<String>(budget.id),
|
||||
requireNotNull(budget.id),
|
||||
budget.name,
|
||||
budget.description,
|
||||
users.map { userPermission: UserPermission ->
|
|
@ -0,0 +1,130 @@
|
|||
package com.wbrawner.twigs.service.budget
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
import com.wbrawner.twigs.service.budgetWithPermission
|
||||
import com.wbrawner.twigs.service.user.UserPermissionRequest
|
||||
import com.wbrawner.twigs.storage.BudgetRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import io.ktor.http.*
|
||||
|
||||
interface BudgetService {
|
||||
suspend fun budgetsForUser(userId: String): List<BudgetResponse>
|
||||
|
||||
suspend fun budget(budgetId: String, userId: String): BudgetResponse
|
||||
|
||||
suspend fun save(request: BudgetRequest, userId: String, budgetId: String? = null): BudgetResponse
|
||||
|
||||
suspend fun delete(budgetId: String, userId: String)
|
||||
}
|
||||
|
||||
class DefaultBudgetService(
|
||||
private val budgetRepository: BudgetRepository,
|
||||
private val permissionRepository: PermissionRepository
|
||||
) : BudgetService {
|
||||
private val budgetPermissionRepository = budgetRepository to permissionRepository
|
||||
override suspend fun budgetsForUser(userId: String): List<BudgetResponse> {
|
||||
val budgetIds = permissionRepository.findAll(userId = userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
return budgetRepository.findAll(ids = budgetIds).map {
|
||||
BudgetResponse(it, permissionRepository.findAll(budgetIds = listOf(it.id)))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun budget(budgetId: String, userId: String): BudgetResponse =
|
||||
budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.READ)
|
||||
|
||||
override suspend fun save(request: BudgetRequest, userId: String, budgetId: String?): BudgetResponse {
|
||||
val budget = if (budgetId?.isNotBlank() == true) {
|
||||
budgetPermissionRepository.budgetWithPermission(
|
||||
budgetId = budgetId,
|
||||
userId = userId,
|
||||
permission = Permission.MANAGE
|
||||
).run {
|
||||
Budget(
|
||||
id = budgetId,
|
||||
name = request.name ?: name,
|
||||
description = request.description ?: description
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (request.name.isNullOrBlank()) {
|
||||
throw HttpException(HttpStatusCode.BadRequest, message = "Name cannot be empty or null")
|
||||
}
|
||||
Budget(
|
||||
name = request.name,
|
||||
description = request.description
|
||||
)
|
||||
}
|
||||
val users = budgetId?.let {
|
||||
// If user is owner, apply changes
|
||||
// If user is manager, make sure they're not changing ownership
|
||||
val oldUsers = permissionRepository.findAll(budgetIds = listOf(budgetId))
|
||||
val oldPermissions = oldUsers.associate { it.userId to it.permission }
|
||||
val currentUserPermission = oldPermissions[userId] ?: throw HttpException(HttpStatusCode.NotFound)
|
||||
val newUsers = request.users?.map { userPermission ->
|
||||
if (userPermission.permission == Permission.OWNER
|
||||
&& oldPermissions[userPermission.user] !== Permission.OWNER
|
||||
&& currentUserPermission != Permission.OWNER
|
||||
) {
|
||||
// The user is attempting to add a new owner
|
||||
throw HttpException(
|
||||
HttpStatusCode.Forbidden,
|
||||
message = "You must be an owner to be able to modify other users' ownership"
|
||||
)
|
||||
}
|
||||
userPermission
|
||||
} ?: return@let oldUsers.map { UserPermissionRequest(it.userId, it.permission) }
|
||||
oldPermissions.filterValues { it == Permission.OWNER }
|
||||
.forEach { (user, permission) ->
|
||||
if (newUsers.none { it.user == user && it.permission == permission }
|
||||
&& currentUserPermission != Permission.OWNER
|
||||
) {
|
||||
// The user is attempting to remove a previous owner
|
||||
throw HttpException(
|
||||
HttpStatusCode.Forbidden,
|
||||
message = "You must be an owner to be able to modify other users' ownership"
|
||||
)
|
||||
}
|
||||
}
|
||||
oldUsers.forEach { oldUserPermission ->
|
||||
if (newUsers.none { it.user == oldUserPermission.userId && it.permission == oldUserPermission.permission }) {
|
||||
permissionRepository.delete(oldUserPermission)
|
||||
}
|
||||
}
|
||||
newUsers
|
||||
} ?: run {
|
||||
val newUsers = request.users
|
||||
?.toMutableList()
|
||||
?: mutableListOf()
|
||||
val currentUserPermission = newUsers.firstOrNull { it.user == userId }
|
||||
if (currentUserPermission == null || currentUserPermission.permission != Permission.OWNER) {
|
||||
newUsers.removeIf { it.user == userId }
|
||||
newUsers.add(UserPermissionRequest(userId, Permission.OWNER))
|
||||
}
|
||||
newUsers
|
||||
}
|
||||
val savedBudget = budgetRepository.save(budget)
|
||||
return BudgetResponse(
|
||||
savedBudget,
|
||||
users.map {
|
||||
permissionRepository.save(
|
||||
UserPermission(
|
||||
budgetId = savedBudget.id,
|
||||
userId = it.user,
|
||||
permission = it.permission
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun delete(budgetId: String, userId: String) {
|
||||
val budgetResponse = budgetPermissionRepository.budgetWithPermission(userId, budgetId, Permission.OWNER)
|
||||
budgetRepository.delete(Budget(budgetResponse.id, budgetResponse.name, budgetResponse.description))
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.twigs.service.category
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import kotlinx.serialization.Serializable
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.twigs.service.recurringtransaction
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import kotlinx.serialization.Serializable
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.twigs.service.transaction
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import kotlinx.serialization.Serializable
|
|
@ -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>? = 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(
|
||||
userId: String,
|
||||
budgetId: String? = null,
|
||||
categoryId: String? = null,
|
||||
from: Instant? = null,
|
||||
to: Instant? = null,
|
||||
): 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(
|
||||
userId: String,
|
||||
budgetId: String?,
|
||||
categoryId: String?,
|
||||
from: Instant?,
|
||||
to: Instant?
|
||||
): 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)
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package com.wbrawner.twigs
|
||||
package com.wbrawner.twigs.service.user
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.model.Permission
|
|
@ -0,0 +1,178 @@
|
|||
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 session(token: String): SessionResponse
|
||||
|
||||
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()) null else request.email
|
||||
)
|
||||
).asResponse()
|
||||
}
|
||||
|
||||
override suspend fun requestPasswordResetEmail(request: ResetPasswordRequest) {
|
||||
userRepository.findAll(nameOrEmail = request.username)
|
||||
.firstOrNull()
|
||||
?.let {
|
||||
val email = it.email ?: return@let
|
||||
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 session(token: String): SessionResponse {
|
||||
return sessionRepository.findAll(token = token)
|
||||
.firstOrNull()
|
||||
?.asResponse()
|
||||
?: throw HttpException(HttpStatusCode.Unauthorized)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
rootProject.name = "twigs"
|
||||
include("core", "api", "app", "storage", "db", "web")
|
||||
include("testhelpers")
|
||||
include("testhelpers")
|
||||
include("service")
|
||||
|
|
|
@ -3,7 +3,7 @@ package com.wbrawner.twigs.storage
|
|||
import com.wbrawner.twigs.model.UserPermission
|
||||
|
||||
interface PermissionRepository : Repository<UserPermission> {
|
||||
fun findAll(
|
||||
suspend fun findAll(
|
||||
budgetIds: List<String>? = null,
|
||||
userId: String? = null
|
||||
): List<UserPermission>
|
||||
|
|
|
@ -5,7 +5,7 @@ import com.wbrawner.twigs.storage.PermissionRepository
|
|||
|
||||
class FakePermissionRepository : PermissionRepository {
|
||||
val permissions: MutableList<UserPermission> = mutableListOf()
|
||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||
override suspend fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||
permissions.filter { userPermission ->
|
||||
budgetIds?.contains(userPermission.budgetId) ?: true
|
||||
&& userId?.let { it == userPermission.userId } ?: true
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import java.util.*
|
||||
|
||||
plugins {
|
||||
`java-library`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
|
@ -7,45 +5,14 @@ plugins {
|
|||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":service"))
|
||||
api(libs.ktor.server.core)
|
||||
api(libs.ktor.server.mustache)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
// TODO: Replace this hack with either a git submodule or an internal Kotlin-based UI
|
||||
tasks.register("package") {
|
||||
doLast {
|
||||
val built = File(rootProject.rootDir.parent, "twigs-web/dist/twigs")
|
||||
if (built.exists()) {
|
||||
built.deleteRecursively()
|
||||
}
|
||||
val dest = File(project.projectDir, "src/main/resources/twigs")
|
||||
if (dest.exists()) {
|
||||
dest.deleteRecursively()
|
||||
}
|
||||
var command = listOf(
|
||||
"cd", "../../twigs-web", ";",
|
||||
"npm", "i", ";",
|
||||
"npm", "run", "package"
|
||||
)
|
||||
command = if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) {
|
||||
listOf("powershell", "-Command") + command
|
||||
} else {
|
||||
listOf("bash", "-c", "\"${command.joinToString(" ")}\"")
|
||||
}
|
||||
exec {
|
||||
commandLine(command)
|
||||
}
|
||||
if (!built.copyRecursively(dest, true) || !dest.isDirectory) {
|
||||
throw GradleException("Failed to copy files from ${built.absolutePath} to ${dest.absolutePath}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//tasks.getByName("processResources") {
|
||||
// dependsOn.add("package")
|
||||
//}
|
||||
}
|
28
web/src/main/kotlin/com/wbrawner/twigs/web/Page.kt
Normal file
|
@ -0,0 +1,28 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
|
||||
interface Page {
|
||||
val title: String
|
||||
val error: String?
|
||||
}
|
||||
|
||||
interface AuthenticatedPage : Page {
|
||||
val user: UserResponse
|
||||
val budgets: List<BudgetListItem>
|
||||
}
|
||||
|
||||
data class BudgetListItem(val id: String, val name: String, val description: String, val selected: Boolean)
|
||||
|
||||
fun BudgetResponse.toBudgetListItem(selectedId: String? = null) = BudgetListItem(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
selected = id == selectedId
|
||||
)
|
||||
|
||||
object NotFoundPage : Page {
|
||||
override val title: String = "404 Not Found"
|
||||
override val error: String? = null
|
||||
}
|
|
@ -1,21 +1,52 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
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.category.categoryWebRoutes
|
||||
import com.wbrawner.twigs.web.transaction.transactionWebRoutes
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.sessions.*
|
||||
|
||||
fun Application.webRoutes() {
|
||||
fun Application.webRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
staticResources("/", "web")
|
||||
intercept(ApplicationCallPipeline.Setup) {
|
||||
if (!call.request.path().startsWith("/api") && !call.request.path().matches(Regex(".*\\.\\w+$"))) {
|
||||
call.resolveResource("web/index.html")?.let {
|
||||
call.respond(it)
|
||||
return@intercept finish()
|
||||
staticResources("/", "static")
|
||||
get("/") {
|
||||
call.sessions.get(CookieSession::class)
|
||||
?.let {
|
||||
try {
|
||||
userService.session(it.token)
|
||||
} catch (e: HttpException) {
|
||||
application.environment.log.debug("Failed to retrieve session for user", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
?.let { session ->
|
||||
application.environment.log.info("Session found!")
|
||||
budgetService.budgetsForUser(session.userId)
|
||||
.firstOrNull()
|
||||
?.let { budget ->
|
||||
call.respondRedirect("/budgets/${budget.id}")
|
||||
} ?: call.respondRedirect("/budgets")
|
||||
} ?: call.respond(MustacheContent("index.mustache", null))
|
||||
}
|
||||
}
|
||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
userWebRoutes(userService)
|
||||
}
|
33
web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt
Normal file
|
@ -0,0 +1,33 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import io.ktor.http.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
|
||||
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||
with(this as DecimalFormat) {
|
||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||
currencySymbol = ""
|
||||
isGroupingUsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
|
||||
|
||||
|
||||
fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
|
||||
?.toDouble()
|
||||
?.toBigDecimal()
|
||||
?.times(BigDecimal(100))
|
||||
?.toLong()
|
||||
?: 0L
|
||||
|
||||
fun Long?.toDecimalString(): String {
|
||||
if (this == null) return ""
|
||||
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
package com.wbrawner.twigs.web.budget
|
||||
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
|
||||
|
||||
data class BudgetListPage(
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = "Budgets"
|
||||
}
|
||||
|
||||
data class BudgetDetailsPage(
|
||||
val budget: BudgetResponse,
|
||||
val balances: BudgetBalances,
|
||||
val incomeCategories: List<CategoryWithBalanceResponse>,
|
||||
val expenseCategories: List<CategoryWithBalanceResponse>,
|
||||
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
|
||||
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
|
||||
val transactionCount: String,
|
||||
val monthAndYear: String,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = budget.name.orEmpty()
|
||||
}
|
||||
|
||||
data class BudgetFormPage(
|
||||
val budget: BudgetResponse,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = if (budget.id.isBlank()) {
|
||||
"New Budget"
|
||||
} else {
|
||||
"Edit Budget"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,242 @@
|
|||
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.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.NotFoundPage
|
||||
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
|
||||
import com.wbrawner.twigs.web.toBudgetListItem
|
||||
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.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
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).map { it.toBudgetListItem() }
|
||||
call.respond(MustacheContent("budgets.mustache", BudgetListPage(budgets, user)))
|
||||
}
|
||||
|
||||
route("/new") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"budget-form.mustache",
|
||||
BudgetFormPage(
|
||||
budget = BudgetResponse(
|
||||
id = "",
|
||||
name = "",
|
||||
description = "",
|
||||
users = listOf()
|
||||
),
|
||||
budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() },
|
||||
user = 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(
|
||||
budget = BudgetResponse(
|
||||
id = "",
|
||||
name = call.parameters["name"].orEmpty(),
|
||||
description = call.parameters["description"].orEmpty(),
|
||||
users = listOf()
|
||||
),
|
||||
budgets = budgetService.budgetsForUser(user.id).map { it.toBudgetListItem() },
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/{id}") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("id")
|
||||
val budgets = budgetService.budgetsForUser(userId = user.id).toMutableList()
|
||||
val budget = budgets.firstOrNull { it.id == budgetId }
|
||||
?: run {
|
||||
call.respond(MustacheContent("404.mustache", NotFoundPage))
|
||||
return@get
|
||||
}
|
||||
val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
val categories = categoryService.categories(budgetIds = listOf(budget.id), userId = user.id)
|
||||
.map { category ->
|
||||
val categoryBalance =
|
||||
abs(transactionService.sum(categoryId = category.id, userId = user.id))
|
||||
CategoryWithBalanceResponse(
|
||||
category = category,
|
||||
amountLabel = category.amount.toCurrencyString(numberFormat),
|
||||
balance = categoryBalance,
|
||||
balanceLabel = categoryBalance.toCurrencyString(numberFormat),
|
||||
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(
|
||||
numberFormat
|
||||
)
|
||||
)
|
||||
}
|
||||
.toMutableSet()
|
||||
val incomeCategories = categories.extractIf { !it.category.expense && !it.category.archived }
|
||||
val archivedIncomeCategories =
|
||||
categories.extractIf { !it.category.expense && it.category.archived }
|
||||
val expenseCategories = categories.extractIf { it.category.expense && !it.category.archived }
|
||||
val archivedExpenseCategories =
|
||||
categories.extractIf { it.category.expense && it.category.archived }
|
||||
val transactions = transactionService.transactions(
|
||||
budgetIds = listOf(budget.id),
|
||||
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
||||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||
userId = user.id
|
||||
)
|
||||
// TODO: Allow user-configurable locale
|
||||
val budgetBalance = transactionService.sum(budgetId = budget.id, userId = user.id)
|
||||
.toCurrencyString(numberFormat)
|
||||
val expectedIncome = incomeCategories.sumOf { it.category.amount }
|
||||
val actualIncome = transactions.sumOf { if (it.expense == false) it.amount ?: 0L else 0L }
|
||||
val expectedExpenses = expenseCategories.sumOf { it.category.amount }
|
||||
val actualExpenses = transactions.sumOf { if (it.expense == true) it.amount ?: 0L else 0L }
|
||||
val balances = BudgetBalances(
|
||||
cashFlow = budgetBalance,
|
||||
expectedIncome = expectedIncome,
|
||||
expectedIncomeLabel = expectedIncome.toCurrencyString(numberFormat),
|
||||
actualIncome = actualIncome,
|
||||
actualIncomeLabel = actualIncome.toCurrencyString(numberFormat),
|
||||
expectedExpenses = expectedExpenses,
|
||||
expectedExpensesLabel = expectedExpenses.toCurrencyString(numberFormat),
|
||||
actualExpenses = actualExpenses,
|
||||
actualExpensesLabel = actualExpenses.toCurrencyString(numberFormat),
|
||||
)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"budget-details.mustache", BudgetDetailsPage(
|
||||
budget = budget,
|
||||
balances = balances,
|
||||
incomeCategories = incomeCategories,
|
||||
expenseCategories = expenseCategories,
|
||||
archivedIncomeCategories = archivedIncomeCategories,
|
||||
archivedExpenseCategories = archivedExpenseCategories,
|
||||
transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||
.format(transactions.size),
|
||||
monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")),
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) }.sortedBy { it.name },
|
||||
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 = budget,
|
||||
budgets = budgetService.budgetsForUser(user.id)
|
||||
.map { it.toBudgetListItem(budget.id) },
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
route("/delete") {
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("id")
|
||||
budgetService.delete(budgetId = budgetId, userId = user.id)
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BudgetBalances(
|
||||
val cashFlow: String,
|
||||
val expectedIncome: Long,
|
||||
val expectedIncomeLabel: String,
|
||||
val actualIncome: Long,
|
||||
val actualIncomeLabel: String,
|
||||
val expectedExpenses: Long,
|
||||
val expectedExpensesLabel: String,
|
||||
val actualExpenses: Long,
|
||||
val actualExpensesLabel: String,
|
||||
) {
|
||||
val maxProgressBarValue: Long = maxOf(expectedExpenses, expectedIncome, actualIncome, actualExpenses)
|
||||
}
|
||||
|
||||
private fun Parameters.toBudgetRequest() = BudgetRequest(
|
||||
name = get("name"),
|
||||
description = get("description"),
|
||||
users = setOf() // TODO: Enable adding users at budget creation
|
||||
)
|
||||
|
||||
private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<T> {
|
||||
val extracted = mutableListOf<T>()
|
||||
val iterator = iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val item = iterator.next()
|
||||
if (predicate(item)) {
|
||||
extracted.add(item)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
return extracted
|
||||
}
|
||||
|
||||
fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format(
|
||||
this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP)
|
||||
)
|
|
@ -0,0 +1,67 @@
|
|||
package com.wbrawner.twigs.web.category
|
||||
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import java.text.NumberFormat
|
||||
|
||||
data class CategoryDetailsPage(
|
||||
val category: CategoryWithBalanceResponse,
|
||||
val budget: BudgetResponse,
|
||||
val transactionCount: String,
|
||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = category.category.title
|
||||
}
|
||||
|
||||
data class TransactionListItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val budgetId: String,
|
||||
val expenseClass: String,
|
||||
val amountLabel: String
|
||||
)
|
||||
|
||||
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
|
||||
id,
|
||||
title.orEmpty(),
|
||||
description.orEmpty(),
|
||||
budgetId,
|
||||
if (expense != false) "expense" else "income",
|
||||
(amount ?: 0L).toCurrencyString(numberFormat)
|
||||
)
|
||||
|
||||
data class CategoryFormPage(
|
||||
val category: CategoryResponse,
|
||||
val amountLabel: String,
|
||||
val budget: BudgetResponse,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = if (category.id.isBlank()) {
|
||||
"New Category"
|
||||
} else {
|
||||
"Edit Category"
|
||||
}
|
||||
|
||||
val expenseChecked: String = if (category.expense) "checked" else ""
|
||||
val incomeChecked: String = if (!category.expense) "checked" else ""
|
||||
val archivedChecked: String = if (category.archived) "checked" else ""
|
||||
}
|
||||
|
||||
data class CategoryWithBalanceResponse(
|
||||
val category: CategoryResponse,
|
||||
val amountLabel: String,
|
||||
val balance: Long,
|
||||
val balanceLabel: String,
|
||||
val remainingAmountLabel: String,
|
||||
)
|
|
@ -0,0 +1,245 @@
|
|||
package com.wbrawner.twigs.web.category
|
||||
|
||||
import com.wbrawner.twigs.endOfMonth
|
||||
import com.wbrawner.twigs.firstOfMonth
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
import com.wbrawner.twigs.service.budget.BudgetService
|
||||
import com.wbrawner.twigs.service.category.CategoryRequest
|
||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||
import com.wbrawner.twigs.service.category.CategoryService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstant
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.*
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
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.*
|
||||
import io.ktor.util.date.*
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
fun Application.categoryWebRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
authenticate(TWIGS_SESSION_COOKIE) {
|
||||
route("/budgets/{budgetId}/categories") {
|
||||
get {
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
call.respondRedirect("/budgets/$budgetId")
|
||||
}
|
||||
|
||||
route("/new") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(
|
||||
CategoryResponse(
|
||||
id = "",
|
||||
title = "",
|
||||
description = "",
|
||||
amount = 0,
|
||||
budgetId = budgetId,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean() ?: true,
|
||||
archived = false,
|
||||
),
|
||||
amountLabel = 0L.toDecimalString(),
|
||||
budget = budget,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
try {
|
||||
val request = call.receiveParameters().toCategoryRequest(budgetId)
|
||||
val category = categoryService.save(request, user.id)
|
||||
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(
|
||||
CategoryResponse(
|
||||
id = "",
|
||||
title = call.parameters["title"].orEmpty(),
|
||||
description = call.parameters["description"].orEmpty(),
|
||||
amount = 0L,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: false,
|
||||
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
||||
budgetId = budgetId
|
||||
),
|
||||
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
|
||||
budget = budget,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/{id}") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val categoryId = call.parameters.getOrFail("id")
|
||||
try {
|
||||
val category = categoryService.category(categoryId = categoryId, userId = user.id)
|
||||
val categoryBalance =
|
||||
abs(transactionService.sum(categoryId = category.id, userId = user.id))
|
||||
val categoryWithBalance = CategoryWithBalanceResponse(
|
||||
category = category,
|
||||
amountLabel = category.amount.toCurrencyString(currencyFormat),
|
||||
balance = categoryBalance,
|
||||
balanceLabel = categoryBalance.toCurrencyString(currencyFormat),
|
||||
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(
|
||||
currencyFormat
|
||||
)
|
||||
)
|
||||
val transactions = transactionService.transactions(
|
||||
budgetIds = listOf(category.budgetId),
|
||||
categoryIds = listOf(category.id),
|
||||
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
||||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||
userId = user.id
|
||||
)
|
||||
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||
.format(transactions.size)
|
||||
val transactionsByDate = transactions.groupBy {
|
||||
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
|
||||
}
|
||||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
|
||||
.entries
|
||||
.sortedByDescending { it.key }
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"category-details.mustache", CategoryDetailsPage(
|
||||
category = categoryWithBalance,
|
||||
transactions = transactionsByDate,
|
||||
transactionCount = transactionCount,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
budget = budget,
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("404.mustache", NotFoundPage)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
route("/edit") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val category = categoryService.category(
|
||||
categoryId = call.parameters.getOrFail("id"),
|
||||
userId = user.id
|
||||
)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(
|
||||
category = category,
|
||||
amountLabel = category.amount.toDecimalString(),
|
||||
budget = budgets.first { it.id == budgetId },
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val categoryId = call.parameters.getOrFail("id")
|
||||
try {
|
||||
val request = call.receiveParameters().toCategoryRequest(budgetId)
|
||||
val category = categoryService.save(request, userId = user.id, categoryId = categoryId)
|
||||
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(
|
||||
CategoryResponse(
|
||||
id = "",
|
||||
title = call.parameters["title"].orEmpty(),
|
||||
description = call.parameters["description"].orEmpty(),
|
||||
amount = 0L,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: false,
|
||||
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
||||
budgetId = budgetId
|
||||
),
|
||||
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
|
||||
budget = budgets.first { it.id == budgetId },
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/delete") {
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val categoryId = call.parameters.getOrFail("id")
|
||||
categoryService.delete(categoryId = categoryId, userId = user.id)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
call.respondRedirect("/budgets/$budgetId")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Parameters.toCategoryRequest(budgetId: String) = CategoryRequest(
|
||||
title = get("title"),
|
||||
description = get("description"),
|
||||
amount = getAmount(),
|
||||
expense = get("expense")?.toBoolean(),
|
||||
archived = get("archived") == "on",
|
||||
budgetId = budgetId
|
||||
)
|
|
@ -0,0 +1,66 @@
|
|||
package com.wbrawner.twigs.web.transaction
|
||||
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.category.TransactionListItem
|
||||
|
||||
data class TransactionListPage(
|
||||
val budget: BudgetResponse,
|
||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = "Transactions"
|
||||
}
|
||||
|
||||
|
||||
data class TransactionDetailsPage(
|
||||
val transaction: TransactionResponse,
|
||||
val category: CategoryResponse?,
|
||||
val budget: BudgetResponse,
|
||||
val amountLabel: String,
|
||||
val dateLabel: String,
|
||||
val createdBy: UserResponse,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = transaction.title.orEmpty()
|
||||
}
|
||||
|
||||
data class TransactionFormPage(
|
||||
val transaction: TransactionResponse,
|
||||
val amountLabel: String,
|
||||
val budget: BudgetResponse,
|
||||
val categoryOptions: List<CategoryOption>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = if (transaction.id.isBlank()) {
|
||||
"New Transaction"
|
||||
} else {
|
||||
"Edit Transaction"
|
||||
}
|
||||
|
||||
data class CategoryOption(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val isSelected: Boolean = false,
|
||||
val isDisabled: Boolean = false
|
||||
) {
|
||||
val selected: String
|
||||
get() = if (isSelected) "selected" else ""
|
||||
|
||||
val disabled: String
|
||||
get() = if (isDisabled) "disabled" else ""
|
||||
}
|
||||
}
|
||||
|
||||
fun CategoryResponse.asOption(selectedCategoryId: String) =
|
||||
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId)
|
|
@ -0,0 +1,362 @@
|
|||
package com.wbrawner.twigs.web.transaction
|
||||
|
||||
import com.wbrawner.twigs.endOfMonth
|
||||
import com.wbrawner.twigs.firstOfMonth
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
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.TransactionRequest
|
||||
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstant
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.*
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import com.wbrawner.twigs.web.category.toListItem
|
||||
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.*
|
||||
import io.ktor.util.date.*
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset.UTC
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
fun Application.transactionWebRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
authenticate(TWIGS_SESSION_COOKIE) {
|
||||
route("/budgets/{budgetId}/transactions") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val transactions = transactionService.transactions(
|
||||
budgetIds = listOf(budgetId),
|
||||
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
|
||||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||
userId = user.id
|
||||
)
|
||||
val transactionsByDate = transactions.groupBy {
|
||||
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
|
||||
}
|
||||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
|
||||
.entries
|
||||
.sortedByDescending { it.key }
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"budget-transactions.mustache",
|
||||
TransactionListPage(
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
budget = budgets.first { it.id == budgetId },
|
||||
transactions = transactionsByDate,
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
route("/new") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
val categoryId = call.request.queryParameters["categoryId"]
|
||||
val transaction = TransactionResponse(
|
||||
id = "",
|
||||
title = "",
|
||||
description = "",
|
||||
amount = 0,
|
||||
budgetId = budgetId,
|
||||
expense = true,
|
||||
date = Instant.now().toHtmlInputString(),
|
||||
categoryId = categoryId,
|
||||
createdBy = user.id
|
||||
)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"transaction-form.mustache",
|
||||
TransactionFormPage(
|
||||
transaction = transaction,
|
||||
amountLabel = 0L.toDecimalString(),
|
||||
budget = budget,
|
||||
categoryOptions = categoryOptions(
|
||||
transaction = transaction,
|
||||
categoryService = categoryService,
|
||||
budgetId = budgetId,
|
||||
user = user
|
||||
),
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == urlBudgetId }
|
||||
try {
|
||||
val request = call.receiveParameters().toTransactionRequest()
|
||||
.run {
|
||||
copy(
|
||||
date = "$date:00Z",
|
||||
expense = categoryService.category(
|
||||
categoryId = requireNotNull(categoryId),
|
||||
userId = user.id
|
||||
).expense,
|
||||
budgetId = urlBudgetId
|
||||
)
|
||||
}
|
||||
val transaction = transactionService.save(request, user.id)
|
||||
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
|
||||
} catch (e: HttpException) {
|
||||
val transaction = TransactionResponse(
|
||||
id = "",
|
||||
title = call.parameters["title"],
|
||||
description = call.parameters["description"],
|
||||
amount = 0L,
|
||||
budgetId = urlBudgetId,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
||||
date = call.parameters["date"].orEmpty(),
|
||||
categoryId = call.parameters["categoryId"],
|
||||
createdBy = user.id
|
||||
)
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent(
|
||||
"transaction-form.mustache",
|
||||
TransactionFormPage(
|
||||
transaction = transaction,
|
||||
amountLabel = call.parameters["amount"].orEmpty(),
|
||||
budget = budget,
|
||||
categoryOptions = categoryOptions(
|
||||
transaction,
|
||||
categoryService,
|
||||
urlBudgetId,
|
||||
user
|
||||
),
|
||||
budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/{id}") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val transactionId = call.parameters.getOrFail("id")
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
// TODO: Allow user-configurable locale
|
||||
try {
|
||||
val transaction = transactionService.transaction(
|
||||
transactionId = transactionId,
|
||||
userId = user.id
|
||||
)
|
||||
check(transaction.budgetId == budgetId) {
|
||||
// TODO: redirect instead of error?
|
||||
"Attempted to fetch transaction from wrong budget"
|
||||
}
|
||||
val category = transaction.categoryId?.let {
|
||||
categoryService.category(categoryId = it, userId = user.id)
|
||||
}
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
val dateFormat = DateTimeFormatter.ofPattern("H:mm a 'on' MMMM d, yyyy")
|
||||
val transactionInstant = transaction.date.toInstant()
|
||||
val transactionOffset = transactionInstant.atOffset(UTC)
|
||||
val dateLabel = transactionOffset.format(dateFormat)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"transaction-details.mustache", TransactionDetailsPage(
|
||||
transaction = transaction,
|
||||
category = category,
|
||||
budget = budget,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
amountLabel = transaction.amount?.toCurrencyString(currencyFormat).orEmpty(),
|
||||
dateLabel = dateLabel,
|
||||
createdBy = userService.user(transaction.createdBy),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("404.mustache", NotFoundPage)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
route("/edit") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
val transaction = transactionService.transaction(
|
||||
transactionId = call.parameters.getOrFail("id"),
|
||||
userId = user.id
|
||||
)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"transaction-form.mustache",
|
||||
TransactionFormPage(
|
||||
transaction = transaction.copy(
|
||||
date = transaction.date.toInstant().toHtmlInputString()
|
||||
),
|
||||
amountLabel = transaction.amount.toDecimalString(),
|
||||
budget = budget,
|
||||
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val transactionId = call.parameters.getOrFail("id")
|
||||
val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budget = budgets.first { it.id == urlBudgetId }
|
||||
try {
|
||||
val request = call.receiveParameters().toTransactionRequest()
|
||||
.run {
|
||||
copy(
|
||||
date = "$date:00Z",
|
||||
expense = categoryService.category(
|
||||
categoryId = requireNotNull(categoryId),
|
||||
userId = user.id
|
||||
).expense,
|
||||
budgetId = urlBudgetId
|
||||
)
|
||||
}
|
||||
val transaction =
|
||||
transactionService.save(request, userId = user.id, transactionId = transactionId)
|
||||
call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}")
|
||||
} catch (e: HttpException) {
|
||||
val transaction = TransactionResponse(
|
||||
id = transactionId,
|
||||
title = call.parameters["title"],
|
||||
description = call.parameters["description"],
|
||||
amount = 0L,
|
||||
budgetId = urlBudgetId,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: true,
|
||||
date = call.parameters["date"].orEmpty(),
|
||||
categoryId = call.parameters["categoryId"],
|
||||
createdBy = user.id
|
||||
)
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent(
|
||||
"transaction-form.mustache",
|
||||
TransactionFormPage(
|
||||
transaction = transaction,
|
||||
amountLabel = call.parameters["amount"].orEmpty(),
|
||||
budget = budget,
|
||||
categoryOptions = categoryOptions(
|
||||
transaction,
|
||||
categoryService,
|
||||
urlBudgetId,
|
||||
user
|
||||
),
|
||||
budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/delete") {
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val transactionId = call.parameters.getOrFail("id")
|
||||
val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
transactionService.delete(transactionId = transactionId, userId = user.id)
|
||||
call.respondRedirect("/budgets/${urlBudgetId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun categoryOptions(
|
||||
transaction: TransactionResponse,
|
||||
categoryService: CategoryService,
|
||||
budgetId: String,
|
||||
user: UserResponse
|
||||
): List<TransactionFormPage.CategoryOption> {
|
||||
val selectedCategoryId = transaction.categoryId.orEmpty()
|
||||
val categoryOptions = listOf(
|
||||
TransactionFormPage.CategoryOption(
|
||||
"",
|
||||
"Select a category",
|
||||
isSelected = transaction.categoryId.isNullOrBlank(),
|
||||
isDisabled = true
|
||||
),
|
||||
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
|
||||
)
|
||||
.plus(
|
||||
categoryService.categories(
|
||||
budgetIds = listOf(budgetId),
|
||||
userId = user.id,
|
||||
expense = false,
|
||||
archived = false
|
||||
).map { category ->
|
||||
category.asOption(selectedCategoryId)
|
||||
}
|
||||
)
|
||||
.plus(
|
||||
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
|
||||
)
|
||||
.plus(
|
||||
categoryService.categories(
|
||||
budgetIds = listOf(budgetId),
|
||||
userId = user.id,
|
||||
expense = true,
|
||||
archived = false
|
||||
).map { category ->
|
||||
category.asOption(selectedCategoryId)
|
||||
}
|
||||
)
|
||||
return categoryOptions
|
||||
}
|
||||
|
||||
private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||
title = get("title"),
|
||||
description = get("description"),
|
||||
amount = getAmount(),
|
||||
expense = false,
|
||||
date = get("date"),
|
||||
categoryId = get("categoryId"),
|
||||
budgetId = get("budgetId"),
|
||||
)
|
||||
|
||||
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
|
11
web/src/main/kotlin/com/wbrawner/twigs/web/user/UserPages.kt
Normal file
|
@ -0,0 +1,11 @@
|
|||
package com.wbrawner.twigs.web.user
|
||||
|
||||
import com.wbrawner.twigs.web.Page
|
||||
|
||||
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 = "", override val error: String? = null) : Page {
|
||||
override val title: String = "Register"
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package com.wbrawner.twigs.web.user
|
||||
|
||||
import com.wbrawner.twigs.model.CookieSession
|
||||
import com.wbrawner.twigs.service.HttpException
|
||||
import com.wbrawner.twigs.service.user.LoginRequest
|
||||
import com.wbrawner.twigs.service.user.UserRequest
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.mustache.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.sessions.*
|
||||
import io.ktor.server.util.*
|
||||
|
||||
const val TWIGS_SESSION_COOKIE = "twigsSession"
|
||||
|
||||
fun Application.userWebRoutes(userService: UserService) {
|
||||
routing {
|
||||
route("/login") {
|
||||
get {
|
||||
call.respond(MustacheContent("login.mustache", LoginPage()))
|
||||
}
|
||||
|
||||
post {
|
||||
val request = call.receiveParameters().toLoginRequest()
|
||||
try {
|
||||
val session = userService.login(request)
|
||||
call.sessions.set(CookieSession(session.token))
|
||||
call.respondRedirect("/")
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
call.respond(
|
||||
status = (e as? HttpException)?.statusCode ?: HttpStatusCode.InternalServerError,
|
||||
MustacheContent("login.mustache", LoginPage(username = request.username, error = e.message))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/logout") {
|
||||
post {
|
||||
call.sessions.clear<CookieSession>()
|
||||
call.respondRedirect("/")
|
||||
}
|
||||
}
|
||||
|
||||
route("/register") {
|
||||
get {
|
||||
call.respond(MustacheContent("register.mustache", RegisterPage()))
|
||||
}
|
||||
|
||||
post {
|
||||
val request = call.receiveParameters()
|
||||
val userRequest = request.toUserRequest()
|
||||
val confirmPassword = request.getOrFail("confirmPassword")
|
||||
if (userRequest.password != confirmPassword) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
MustacheContent(
|
||||
"register.mustache",
|
||||
userRequest.toPage("passwords don't match")
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
try {
|
||||
userService.register(userRequest)
|
||||
val session = userService.login(
|
||||
LoginRequest(
|
||||
requireNotNull(userRequest.username),
|
||||
requireNotNull(userRequest.password)
|
||||
)
|
||||
)
|
||||
call.sessions.set(CookieSession(session.token))
|
||||
call.respondRedirect("/")
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("register.mustache", userRequest.toPage(error = e.message))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Parameters.toLoginRequest() = LoginRequest(get("username").orEmpty(), get("password").orEmpty())
|
||||
|
||||
private fun Parameters.toUserRequest() = UserRequest(get("username").orEmpty(), get("password").orEmpty(), get("email"))
|
||||
|
||||
private fun UserRequest.toPage(error: String? = null) =
|
||||
RegisterPage(username = username.orEmpty(), email = email.orEmpty(), error = error)
|
BIN
web/src/main/resources/static/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
web/src/main/resources/static/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 969 B |
BIN
web/src/main/resources/static/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
web/src/main/resources/static/icons/favicon-96x96.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
BIN
web/src/main/resources/static/icons/icon-128x128.png
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
web/src/main/resources/static/icons/icon-144x144.png
Normal file
After Width: | Height: | Size: 7.3 KiB |
BIN
web/src/main/resources/static/icons/icon-152x152.png
Normal file
After Width: | Height: | Size: 7.7 KiB |
BIN
web/src/main/resources/static/icons/icon-192x192.png
Normal file
After Width: | Height: | Size: 9.6 KiB |
BIN
web/src/main/resources/static/icons/icon-384x384.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
web/src/main/resources/static/icons/icon-512x512.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
web/src/main/resources/static/icons/icon-72x72.png
Normal file
After Width: | Height: | Size: 3.7 KiB |
BIN
web/src/main/resources/static/icons/icon-96x96.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-128x128.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-144x144.png
Normal file
After Width: | Height: | Size: 4.5 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-152x152.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-192x192.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-384x384.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-512x512.png
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-72x72.png
Normal file
After Width: | Height: | Size: 2 KiB |
BIN
web/src/main/resources/static/icons/icon-maskable-96x96.png
Normal file
After Width: | Height: | Size: 3.2 KiB |
BIN
web/src/main/resources/static/icons/touch-icon.png
Normal file
After Width: | Height: | Size: 675 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
@ -2,20 +2,19 @@
|
|||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 270.93333 270.93334"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (unknown)"
|
||||
sodipodi:docname="White.svg">
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 270.93333 270.93334"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="0.92.4 (unknown)"
|
||||
sodipodi:docname="White.svg">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
8
web/src/main/resources/static/js/index.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
const forms = document.getElementsByTagName('form')
|
||||
|
||||
for (let i = 0; i < forms.length; i++) {
|
||||
const form = forms[i]
|
||||
form.onsubmit = () => {
|
||||
form.querySelector('input[type="submit"]').disabled = true
|
||||
}
|
||||
}
|
99
web/src/main/resources/static/manifest.json
Normal file
|
@ -0,0 +1,99 @@
|
|||
{
|
||||
"name": "Twigs",
|
||||
"short_name": "Twigs",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
284
web/src/main/resources/static/style.css
Normal file
|
@ -0,0 +1,284 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
transition: 0.25s ease;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Segoe UI", "Product Sans", "Roboto", "San Francisco", sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-accent: #004800;
|
||||
--color-error: #a80000;
|
||||
--color-on-accent: #FFFFFF;
|
||||
--color-on-background: #000000;
|
||||
--color-on-dim: #222222;
|
||||
--logo: url("/img/logo-color.svg");
|
||||
--background-color-primary: #ffffff;
|
||||
--background-color-secondary: #bbbbbb;
|
||||
--border-radius: 5px;
|
||||
--input-padding: 10px;
|
||||
}
|
||||
|
||||
@media all and (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-accent: #baff33;
|
||||
--color-error: #ff4040;
|
||||
--color-on-accent: #000000;
|
||||
--color-on-background: #FFFFFF;
|
||||
--color-on-dim: #888888;
|
||||
--logo: url("/img/logo-white.svg");
|
||||
--background-color-primary: #000000;
|
||||
--background-color-secondary: #333333;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-image: linear-gradient(var(--background-color-primary), var(--background-color-secondary));
|
||||
background-attachment: fixed;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2, h3, h4, h5, h6, p, summary {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
#app {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
#hamburger {
|
||||
color: var(--color-on-background);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.stacked-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media all and (min-width: 1200px) {
|
||||
.columns {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.columns > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
header.row {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card header .button {
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--background-color-primary);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding: var(--input-padding);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flex-full-width {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.button-secondary {
|
||||
background-color: var(--background-color-primary);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
background-color: var(--color-error);
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.center {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background-image: var(--logo);
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.center form {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: "Segoe UI", "Product Sans", "Roboto", "San Francisco", sans-serif;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--input-padding);
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background-color: var(--background-color-secondary);
|
||||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.list-item > a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.list-item > a:hover {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.body-large {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.body-medium {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.body-small {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
.hide-small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 900px) {
|
||||
#sidebar {
|
||||
background-color: var(--background-color-primary);
|
||||
position: fixed;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
#sidebar:target {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 900px) {
|
||||
#hamburger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-width: 400px) {
|
||||
.button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.flex-full-width {
|
||||
flex-direction: column;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-full-width .button {
|
||||
flex-direction: column;
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
8
web/src/main/resources/templates/404.mustache
Normal file
|
@ -0,0 +1,8 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
<p>Looks like you got a little lost in the woods. Take a few steps back and try again!</p>
|
||||
</div>
|
||||
</div>
|
||||
{{>partials/foot}}
|
108
web/src/main/resources/templates/budget-details.mustache
Normal file
|
@ -0,0 +1,108 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
{{>partials/sidebar}}
|
||||
<main>
|
||||
<header class="row">
|
||||
<a id="hamburger" href="#sidebar">☰</a>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="row">
|
||||
<a href="/budgets/{{budget.id}}/transactions/new"
|
||||
class="button button-secondary">
|
||||
<span aria-description="New Transaction">+</span> <span class="hide-small" aria-hidden="true">New Transaction</span>
|
||||
</a>
|
||||
<a href="/budgets/{{budget.id}}/edit"
|
||||
class="button button-secondary" style="margin-right: 10px;">
|
||||
<span aria-description="Edit Budget">✎</span> <span class="hide-small" aria-hidden="true">Edit Budget</span>
|
||||
</a>
|
||||
<form action="/budgets/{{budget.id}}/delete" method="post">
|
||||
<!-- TODO: Show confirmation dialog before actually deleting -->
|
||||
<button class="button button-danger" style="margin-right: 10px;">
|
||||
<span aria-description="Delete Budget">🗑</span> <span class="hide-small" aria-hidden="true">Delete Budget</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<p>{{budget.description}}</p>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Month</p>
|
||||
<p class="body-large">{{monthAndYear}}</p>
|
||||
</div>
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Cash Flow</p>
|
||||
<p class="body-large">{{balances.cashFlow}}</p>
|
||||
</div>
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Transactions</p>
|
||||
<p class="body-large">{{transactionCount}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="column">
|
||||
<p>Expected income: {{balances.expectedIncomeLabel}}</p>
|
||||
<progress style="margin: 0 0.5rem;"
|
||||
value="{{balances.expectedIncome}}"
|
||||
max="{{balances.maxProgressBarValue}}">{{balances.expectedIncomeLabel}}</progress>
|
||||
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
|
||||
<progress style="margin: 0 0.5rem;"
|
||||
value="{{balances.actualIncome}}"
|
||||
max="{{balances.maxProgressBarValue}}">{{balances.actualIncomeLabel}}</progress>
|
||||
<p>Expected expenses: {{balances.expectedExpensesLabel}}</p>
|
||||
<progress style="margin: 0 0.5rem;"
|
||||
value="{{balances.expectedExpenses}}"
|
||||
max="{{balances.maxProgressBarValue}}">{{balances.expectedExpensesLabel}}</progress>
|
||||
<p>Actual Income: {{balances.actualIncomeLabel}}</p>
|
||||
<progress style="margin: 0 0.5rem;"
|
||||
value="{{balances.actualExpenses}}"
|
||||
max="{{balances.maxProgressBarValue}}">{{balances.actualExpensesLabel}}</progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<header class="row">
|
||||
<h3>Income</h3>
|
||||
<a href="/budgets/{{budget.id}}/categories/new?expense=false" class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
|
||||
</a>
|
||||
</header>
|
||||
<ul>
|
||||
{{#incomeCategories}}
|
||||
{{>partials/category-list}}
|
||||
{{/incomeCategories}}
|
||||
</ul>
|
||||
<details>
|
||||
<summary>Archived</summary>
|
||||
<ul>
|
||||
{{#archivedIncomeCategories}}
|
||||
{{>partials/category-list}}
|
||||
{{/archivedIncomeCategories}}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
<div class="card">
|
||||
<header class="row">
|
||||
<h3>Expenses</h3>
|
||||
<a href="/budgets/{{budget.id}}/categories/new?expense=true" class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Category">+</span> <span class="hide-small" aria-hidden="true">New Category</span>
|
||||
</a>
|
||||
</header>
|
||||
<ul>
|
||||
{{#expenseCategories}}
|
||||
{{>partials/category-list}}
|
||||
{{/expenseCategories}}
|
||||
</ul>
|
||||
<details>
|
||||
<summary>Archived</summary>
|
||||
<ul>
|
||||
{{#archivedExpenseCategories}}
|
||||
{{>partials/category-list}}
|
||||
{{/archivedExpenseCategories}}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
19
web/src/main/resources/templates/budget-form.mustache
Normal file
|
@ -0,0 +1,19 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<main>
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
|
@ -0,0 +1,29 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
{{>partials/sidebar}}
|
||||
<main>
|
||||
<div class="column">
|
||||
<header class="row">
|
||||
<a id="hamburger" href="#sidebar">☰</a>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="row">
|
||||
<a href="/budgets/{{budget.id}}/transactions/new"
|
||||
class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Transaction">+</span> <span
|
||||
aria-hidden="true">New Transaction</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div class="card">
|
||||
<!-- TODO: Add a search bar to filter transactions by name/description -->
|
||||
<ul>
|
||||
{{#transactions}}
|
||||
{{>partials/transaction-list}}
|
||||
{{/transactions}}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
9
web/src/main/resources/templates/budgets.mustache
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="flex-full-width">
|
||||
{{>partials/budget-list}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{>partials/foot}}
|
60
web/src/main/resources/templates/category-details.mustache
Normal file
|
@ -0,0 +1,60 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
{{>partials/sidebar}}
|
||||
<main>
|
||||
<div class="column">
|
||||
<header class="row">
|
||||
<a id="hamburger" href="#sidebar">☰</a>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="row">
|
||||
<a href="/budgets/{{budget.id}}/transactions/new?categoryId={{category.category.id}}"
|
||||
class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Transaction">+</span> <span aria-hidden="true">New Transaction</span>
|
||||
</a>
|
||||
<a href="/budgets/{{budget.id}}/categories/{{category.category.id}}/edit"
|
||||
class="button button-secondary" style="margin-right: 10px;">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="Edit Category">✎</span> <span aria-hidden="true">Edit Category</span>
|
||||
</a>
|
||||
<form action="/budgets/{{budget.id}}/categories/{{category.category.id}}/delete" method="post">
|
||||
<!-- TODO: Show confirmation dialog before actually deleting -->
|
||||
<button class="button button-danger" style="margin-right: 10px;">
|
||||
<span aria-description="Delete Category">🗑</span> <span class="hide-small"
|
||||
aria-hidden="true">Delete Category</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
<p>{{category.category.description}}</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Budgeted</p>
|
||||
<p class="body-large">{{category.amountLabel}}</p>
|
||||
</div>
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Actual</p>
|
||||
<p class="body-large">{{category.balanceLabel}}</p>
|
||||
</div>
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Remaining</p>
|
||||
<p class="body-large">{{category.remainingAmountLabel}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<progress value="{{category.balance}}"
|
||||
max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
|
||||
</div>
|
||||
<div class="card">
|
||||
<!-- TODO: Add a search bar to filter transactions by name/description -->
|
||||
<h3>Transactions</h3>
|
||||
<ul>
|
||||
{{#transactions}}
|
||||
{{>partials/transaction-list}}
|
||||
{{/transactions}}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
31
web/src/main/resources/templates/category-form.mustache
Normal file
|
@ -0,0 +1,31 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
{{#error }}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/error}}
|
||||
<form method="post">
|
||||
<label for="title">Name</label>
|
||||
<input id="title" type="text" name="title" value="{{ category.title }}" required/>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description">{{ category.description }}</textarea>
|
||||
<label for="amount">Amount</label>
|
||||
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" step="0.01" required/>
|
||||
<div class="inline-input">
|
||||
<input id="expense" type="radio" name="expense" value="true" {{expenseChecked}} />
|
||||
<label for="expense">Expense</label>
|
||||
</div>
|
||||
<div class="inline-input">
|
||||
<input id="income" type="radio" name="expense" value="false" {{incomeChecked}} />
|
||||
<label for="income">Income</label>
|
||||
</div>
|
||||
<div class="inline-input">
|
||||
<input id="archived" type="checkbox" name="archived" {{archivedChecked}} />
|
||||
<label for="archived">Archived</label>
|
||||
</div>
|
||||
<input id="submit" type="submit" class="button button-primary" value="Save"/>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
11
web/src/main/resources/templates/index.mustache
Normal file
|
@ -0,0 +1,11 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
<div class="flex-full-width">
|
||||
<a href="/login" class="button button-primary">Login</a>
|
||||
<a href="/register" class="button button-secondary">Register</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{>partials/foot}}
|
22
web/src/main/resources/templates/login.mustache
Normal file
|
@ -0,0 +1,22 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
{{#error }}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/error}}
|
||||
<form action="/login" method="post">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" type="text" name="username" value="{{ username }}"/>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" type="password" name="password"/>
|
||||
<input id="submit" type="submit" class="button button-primary" value="Login"/>
|
||||
</form>
|
||||
<a href="/resetpassword" style="padding: var(--input-padding)">Forgot your password?</a>
|
||||
<a href="/register" style="padding: var(--input-padding)">Create an account</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
|
@ -0,0 +1,20 @@
|
|||
<ul>
|
||||
{{#budgets}}
|
||||
<li class="list-item">
|
||||
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
|
||||
<span class="body-medium">{{name}}</span>
|
||||
<span class="body-small">{{description}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{/budgets}}
|
||||
<li class="list-item">
|
||||
<a href="/budgets/new">
|
||||
<span class="body-medium">+ New Budget</span>
|
||||
</a>
|
||||
</li>
|
||||
</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}}
|
|
@ -0,0 +1,9 @@
|
|||
<li class="list-item">
|
||||
<a href="/budgets/{{category.budgetId}}/categories/{{category.id}}">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<span class="body-large">{{category.title}}</span>
|
||||
<span class="body-small">{{remainingAmountLabel}} remaining</span>
|
||||
</div>
|
||||
<progress value="{{balance}}" max="{{category.amount}}">{{balanceLabel}}</progress>
|
||||
</a>
|
||||
</li>
|
3
web/src/main/resources/templates/partials/foot.mustache
Normal file
|
@ -0,0 +1,3 @@
|
|||
<script type="text/javascript" async src="/js/index.js"></script>
|
||||
</body>
|
||||
</html>
|