Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
6bbb421167 | |||
04802434fd | |||
f3edf5044b | |||
f0e937e8b4 |
99 changed files with 1250 additions and 5170 deletions
28
.gitignore
vendored
28
.gitignore
vendored
|
@ -1,33 +1,15 @@
|
|||
HELP.md
|
||||
/target/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
### STS ###
|
||||
.apt_generated
|
||||
.classpath
|
||||
.factorypath
|
||||
.project
|
||||
.settings
|
||||
.springBeans
|
||||
.sts4-cache
|
||||
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
### NetBeans ###
|
||||
/nbproject/private/
|
||||
/nbbuild/
|
||||
/dist/
|
||||
/nbdist/
|
||||
/.nb-gradle/
|
||||
/build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
.gradle
|
||||
*.sql
|
||||
|
||||
tags
|
||||
twigs.db
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
|
1218
Cargo.lock
generated
Normal file
1218
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
9
Cargo.toml
Normal file
9
Cargo.toml
Normal file
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "twigs"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
1
api/.gitignore
vendored
1
api/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,19 +0,0 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
`java-library`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(project(":core"))
|
||||
implementation(project(":storage"))
|
||||
api(libs.ktor.server.core)
|
||||
api(libs.ktor.serialization)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
testImplementation(project(":testhelpers"))
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -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,33 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.*
|
||||
|
||||
@Serializable
|
||||
data class BudgetRequest(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val users: Set<UserPermissionRequest>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BudgetResponse(
|
||||
val id: String,
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val users: List<UserPermissionResponse>
|
||||
) {
|
||||
constructor(budget: Budget, users: Iterable<UserPermission>) : this(
|
||||
Objects.requireNonNull<String>(budget.id),
|
||||
budget.name,
|
||||
budget.description,
|
||||
users.map { userPermission: UserPermission ->
|
||||
UserPermissionResponse(
|
||||
userPermission.userId,
|
||||
userPermission.permission
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
|
@ -1,125 +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.model.UserPermission
|
||||
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.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Application.budgetRoutes(
|
||||
budgetRepository: BudgetRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
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
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
delete("/{id}") {
|
||||
budgetWithPermission(
|
||||
budgetRepository,
|
||||
permissionRepository,
|
||||
budgetId = call.parameters["id"]!!,
|
||||
Permission.OWNER
|
||||
) { budget ->
|
||||
budgetRepository.delete(budget)
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class CategoryRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val amount: Long? = null,
|
||||
val budgetId: String? = null,
|
||||
val expense: Boolean? = null,
|
||||
val archived: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CategoryResponse(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val amount: Long,
|
||||
val budgetId: String,
|
||||
val expense: Boolean,
|
||||
val archived: Boolean
|
||||
)
|
||||
|
||||
fun Category.asResponse(): CategoryResponse = CategoryResponse(
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
amount,
|
||||
budgetId,
|
||||
expense,
|
||||
archived
|
||||
)
|
|
@ -1,141 +0,0 @@
|
|||
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 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.*
|
||||
|
||||
fun Application.categoryRoutes(
|
||||
categoryRepository: CategoryRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
routing {
|
||||
route("/api/categories") {
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetIds"),
|
||||
userId = session.userId
|
||||
).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
call.respond(emptyList<CategoryResponse>())
|
||||
return@get
|
||||
}
|
||||
call.respond(categoryRepository.findAll(
|
||||
budgetIds = budgetIds,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean(),
|
||||
archived = call.request.queryParameters["archived"]?.toBoolean()
|
||||
).map { it.asResponse() })
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val budgetIds = permissionRepository.findAll(userId = session.userId).map { it.budgetId }
|
||||
if (budgetIds.isEmpty()) {
|
||||
errorResponse()
|
||||
return@get
|
||||
}
|
||||
categoryRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = budgetIds
|
||||
)
|
||||
.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
|
||||
}
|
||||
if (request.budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
categoryRepository.save(
|
||||
Category(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<CategoryRequest>()
|
||||
val category = categoryRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
call.respond(HttpStatusCode.NotFound)
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
category.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
}
|
||||
call.respond(
|
||||
categoryRepository.save(
|
||||
category.copy(
|
||||
title = request.title ?: category.title,
|
||||
description = request.description ?: category.description,
|
||||
amount = request.amount ?: category.amount,
|
||||
expense = request.expense ?: category.expense,
|
||||
archived = request.archived ?: category.archived,
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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,161 +0,0 @@
|
|||
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 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)
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Serializable
|
||||
data class RecurringTransactionRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val amount: Long? = null,
|
||||
val categoryId: String? = null,
|
||||
val expense: Boolean? = null,
|
||||
val budgetId: String? = null,
|
||||
val frequency: String,
|
||||
val start: String? = null,
|
||||
val finish: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecurringTransactionResponse(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val frequency: String,
|
||||
val start: String,
|
||||
val finish: String?,
|
||||
val amount: Long?,
|
||||
val expense: Boolean?,
|
||||
val budgetId: String,
|
||||
val categoryId: String?,
|
||||
val createdBy: String
|
||||
)
|
||||
|
||||
fun RecurringTransaction.asResponse(): RecurringTransactionResponse = RecurringTransactionResponse(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
frequency = frequency.toString(),
|
||||
start = start.truncatedTo(ChronoUnit.SECONDS).toString(),
|
||||
finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
budgetId = budgetId,
|
||||
categoryId = categoryId,
|
||||
createdBy = createdBy
|
||||
)
|
|
@ -1,44 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
@Serializable
|
||||
data class TransactionRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
val date: String? = null,
|
||||
val amount: Long? = null,
|
||||
val categoryId: String? = null,
|
||||
val expense: Boolean? = null,
|
||||
val budgetId: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TransactionResponse(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val date: String,
|
||||
val amount: Long?,
|
||||
val expense: Boolean?,
|
||||
val budgetId: String,
|
||||
val categoryId: String?,
|
||||
val createdBy: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BalanceResponse(val balance: Long)
|
||||
|
||||
fun Transaction.asResponse(): TransactionResponse = TransactionResponse(
|
||||
id = id,
|
||||
title = title,
|
||||
description = description,
|
||||
date = date.truncatedTo(ChronoUnit.SECONDS).toString(),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
budgetId = budgetId,
|
||||
categoryId = categoryId,
|
||||
createdBy = createdBy
|
||||
)
|
|
@ -1,171 +0,0 @@
|
|||
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 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
|
||||
|
||||
fun Application.transactionRoutes(
|
||||
transactionRepository: TransactionRepository,
|
||||
permissionRepository: PermissionRepository
|
||||
) {
|
||||
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 },
|
||||
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() })
|
||||
}
|
||||
|
||||
get("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val transaction = transactionRepository.findAll(
|
||||
ids = call.parameters.getAll("id"),
|
||||
budgetIds = permissionRepository.findAll(
|
||||
userId = session.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"
|
||||
)
|
||||
return@get
|
||||
}
|
||||
transactionRepository.sumByCategory(categoryId, from, to)
|
||||
} else if (!budgetId.isNullOrBlank()) {
|
||||
transactionRepository.sumByBudget(budgetId, from, to)
|
||||
} else {
|
||||
errorResponse(HttpStatusCode.BadRequest, "budgetId or categoryId must be provided to sum")
|
||||
return@get
|
||||
}
|
||||
call.respond(BalanceResponse(balance))
|
||||
}
|
||||
|
||||
post {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
if (request.title.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Title cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
if (request.budgetId.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Budget ID cannot be null or empty")
|
||||
return@post
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
request.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
transactionRepository.save(
|
||||
Transaction(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
amount = request.amount ?: 0L,
|
||||
expense = request.expense ?: true,
|
||||
budgetId = request.budgetId,
|
||||
categoryId = request.categoryId,
|
||||
createdBy = session.userId,
|
||||
date = request.date?.let { Instant.parse(it) } ?: Instant.now()
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<TransactionRequest>()
|
||||
val transaction = transactionRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse()
|
||||
return@put
|
||||
}
|
||||
requireBudgetWithPermission(
|
||||
permissionRepository,
|
||||
session.userId,
|
||||
transaction.budgetId,
|
||||
Permission.WRITE
|
||||
) {
|
||||
return@put
|
||||
}
|
||||
call.respond(
|
||||
transactionRepository.save(
|
||||
transaction.copy(
|
||||
title = request.title ?: transaction.title,
|
||||
description = request.description ?: transaction.description,
|
||||
amount = request.amount ?: transaction.amount,
|
||||
expense = request.expense ?: transaction.expense,
|
||||
date = request.date?.let { Instant.parse(it) } ?: transaction.date,
|
||||
categoryId = request.categoryId ?: transaction.categoryId,
|
||||
budgetId = request.budgetId ?: transaction.budgetId,
|
||||
createdBy = transaction.createdBy,
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
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)) {
|
||||
HttpStatusCode.NoContent
|
||||
} else {
|
||||
HttpStatusCode.InternalServerError
|
||||
}
|
||||
call.respond(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.model.User
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UserRequest(
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
val email: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoginRequest(val username: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
data class UserResponse(val id: String, val username: String, val email: String?)
|
||||
|
||||
@Serializable
|
||||
data class UserPermissionRequest(
|
||||
val user: String,
|
||||
val permission: Permission = Permission.READ
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserPermissionResponse(val user: String, val permission: Permission?)
|
||||
|
||||
@Serializable
|
||||
data class SessionResponse(val userId: String, val token: String, val expiration: String)
|
||||
|
||||
/**
|
||||
* Used to request the password reset email
|
||||
*/
|
||||
@Serializable
|
||||
data class ResetPasswordRequest(val username: String)
|
||||
|
||||
/**
|
||||
* Used to modify the user's password after receiving the password reset email
|
||||
*/
|
||||
@Serializable
|
||||
data class PasswordResetRequest(val token: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
data class PasswordResetTokenResponse(val userId: String, val id: String, val expiration: String)
|
||||
|
||||
fun User.asResponse(): UserResponse = UserResponse(id, name, email)
|
||||
|
||||
fun Session.asResponse(): SessionResponse = SessionResponse(userId, token, expiration.toString())
|
||||
|
||||
fun PasswordResetToken.asResponse(): PasswordResetTokenResponse =
|
||||
PasswordResetTokenResponse(userId, id, expiration.toString())
|
|
@ -1,202 +0,0 @@
|
|||
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.PasswordResetRepository
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
import com.wbrawner.twigs.storage.UserRepository
|
||||
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
|
||||
|
||||
fun Application.userRoutes(
|
||||
emailService: EmailService,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
userRepository: UserRepository
|
||||
) {
|
||||
routing {
|
||||
route("/api/users") {
|
||||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
val user =
|
||||
userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
.firstOrNull()
|
||||
?: userRepository.findAll(nameOrEmail = request.username, password = request.password.hash())
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.Unauthorized, "Invalid credentials")
|
||||
return@post
|
||||
}
|
||||
val session = sessionRepository.save(Session(userId = user.id))
|
||||
call.respond(session.asResponse())
|
||||
}
|
||||
|
||||
post("/register") {
|
||||
val request = call.receive<UserRequest>()
|
||||
if (request.username.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
if (request.password.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
val existingUser = userRepository.findAll(nameOrEmail = request.username).firstOrNull()
|
||||
?: request.email?.let {
|
||||
return@let if (it.isBlank()) {
|
||||
null
|
||||
} else {
|
||||
userRepository.findAll(nameOrEmail = it).firstOrNull()
|
||||
}
|
||||
}
|
||||
existingUser?.let {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username or email already taken")
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
userRepository.save(
|
||||
User(
|
||||
name = request.username,
|
||||
password = request.password.hash(),
|
||||
email = if (request.email.isNullOrBlank()) null else request.email
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
authenticate(optional = false) {
|
||||
get {
|
||||
val query = call.request.queryParameters.getAll("query")
|
||||
if (query?.firstOrNull()?.isNotBlank() == true) {
|
||||
call.respond(userRepository.findAll(nameLike = query.first()).map { it.asResponse() })
|
||||
return@get
|
||||
}
|
||||
permissionRepository.findAll(
|
||||
budgetIds = call.request.queryParameters.getAll("budgetId")
|
||||
).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)
|
||||
}
|
||||
|
||||
post {
|
||||
val request = call.receive<UserRequest>()
|
||||
if (request.username.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Username must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
if (request.password.isNullOrBlank()) {
|
||||
errorResponse(HttpStatusCode.BadRequest, "Password must not be null or blank")
|
||||
return@post
|
||||
}
|
||||
call.respond(
|
||||
userRepository.save(
|
||||
User(
|
||||
name = request.username,
|
||||
password = request.password,
|
||||
email = request.email
|
||||
)
|
||||
).asResponse()
|
||||
)
|
||||
}
|
||||
|
||||
put("/{id}") {
|
||||
val session = call.principal<Session>()!!
|
||||
val request = call.receive<UserRequest>()
|
||||
// TODO: Add some kind of admin denotation to allow admins to edit other users
|
||||
if (call.parameters["id"] != session.userId) {
|
||||
errorResponse(HttpStatusCode.Forbidden)
|
||||
return@put
|
||||
}
|
||||
call.respond(
|
||||
userRepository.save(
|
||||
userRepository.findAll(ids = call.parameters.getAll("id"))
|
||||
.first()
|
||||
.run {
|
||||
copy(
|
||||
name = request.username ?: name,
|
||||
password = request.password?.hash() ?: 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
|
||||
}
|
||||
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 ?: return@let
|
||||
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 = request.password.hash()))
|
||||
passwordResetRepository.delete(passwordResetToken)
|
||||
}
|
||||
?: run {
|
||||
errorResponse(HttpStatusCode.InternalServerError, "Invalid token")
|
||||
return@post
|
||||
}
|
||||
call.respond(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
app/.gitignore
vendored
1
app/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,52 +0,0 @@
|
|||
import java.net.URI
|
||||
|
||||
plugins {
|
||||
java
|
||||
kotlin("jvm")
|
||||
application
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = URI("https://repo.maven.apache.org/maven2")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":api"))
|
||||
implementation(project(":core"))
|
||||
implementation(project(":db"))
|
||||
implementation(project(":web"))
|
||||
implementation(libs.kotlin.reflect)
|
||||
implementation(libs.bundles.ktor.server)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.logback)
|
||||
implementation(libs.mail)
|
||||
testImplementation(project(":testhelpers"))
|
||||
testImplementation(libs.ktor.client.content.negotiation)
|
||||
testImplementation(libs.ktor.server.test)
|
||||
}
|
||||
|
||||
description = "twigs-server"
|
||||
|
||||
val twigsMain = "com.wbrawner.twigs.server.ApplicationKt"
|
||||
|
||||
application {
|
||||
mainClass.set(twigsMain)
|
||||
}
|
||||
|
||||
tasks.shadowJar {
|
||||
manifest {
|
||||
attributes("Main-Class" to twigsMain)
|
||||
archiveBaseName.set("twigs")
|
||||
archiveClassifier.set("")
|
||||
archiveVersion.set("")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -1,206 +0,0 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import ch.qos.logback.classic.Level
|
||||
import com.wbrawner.twigs.*
|
||||
import com.wbrawner.twigs.db.*
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.storage.*
|
||||
import com.wbrawner.twigs.web.webRoutes
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
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.plugins.callloging.*
|
||||
import io.ktor.server.plugins.contentnegotiation.*
|
||||
import io.ktor.server.plugins.cors.routing.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.sessions.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
fun main() {
|
||||
embeddedServer(CIO, port = System.getenv("PORT")?.toIntOrNull() ?: 8080) {
|
||||
module()
|
||||
}.start(wait = true)
|
||||
}
|
||||
|
||||
private const val DATABASE_VERSION = 3
|
||||
|
||||
fun Application.module() {
|
||||
val dbType = environment.config.propertyOrNull("twigs.database.type")?.getString() ?: "sqlite"
|
||||
val dbHost = environment.config.propertyOrNull("twigs.database.host")?.getString() ?: "localhost"
|
||||
val dbPort = environment.config.propertyOrNull("twigs.database.port")?.getString() ?: "5432"
|
||||
val dbName = environment.config.propertyOrNull("twigs.database.name")?.getString() ?: "twigs"
|
||||
val dbUser = environment.config.propertyOrNull("twigs.database.user")?.getString() ?: "twigs"
|
||||
val dbPass = environment.config.propertyOrNull("twigs.database.password")?.getString() ?: "twigs"
|
||||
val jdbcUrl = when (dbType) {
|
||||
"postgresql" -> {
|
||||
"jdbc:$dbType://$dbHost:$dbPort/$dbName?stringtype=unspecified"
|
||||
}
|
||||
"sqlite" -> {
|
||||
Class.forName("org.sqlite.JDBC")
|
||||
"jdbc:$dbType:$dbName"
|
||||
}
|
||||
else -> {
|
||||
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
|
||||
password = dbPass
|
||||
}).also {
|
||||
moduleWithDependencies(
|
||||
emailService = SmtpEmailService(
|
||||
from = environment.config.propertyOrNull("twigs.smtp.from")?.getString(),
|
||||
host = environment.config.propertyOrNull("twigs.smtp.host")?.getString(),
|
||||
port = environment.config.propertyOrNull("twigs.smtp.port")?.getString()?.toIntOrNull(),
|
||||
username = environment.config.propertyOrNull("twigs.smtp.user")?.getString(),
|
||||
password = environment.config.propertyOrNull("twigs.smtp.pass")?.getString(),
|
||||
),
|
||||
metadataRepository = JdbcMetadataRepository(it),
|
||||
budgetRepository = JdbcBudgetRepository(it),
|
||||
categoryRepository = JdbcCategoryRepository(it),
|
||||
passwordResetRepository = JdbcPasswordResetRepository(it),
|
||||
permissionRepository = JdbcPermissionRepository(it),
|
||||
recurringTransactionRepository = JdbcRecurringTransactionRepository(it),
|
||||
sessionRepository = JdbcSessionRepository(it),
|
||||
transactionRepository = JdbcTransactionRepository(it),
|
||||
userRepository = JdbcUserRepository(it)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.moduleWithDependencies(
|
||||
emailService: EmailService,
|
||||
metadataRepository: MetadataRepository,
|
||||
budgetRepository: BudgetRepository,
|
||||
categoryRepository: CategoryRepository,
|
||||
passwordResetRepository: PasswordResetRepository,
|
||||
permissionRepository: PermissionRepository,
|
||||
recurringTransactionRepository: RecurringTransactionRepository,
|
||||
sessionRepository: SessionRepository,
|
||||
transactionRepository: TransactionRepository,
|
||||
userRepository: UserRepository
|
||||
) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
install(Sessions) {
|
||||
header<Session>("Authorization") {
|
||||
serializer = object : SessionSerializer<Session> {
|
||||
override fun deserialize(text: String): Session {
|
||||
this@moduleWithDependencies.environment.log.info("Deserializing session!")
|
||||
return Session(token = text.substringAfter("Bearer "))
|
||||
}
|
||||
|
||||
override fun serialize(session: Session): String = session.token
|
||||
}
|
||||
}
|
||||
}
|
||||
install(ContentNegotiation) {
|
||||
json(json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
explicitNulls = false
|
||||
isLenient = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
allowStructuredMapKeys = true
|
||||
prettyPrint = false
|
||||
useArrayPolymorphism = true
|
||||
})
|
||||
}
|
||||
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.Put)
|
||||
allowMethod(HttpMethod.Delete)
|
||||
allowHeader(HttpHeaders.Authorization)
|
||||
allowHeader(HttpHeaders.Accept)
|
||||
allowHeader(HttpHeaders.AcceptEncoding)
|
||||
allowHeader(HttpHeaders.AcceptLanguage)
|
||||
allowHeader(HttpHeaders.Connection)
|
||||
allowHeader(HttpHeaders.ContentType)
|
||||
allowHeader(HttpHeaders.Host)
|
||||
allowHeader(HttpHeaders.Origin)
|
||||
allowHeader(HttpHeaders.AccessControlRequestHeaders)
|
||||
allowHeader(HttpHeaders.AccessControlRequestMethod)
|
||||
allowHeader("Sec-Fetch-Dest")
|
||||
allowHeader("Sec-Fetch-Mode")
|
||||
allowHeader("Sec-Fetch-Site")
|
||||
allowHeader("sec-ch-ua")
|
||||
allowHeader("sec-ch-ua-mobile")
|
||||
allowHeader("sec-ch-ua-platform")
|
||||
allowHeader(HttpHeaders.UserAgent)
|
||||
allowHeader("DNT")
|
||||
allowCredentials = true
|
||||
}
|
||||
budgetRoutes(budgetRepository, permissionRepository)
|
||||
categoryRoutes(categoryRepository, permissionRepository)
|
||||
recurringTransactionRoutes(recurringTransactionRepository, permissionRepository)
|
||||
transactionRoutes(transactionRepository, permissionRepository)
|
||||
userRoutes(emailService, passwordResetRepository, permissionRepository, sessionRepository, userRepository)
|
||||
webRoutes()
|
||||
launch {
|
||||
val metadata = (metadataRepository.findAll().firstOrNull() ?: DatabaseMetadata())
|
||||
var version = metadata.version
|
||||
while (currentCoroutineContext().isActive && version++ < DATABASE_VERSION) {
|
||||
metadataRepository.runMigration(version)
|
||||
metadataRepository.save(metadata.copy(version = version))
|
||||
}
|
||||
salt = metadata.salt.ifEmpty {
|
||||
metadataRepository.save(
|
||||
metadata.copy(
|
||||
salt = environment.config
|
||||
.propertyOrNull("twigs.password.salt")
|
||||
?.getString()
|
||||
?: randomString(16)
|
||||
)
|
||||
).salt
|
||||
}
|
||||
val jobs = listOf(
|
||||
SessionCleanupJob(sessionRepository),
|
||||
RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
)
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
delay(TimeUnit.HOURS.toMillis(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Job {
|
||||
suspend fun run()
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import com.wbrawner.twigs.model.Position
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.time.*
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
import kotlin.math.ceil
|
||||
|
||||
class RecurringTransactionProcessingJob(
|
||||
private val recurringTransactionRepository: RecurringTransactionRepository,
|
||||
private val transactionRepository: TransactionRepository
|
||||
) : Job {
|
||||
override suspend fun run() {
|
||||
val now = Instant.now()
|
||||
val maxDaysInMonth = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
createTransactions(now, maxDaysInMonth)
|
||||
}
|
||||
|
||||
suspend fun createTransactions(now: Instant, maxDaysInMonth: Int) {
|
||||
recurringTransactionRepository.findAll(now).forEach {
|
||||
val zonedNow = now.atZone(ZoneId.of("UTC"))
|
||||
when (it.frequency) {
|
||||
is Frequency.Daily -> {
|
||||
if (it.lastRun != null && ChronoUnit.DAYS.between(it.lastRun, now) < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
is Frequency.Weekly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
if (ChronoUnit.WEEKS.between(zonedLastRun, zonedNow) < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
if (!(it.frequency as Frequency.Weekly).daysOfWeek.contains(DayOfWeek.from(zonedNow)))
|
||||
return@forEach
|
||||
}
|
||||
is Frequency.Monthly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
val monthsPassed = ((zonedNow.year * 12) + zonedNow.monthValue) - ((zonedLastRun.year * 12) + zonedLastRun.monthValue)
|
||||
if (monthsPassed < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
val frequency = (it.frequency as Frequency.Monthly).dayOfMonth
|
||||
frequency.day?.let { day ->
|
||||
if (zonedNow.dayOfMonth != Integer.min(day, maxDaysInMonth))
|
||||
return@forEach
|
||||
}
|
||||
frequency.positionalDayOfWeek?.let { positionalDayOfWeek ->
|
||||
if (positionalDayOfWeek.dayOfWeek != DayOfWeek.from(now.atZone(ZoneId.of("UTC"))))
|
||||
return@forEach
|
||||
val dayOfMonth = now.atZone(ZoneId.of("UTC")).dayOfMonth
|
||||
val position = ceil(dayOfMonth / 7.0).toInt()
|
||||
when (positionalDayOfWeek.position) {
|
||||
Position.FIRST -> if (position != 1) return@forEach
|
||||
Position.SECOND -> if (position != 2) return@forEach
|
||||
Position.THIRD -> if (position != 3) return@forEach
|
||||
Position.FOURTH -> if (position != 4) return@forEach
|
||||
Position.LAST -> {
|
||||
if (dayOfMonth + 7 <= maxDaysInMonth)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is Frequency.Yearly -> {
|
||||
it.lastRun?.let { last ->
|
||||
val zonedLastRun = last.atZone(ZoneId.of("UTC"))
|
||||
if (zonedNow.year - zonedLastRun.year < it.frequency.count)
|
||||
return@forEach
|
||||
}
|
||||
with((it.frequency as Frequency.Yearly).dayOfYear) {
|
||||
// If the user has selected Feb 29th, then on non-leap years we'll adjust the date to Feb 28th
|
||||
val adjustedMonthDay =
|
||||
if (this.month == Month.FEBRUARY && this.dayOfMonth == 29 && !Year.isLeap(zonedNow.year.toLong())) {
|
||||
MonthDay.of(2, 28)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
if (MonthDay.from(zonedNow) != adjustedMonthDay)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
transactionRepository.save(it.toTransaction(now))
|
||||
recurringTransactionRepository.save(it.copy(lastRun = now))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
|
||||
class SessionCleanupJob(private val sessionRepository: SessionRepository) : Job {
|
||||
override suspend fun run() {
|
||||
sessionRepository.deleteExpired()
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.EmailService
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import java.util.*
|
||||
import javax.mail.*
|
||||
import javax.mail.internet.InternetAddress
|
||||
import javax.mail.internet.MimeBodyPart
|
||||
import javax.mail.internet.MimeMessage
|
||||
import javax.mail.internet.MimeMultipart
|
||||
|
||||
|
||||
class SmtpEmailService(
|
||||
val from: String?,
|
||||
val host: String?,
|
||||
val port: Int?,
|
||||
val username: String?,
|
||||
val password: String?
|
||||
) : EmailService {
|
||||
private val canSendEmail = !from.isNullOrBlank()
|
||||
&& !host.isNullOrBlank()
|
||||
&& port != null
|
||||
&& !username.isNullOrBlank()
|
||||
&& !password.isNullOrBlank()
|
||||
|
||||
private val session = Session.getInstance(
|
||||
Properties().apply {
|
||||
put("mail.smtp.auth", "true")
|
||||
put("mail.smtp.host", host ?: "")
|
||||
put("mail.smtp.port", port ?: 25)
|
||||
put("mail.smtp.from", from ?: "")
|
||||
},
|
||||
object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
||||
return PasswordAuthentication(username, password)
|
||||
}
|
||||
})
|
||||
|
||||
override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) {
|
||||
val resetUrl = "twigs://resetpassword?token=${token.id}"
|
||||
val plainText = javaClass.getResource("/email/plain/passwordreset.txt")
|
||||
?.readText()
|
||||
?.replace("{reset_url}", resetUrl)
|
||||
val html = javaClass.getResource("/email/html/passwordreset.html")
|
||||
?.readText()
|
||||
?.replace("{reset_url}", resetUrl)
|
||||
sendEmail(
|
||||
plainText,
|
||||
html,
|
||||
to,
|
||||
"Twigs Password Reset" // TODO: Localization
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendEmail(plainText: String?, html: String?, to: String, subject: String) {
|
||||
if (!canSendEmail) return
|
||||
if (plainText.isNullOrBlank() && html.isNullOrBlank()) return
|
||||
val message = MimeMessage(session)
|
||||
message.setFrom(InternetAddress(from, "Twigs"))
|
||||
message.setRecipients(Message.RecipientType.TO, to)
|
||||
val multipart: Multipart = MimeMultipart("alternative").apply {
|
||||
plainText?.let {
|
||||
addBodyPart(it.asMimeBodyPart("text/plain; charset=utf-8"))
|
||||
}
|
||||
html?.let {
|
||||
addBodyPart(it.asMimeBodyPart("text/html; charset=utf-8"))
|
||||
}
|
||||
}
|
||||
message.setContent(multipart)
|
||||
message.subject = subject
|
||||
Transport.send(message)
|
||||
}
|
||||
|
||||
private fun String.asMimeBodyPart(mimeType: String): MimeBodyPart = MimeBodyPart().apply {
|
||||
setContent(this@asMimeBodyPart, mimeType)
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
ktor {
|
||||
deployment {
|
||||
port = 8080
|
||||
port = ${?PORT}
|
||||
}
|
||||
}
|
||||
|
||||
twigs {
|
||||
database {
|
||||
type = ${?TWIGS_DB_TYPE}
|
||||
host = ${?TWIGS_DB_HOST}
|
||||
port = ${?TWIGS_DB_PORT}
|
||||
name = ${?TWIGS_DB_NAME}
|
||||
user = ${?TWIGS_DB_USER}
|
||||
password = ${?TWIGS_DB_PASS}
|
||||
}
|
||||
password {
|
||||
salt = ${?TWIGS_PW_SALT}
|
||||
}
|
||||
smtp {
|
||||
from = ${?TWIGS_SMTP_FROM}
|
||||
host = ${?TWIGS_SMTP_HOST}
|
||||
port = ${?TWIGS_SMTP_PORT}
|
||||
user = ${?TWIGS_SMTP_USER}
|
||||
pass = ${?TWIGS_SMTP_PASS}
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Twigs - Reset Your Password</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"
|
||||
"/>
|
||||
</head>
|
||||
<body>
|
||||
<style>
|
||||
html, body {
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
height: 100vh;
|
||||
background: white;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #30d158;
|
||||
}
|
||||
</style>
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="https://twigs.wbrawner.com/favicon-96x96.png"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h1>Reset Your Password</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
If you requested a password reset, please <a href="{reset_url}" target="_blank">click here</a> to complete
|
||||
the
|
||||
process. If
|
||||
you didn't make this request,
|
||||
you can ignore this email. Alternatively, you can copy and paste the link below in your browser:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<pre>{reset_url}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -1,5 +0,0 @@
|
|||
Reset Your Password
|
||||
|
||||
If you requested a password reset, please open the link below to complete the process. If you didn't make this request, you can ignore this email.
|
||||
|
||||
{reset_url}
|
|
@ -1,597 +0,0 @@
|
|||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "twigs API"
|
||||
description: "twigs API"
|
||||
version: "1.0.0"
|
||||
servers:
|
||||
- url: "https://twigs"
|
||||
paths:
|
||||
/api/recurringtransactions:
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "budgetId"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/components/schemas/RecurringTransactionResponse"
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecurringTransactionResponse"
|
||||
/api/recurringtransactions/{id}:
|
||||
delete:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
"500":
|
||||
description: "Internal Server Error"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecurringTransactionResponse"
|
||||
put:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/RecurringTransactionResponse"
|
||||
/api/categories:
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "budgetIds"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "expense"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "archived"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/CategoryResponse"
|
||||
/api/categories/{id}:
|
||||
delete:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/CategoryResponse"
|
||||
put:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"404":
|
||||
description: "Not Found"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/CategoryResponse"
|
||||
/api/passwordreset:
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
/api/resetpassword:
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"202":
|
||||
description: "Accepted"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
/api/users:
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "query"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "budgetId"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
/api/users/{id}:
|
||||
delete:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
put:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
/api/users/login:
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/SessionResponse"
|
||||
/api/users/register:
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserResponse"
|
||||
/api/budgets:
|
||||
get:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "array"
|
||||
items:
|
||||
type: "object"
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/BudgetResponse"
|
||||
/api/budgets/{id}:
|
||||
delete:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/BudgetResponse"
|
||||
put:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/BudgetResponse"
|
||||
/api/transactions:
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "budgetIds"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "categoryIds"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "from"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "to"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "expense"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/components/schemas/TransactionResponse"
|
||||
post:
|
||||
description: ""
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/TransactionResponse"
|
||||
/api/transactions/{id}:
|
||||
delete:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"204":
|
||||
description: "No Content"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
"500":
|
||||
description: "Internal Server Error"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
type: "object"
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/TransactionResponse"
|
||||
put:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/TransactionResponse"
|
||||
/api/transactions/sum:
|
||||
get:
|
||||
description: ""
|
||||
parameters:
|
||||
- name: "categoryId"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "budgetId"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "from"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
- name: "to"
|
||||
in: "query"
|
||||
required: false
|
||||
schema:
|
||||
type: "string"
|
||||
responses:
|
||||
"200":
|
||||
description: "OK"
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: "#/components/schemas/BalanceResponse"
|
||||
components:
|
||||
schemas:
|
||||
RecurringTransactionResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "string"
|
||||
title:
|
||||
type: "string"
|
||||
description:
|
||||
type: "string"
|
||||
frequency:
|
||||
type: "string"
|
||||
start:
|
||||
type: "string"
|
||||
finish:
|
||||
type: "string"
|
||||
amount:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
expense:
|
||||
type: "boolean"
|
||||
budgetId:
|
||||
type: "string"
|
||||
categoryId:
|
||||
type: "string"
|
||||
createdBy:
|
||||
type: "string"
|
||||
CategoryResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "string"
|
||||
title:
|
||||
type: "string"
|
||||
description:
|
||||
type: "string"
|
||||
amount:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
budgetId:
|
||||
type: "string"
|
||||
expense:
|
||||
type: "boolean"
|
||||
archived:
|
||||
type: "boolean"
|
||||
UserResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "string"
|
||||
username:
|
||||
type: "string"
|
||||
email:
|
||||
type: "string"
|
||||
SessionResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
userId:
|
||||
type: "string"
|
||||
token:
|
||||
type: "string"
|
||||
expiration:
|
||||
type: "string"
|
||||
BudgetResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "string"
|
||||
name:
|
||||
type: "string"
|
||||
description:
|
||||
type: "string"
|
||||
TransactionResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "string"
|
||||
title:
|
||||
type: "string"
|
||||
description:
|
||||
type: "string"
|
||||
date:
|
||||
type: "string"
|
||||
amount:
|
||||
type: "integer"
|
||||
format: "int64"
|
||||
expense:
|
||||
type: "boolean"
|
||||
budgetId:
|
||||
type: "string"
|
||||
categoryId:
|
||||
type: "string"
|
||||
createdBy:
|
||||
type: "string"
|
||||
BalanceResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
balance:
|
||||
type: "integer"
|
||||
format: "int64"
|
|
@ -1,356 +0,0 @@
|
|||
package com.wbrawner.twigs.server
|
||||
|
||||
import com.wbrawner.twigs.model.*
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeRecurringTransactionRepository
|
||||
import com.wbrawner.twigs.test.helpers.repository.FakeTransactionRepository
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.*
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.time.temporal.TemporalUnit
|
||||
import java.util.*
|
||||
|
||||
class RecurringTransactionProcessingJobTest {
|
||||
private lateinit var recurringTransactionRepository: RecurringTransactionRepository
|
||||
private lateinit var transactionRepository: TransactionRepository
|
||||
private lateinit var job: RecurringTransactionProcessingJob
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
recurringTransactionRepository = FakeRecurringTransactionRepository()
|
||||
transactionRepository = FakeTransactionRepository()
|
||||
job = RecurringTransactionProcessingJob(recurringTransactionRepository, transactionRepository)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily transactions are created every day`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Daily transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Daily(1, Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 3)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(3, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-02T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-01-03T09:00:00Z", createdTransactions[2].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily transactions are only created once per day`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Daily transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Daily(1, Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 72, ChronoUnit.HOURS)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(3, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-02T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-01-03T09:00:00Z", createdTransactions[2].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `daily transactions are created every other day`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Daily transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Daily(2, Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 3)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly transactions are created every thursday`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Weekly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Weekly(1, setOf(DayOfWeek.THURSDAY), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 28)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-08T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-01-15T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-01-22T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly transactions are created every third thursday`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Weekly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Weekly(3, setOf(DayOfWeek.THURSDAY), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 28)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-01-22T09:00:00Z", createdTransactions[1].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 1st of month`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(1, DayOfMonth.day(1), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 90)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(3, createdTransactions.size)
|
||||
assertEquals("1970-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-01T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-01T09:00:00Z", createdTransactions[2].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every last day of month when greater than max days in month`() =
|
||||
runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(1, DayOfMonth.day(31), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-28T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-30T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 6 months`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(6, DayOfMonth.day(15), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 197)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-01-15T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-07-15T09:00:00Z", createdTransactions[1].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every 2nd tuesday`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(
|
||||
1,
|
||||
DayOfMonth.positionalDayOfWeek(Position.SECOND, DayOfWeek.TUESDAY),
|
||||
Time(9, 0, 0)
|
||||
),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-13T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-10T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-10T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-14T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created every last friday`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(
|
||||
1,
|
||||
DayOfMonth.positionalDayOfWeek(Position.LAST, DayOfWeek.FRIDAY),
|
||||
Time(9, 0, 0)
|
||||
),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 120)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-01-30T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1970-02-27T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1970-03-27T09:00:00Z", createdTransactions[2].date.toString())
|
||||
assertEquals("1970-04-24T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monthly transactions are created in the new year`() = runTest {
|
||||
val start = Instant.parse("1971-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Monthly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Monthly(
|
||||
1,
|
||||
DayOfMonth.day(1),
|
||||
Time(9, 0, 0)
|
||||
),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId",
|
||||
lastRun = Instant.parse("1970-12-01T09:00:00Z")
|
||||
)
|
||||
)
|
||||
loopFor(start, 1)
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(1, createdTransactions.size)
|
||||
assertEquals("1971-01-01T09:00:00Z", createdTransactions[0].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every march 31st`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(1, MonthDay.of(3, 31), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1971-03-31T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1972-03-31T09:00:00Z", createdTransactions[2].date.toString()) // 1972 was a leap year
|
||||
assertEquals("1973-03-31T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every other march 31st`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(2, MonthDay.of(3, 31), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(2, createdTransactions.size)
|
||||
assertEquals("1970-03-31T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1972-03-31T09:00:00Z", createdTransactions[1].date.toString()) // 1972 was a leap year
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yearly transactions are created every february 29th`() = runTest {
|
||||
val start = Instant.parse("1970-01-01T00:00:00Z")
|
||||
recurringTransactionRepository.save(
|
||||
RecurringTransaction(
|
||||
title = "Yearly transaction",
|
||||
amount = 123,
|
||||
frequency = Frequency.Yearly(1, MonthDay.of(2, 29), Time(9, 0, 0)),
|
||||
expense = true,
|
||||
start = start,
|
||||
createdBy = "tester",
|
||||
budgetId = "budgetId"
|
||||
)
|
||||
)
|
||||
loopFor(start, 1460) // 4 years from Jan 1, 1970
|
||||
val createdTransactions = transactionRepository.findAll()
|
||||
assertEquals(4, createdTransactions.size)
|
||||
assertEquals("1970-02-28T09:00:00Z", createdTransactions[0].date.toString())
|
||||
assertEquals("1971-02-28T09:00:00Z", createdTransactions[1].date.toString())
|
||||
assertEquals("1972-02-29T09:00:00Z", createdTransactions[2].date.toString()) // 1972 was a leap year
|
||||
assertEquals("1973-02-28T09:00:00Z", createdTransactions[3].date.toString())
|
||||
}
|
||||
|
||||
private suspend fun loopFor(start: Instant, count: Int, timeUnit: TemporalUnit = ChronoUnit.DAYS) {
|
||||
if (count == 0) return
|
||||
val maxDays = GregorianCalendar.from(ZonedDateTime.ofInstant(start, ZoneId.of("UTC")))
|
||||
.getActualMaximum(Calendar.DAY_OF_MONTH)
|
||||
job.createTransactions(start, maxDays)
|
||||
loopFor(start.plus(1, timeUnit), count - 1, timeUnit)
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
package com.wbrawner.twigs.server.api
|
||||
|
||||
import com.wbrawner.twigs.server.moduleWithDependencies
|
||||
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 org.junit.jupiter.api.BeforeEach
|
||||
|
||||
open class ApiTest {
|
||||
lateinit var budgetRepository: FakeBudgetRepository
|
||||
lateinit var categoryRepository: FakeCategoryRepository
|
||||
lateinit var emailService: FakeEmailService
|
||||
lateinit var metadataRepository: FakeMetadataRepository
|
||||
lateinit var passwordResetRepository: FakePasswordResetRepository
|
||||
lateinit var permissionRepository: FakePermissionRepository
|
||||
lateinit var recurringTransactionRepository: FakeRecurringTransactionRepository
|
||||
lateinit var sessionRepository: FakeSessionRepository
|
||||
lateinit var transactionRepository: FakeTransactionRepository
|
||||
lateinit var userRepository: FakeUserRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
budgetRepository = FakeBudgetRepository()
|
||||
categoryRepository = FakeCategoryRepository()
|
||||
emailService = FakeEmailService()
|
||||
metadataRepository = FakeMetadataRepository()
|
||||
passwordResetRepository = FakePasswordResetRepository()
|
||||
permissionRepository = FakePermissionRepository()
|
||||
recurringTransactionRepository = FakeRecurringTransactionRepository()
|
||||
sessionRepository = FakeSessionRepository()
|
||||
transactionRepository = FakeTransactionRepository()
|
||||
userRepository = FakeUserRepository()
|
||||
}
|
||||
|
||||
fun apiTest(test: suspend ApiTest.(client: HttpClient) -> Unit) = testApplication {
|
||||
application {
|
||||
moduleWithDependencies(
|
||||
emailService = emailService,
|
||||
metadataRepository = metadataRepository,
|
||||
budgetRepository = budgetRepository,
|
||||
categoryRepository = categoryRepository,
|
||||
passwordResetRepository = passwordResetRepository,
|
||||
permissionRepository = permissionRepository,
|
||||
recurringTransactionRepository = recurringTransactionRepository,
|
||||
sessionRepository = sessionRepository,
|
||||
transactionRepository = transactionRepository,
|
||||
userRepository = userRepository
|
||||
)
|
||||
}
|
||||
val client = createClient {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
}
|
||||
test(client)
|
||||
}
|
||||
}
|
|
@ -1,423 +0,0 @@
|
|||
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 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() {
|
||||
|
||||
@Test
|
||||
fun `fetching budgets requires authentication`() = apiTest { client ->
|
||||
val response = client.get("/api/budgets")
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetching budgets returns empty list when there are no budgets`() = apiTest { client ->
|
||||
val session = Session()
|
||||
sessionRepository.save(session)
|
||||
val response = client.get("/api/budgets") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
assertEquals(0, response.body<List<BudgetResponse>>().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetching budgets only returns budgets for current user`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val currentUserBudget = budgetRepository.save(Budget(name = "Test User's Budget"))
|
||||
val otherUserBudget = budgetRepository.save(Budget(name = "Other User's Budget"))
|
||||
permissionRepository.save(
|
||||
UserPermission(
|
||||
budgetId = currentUserBudget.id,
|
||||
userId = users[0].id,
|
||||
Permission.OWNER
|
||||
)
|
||||
)
|
||||
permissionRepository.save(UserPermission(budgetId = otherUserBudget.id, userId = users[1].id, Permission.OWNER))
|
||||
val response = client.get("/api/budgets") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
}
|
||||
val returnedBudgets = response.body<List<BudgetResponse>>()
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
assertEquals(1, returnedBudgets.size)
|
||||
assertEquals(currentUserBudget.id, returnedBudgets.first().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creating budgets requires authentication`() = apiTest { client ->
|
||||
val request = BudgetRequest("Test Budget", "A budget for testing")
|
||||
val response = client.post("/api/budgets") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newly created budgets are saved`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermissionRequest(users[0].id, Permission.OWNER),
|
||||
UserPermissionRequest(users[1].id, Permission.READ),
|
||||
)
|
||||
val request = BudgetRequest("Test Budget", "A budget for testing", permissions)
|
||||
val response = client.post("/api/budgets") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val responseBody: BudgetResponse = response.body()
|
||||
assert(responseBody.id.isNotEmpty())
|
||||
assertEquals("Test Budget", responseBody.name)
|
||||
assertEquals("A budget for testing", responseBody.description)
|
||||
assertEquals(2, responseBody.users.size)
|
||||
assert(responseBody.users.containsAll(permissions.map { UserPermissionResponse(it.user, it.permission) }))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newly created budgets include current user as owner if omitted`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermissionRequest(users[1].id, Permission.OWNER),
|
||||
)
|
||||
val request = BudgetRequest("Test Budget", "A budget for testing", permissions)
|
||||
val response = client.post("/api/budgets") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val responseBody: BudgetResponse = response.body()
|
||||
assert(responseBody.id.isNotEmpty())
|
||||
assertEquals("Test Budget", responseBody.name)
|
||||
assertEquals("A budget for testing", responseBody.description)
|
||||
assertEquals(2, responseBody.users.size)
|
||||
val expectedPermissions = listOf(
|
||||
UserPermissionResponse(users[0].id, Permission.OWNER),
|
||||
UserPermissionResponse(users[1].id, Permission.OWNER),
|
||||
)
|
||||
assert(responseBody.users.containsAll(expectedPermissions))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updating budgets requires authentication`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val request = BudgetRequest("Update Budget", "A budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updating budgets returns forbidden for users with read only access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val request = BudgetRequest("Update Budget", "A budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updating budgets returns forbidden for users with write only access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val request = BudgetRequest("Update Budget", "A budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updating budgets returns success for users with manage access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val request = BudgetRequest("Update Budget", "An update budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val updatedBudget: BudgetResponse = response.body()
|
||||
assertEquals(request.name, updatedBudget.name)
|
||||
assertEquals(request.description, updatedBudget.description)
|
||||
val expectedUsers = permissions.map { UserPermissionResponse(it.userId, it.permission) }
|
||||
val updatedUsers = updatedBudget.users
|
||||
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 ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val request = BudgetRequest("Update Budget", "An update budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
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(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val request = BudgetRequest("Update Budget", "An update budget for testing")
|
||||
val response = client.put("/api/budgets/random-budget-id") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
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 ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget =
|
||||
budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val request = BudgetRequest(
|
||||
"Update Budget",
|
||||
"An update budget for testing",
|
||||
setOf(
|
||||
UserPermissionRequest(users[0].id, Permission.OWNER),
|
||||
UserPermissionRequest(users[0].id, Permission.MANAGE),
|
||||
)
|
||||
)
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting budgets requires authentication`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val request = BudgetRequest("Update Budget", "A budget for testing")
|
||||
val response = client.put("/api/budgets/${existingBudget.id}") {
|
||||
header("Content-Type", "application/json")
|
||||
setBody(request)
|
||||
}
|
||||
assertEquals(HttpStatusCode.Unauthorized, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting budgets returns forbidden for users with read only access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.READ),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val response = client.delete("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting budgets returns forbidden for users with write only access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.WRITE),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val response = client.delete("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting budgets returns forbidden for users with manage access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.MANAGE),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val response = client.delete("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
}
|
||||
assertEquals(HttpStatusCode.Forbidden, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleting budgets returns success for users with owner access`() = apiTest { client ->
|
||||
val users = listOf(
|
||||
User(name = "testuser", password = "testpassword"),
|
||||
User(name = "otheruser", password = "otherpassword"),
|
||||
)
|
||||
users.forEach { userRepository.save(it) }
|
||||
val existingBudget = budgetRepository.save(Budget(name = "Test Budget", description = "A budget for testing"))
|
||||
val session = Session(userId = users.first().id)
|
||||
sessionRepository.save(session)
|
||||
val permissions = setOf(
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[0].id, Permission.OWNER),
|
||||
UserPermission(budgetId = existingBudget.id, userId = users[1].id, Permission.OWNER),
|
||||
)
|
||||
permissions.forEach {
|
||||
permissionRepository.save(it)
|
||||
}
|
||||
val response = client.delete("/api/budgets/${existingBudget.id}") {
|
||||
header("Authorization", "Bearer ${session.token}")
|
||||
header("Content-Type", "application/json")
|
||||
}
|
||||
assertEquals(HttpStatusCode.NoContent, response.status)
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
import java.net.URI
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath(libs.kotlin.gradle)
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
java
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
maven {
|
||||
url = URI("https://repo.maven.apache.org/maven2")
|
||||
}
|
||||
}
|
||||
group = "com.wbrawner"
|
||||
version = "0.0.1-SNAPSHOT"
|
||||
tasks.withType<KotlinCompile> {
|
||||
kotlinOptions.jvmTarget = "16"
|
||||
}
|
||||
}
|
1
core/.gitignore
vendored
1
core/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,16 +0,0 @@
|
|||
plugins {
|
||||
`java-library`
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(libs.ktor.server.auth)
|
||||
api(libs.bcrypt)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
|
||||
interface EmailService {
|
||||
fun sendPasswordResetEmail(token: PasswordResetToken, to: String)
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
interface Identifiable {
|
||||
val id: String
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
interface Logger {
|
||||
fun log(message: String)
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
package com.wbrawner.twigs
|
||||
|
||||
import at.favre.lib.crypto.bcrypt.BCrypt
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
private val CALENDAR_FIELDS = intArrayOf(
|
||||
Calendar.MILLISECOND,
|
||||
Calendar.SECOND,
|
||||
Calendar.MINUTE,
|
||||
Calendar.HOUR_OF_DAY,
|
||||
Calendar.DATE
|
||||
)
|
||||
|
||||
val firstOfMonth: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
for (calField in CALENDAR_FIELDS) {
|
||||
set(calField, getActualMinimum(calField))
|
||||
}
|
||||
toInstant()
|
||||
}
|
||||
|
||||
val endOfMonth: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
for (calField in CALENDAR_FIELDS) {
|
||||
set(calField, getActualMaximum(calField))
|
||||
}
|
||||
toInstant()
|
||||
}
|
||||
|
||||
val twoWeeksFromNow: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
add(Calendar.DATE, 14)
|
||||
toInstant()
|
||||
}
|
||||
|
||||
val tomorrow: Instant
|
||||
get() = GregorianCalendar(TimeZone.getTimeZone("UTC")).run {
|
||||
add(Calendar.DATE, 1)
|
||||
toInstant()
|
||||
}
|
||||
|
||||
private const val CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
fun randomString(length: Int = 32): String {
|
||||
val id = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
id.append(CHARACTERS.random())
|
||||
}
|
||||
return id.toString()
|
||||
}
|
||||
|
||||
lateinit var salt: String
|
||||
fun String.hash(): String = String(BCrypt.withDefaults().hash(10, salt.toByteArray(), this.toByteArray()))
|
||||
|
||||
fun String.toInstant(): Instant = Instant.parse(this)
|
||||
|
||||
fun String.asFrequency(): Frequency = Frequency.parse(this)
|
|
@ -1,11 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
|
||||
data class Budget(
|
||||
override val id: String = randomString(),
|
||||
var name: String? = null,
|
||||
var description: String? = null,
|
||||
var currencyCode: String? = "USD",
|
||||
) : Identifiable
|
|
@ -1,14 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
|
||||
data class Category(
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val amount: Long,
|
||||
val budgetId: String,
|
||||
val description: String? = null,
|
||||
val expense: Boolean = true,
|
||||
val archived: Boolean = false
|
||||
) : Identifiable
|
|
@ -1,12 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import com.wbrawner.twigs.tomorrow
|
||||
import java.time.Instant
|
||||
|
||||
data class PasswordResetToken(
|
||||
override val id: String = randomString(),
|
||||
val userId: String = "",
|
||||
var expiration: Instant = tomorrow
|
||||
) : Identifiable
|
|
@ -1,197 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import java.time.DayOfWeek
|
||||
import java.time.Instant
|
||||
import java.time.MonthDay
|
||||
|
||||
data class RecurringTransaction(
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val frequency: Frequency,
|
||||
val start: Instant,
|
||||
val finish: Instant? = null,
|
||||
val amount: Long,
|
||||
val expense: Boolean,
|
||||
val createdBy: String,
|
||||
val categoryId: String? = null,
|
||||
val budgetId: String,
|
||||
val lastRun: Instant? = null
|
||||
) : Identifiable {
|
||||
fun toTransaction(now: Instant = Instant.now()): Transaction = Transaction(
|
||||
title = title,
|
||||
description = description,
|
||||
date = frequency.instant(now),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
createdBy = createdBy,
|
||||
categoryId = categoryId,
|
||||
budgetId = budgetId
|
||||
)
|
||||
}
|
||||
|
||||
sealed class Frequency {
|
||||
abstract val count: Int
|
||||
abstract val time: Time
|
||||
|
||||
data class Daily(override val count: Int, override val time: Time) : Frequency() {
|
||||
override fun toString(): String = "D;$count;$time"
|
||||
|
||||
companion object {
|
||||
fun parse(s: String): Daily {
|
||||
require(s[0] == 'D') { "Invalid format for Daily: $s" }
|
||||
return with(s.split(';')) {
|
||||
Daily(
|
||||
get(1).toInt(),
|
||||
Time.parse(get(2))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Weekly(override val count: Int, val daysOfWeek: Set<DayOfWeek>, override val time: Time) : Frequency() {
|
||||
override fun toString(): String = "W;$count;${daysOfWeek.joinToString(",")};$time"
|
||||
companion object {
|
||||
fun parse(s: String): Weekly {
|
||||
require(s[0] == 'W') { "Invalid format for Weekly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Weekly(
|
||||
get(1).toInt(),
|
||||
get(2).split(',').map { DayOfWeek.valueOf(it) }.toSet(),
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Monthly(
|
||||
override val count: Int,
|
||||
val dayOfMonth: DayOfMonth,
|
||||
override val time: Time
|
||||
) : Frequency() {
|
||||
override fun toString(): String = "M;$count;$dayOfMonth;$time"
|
||||
companion object {
|
||||
fun parse(s: String): Monthly {
|
||||
require(s[0] == 'M') { "Invalid format for Monthly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Monthly(
|
||||
get(1).toInt(),
|
||||
DayOfMonth.parse(get(2)),
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Yearly(override val count: Int, val dayOfYear: MonthDay, override val time: Time) : Frequency() {
|
||||
override fun toString(): String = "Y;$count;%02d-%02d;$time".format(dayOfYear.monthValue, dayOfYear.dayOfMonth)
|
||||
companion object {
|
||||
fun parse(s: String): Yearly {
|
||||
require(s[0] == 'Y') { "Invalid format for Yearly: $s" }
|
||||
return with(s.split(';')) {
|
||||
Yearly(
|
||||
get(1).toInt(),
|
||||
with(get(2).split("-")) {
|
||||
MonthDay.of(get(0).toInt(), get(1).toInt())
|
||||
},
|
||||
Time.parse(get(3))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun instant(now: Instant): Instant = Instant.parse(now.toString().split("T")[0] + "T" + time.toString() + "Z")
|
||||
|
||||
companion object {
|
||||
fun parse(s: String): Frequency = when (s[0]) {
|
||||
'D' -> Daily.parse(s)
|
||||
'W' -> Weekly.parse(s)
|
||||
'M' -> Monthly.parse(s)
|
||||
'Y' -> Yearly.parse(s)
|
||||
else -> throw IllegalArgumentException("Invalid frequency format: $s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Time(val hours: Int, val minutes: Int, val seconds: Int) {
|
||||
override fun toString(): String {
|
||||
val s = StringBuilder()
|
||||
if (hours < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(hours)
|
||||
s.append(":")
|
||||
if (minutes < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(minutes)
|
||||
s.append(":")
|
||||
if (seconds < 10) {
|
||||
s.append("0")
|
||||
}
|
||||
s.append(seconds)
|
||||
return s.toString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(s: String): Time {
|
||||
require(s.length < 9) { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
|
||||
require(s[2] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
|
||||
require(s[5] == ':') { "Invalid time format: $s. Time should be formatted as HH:mm:ss" }
|
||||
return Time(
|
||||
s.substring(0, 2).toInt(),
|
||||
s.substring(3, 5).toInt(),
|
||||
s.substring(7).toInt(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DayOfMonth private constructor(
|
||||
val day: Int? = null,
|
||||
val positionalDayOfWeek: PositionalDayOfWeek? = null
|
||||
) {
|
||||
override fun toString() = day?.let { "DAY-${it}" } ?: positionalDayOfWeek!!.toString()
|
||||
|
||||
companion object {
|
||||
fun day(day: Int): DayOfMonth {
|
||||
require(day in 1..31) { "Day out of range: $day" }
|
||||
return DayOfMonth(day = day)
|
||||
}
|
||||
|
||||
fun positionalDayOfWeek(position: Position, dayOfWeek: DayOfWeek): DayOfMonth {
|
||||
return DayOfMonth(positionalDayOfWeek = PositionalDayOfWeek(position, dayOfWeek))
|
||||
}
|
||||
|
||||
fun parse(s: String): DayOfMonth = with(s.split("-")) {
|
||||
when (size) {
|
||||
2 -> when (first()) {
|
||||
"DAY" -> day(get(1).toInt())
|
||||
else -> positionalDayOfWeek(
|
||||
Position.valueOf(first()),
|
||||
DayOfWeek.valueOf(get(1))
|
||||
)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Failed to parse string $s")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PositionalDayOfWeek(val position: Position, val dayOfWeek: DayOfWeek) {
|
||||
override fun toString(): String = "${position.name}-${dayOfWeek.name}"
|
||||
}
|
||||
}
|
||||
|
||||
enum class Position {
|
||||
FIRST,
|
||||
SECOND,
|
||||
THIRD,
|
||||
FOURTH,
|
||||
LAST
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import com.wbrawner.twigs.twoWeeksFromNow
|
||||
import io.ktor.server.auth.*
|
||||
import java.time.Instant
|
||||
|
||||
data class Session(
|
||||
override val id: String = randomString(),
|
||||
val userId: String = "",
|
||||
val token: String = randomString(255),
|
||||
var expiration: Instant = twoWeeksFromNow
|
||||
) : Principal, Identifiable
|
|
@ -1,17 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import java.time.Instant
|
||||
|
||||
data class Transaction(
|
||||
override val id: String = randomString(),
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
val date: Instant,
|
||||
val amount: Long,
|
||||
val expense: Boolean,
|
||||
val createdBy: String,
|
||||
val categoryId: String? = null,
|
||||
val budgetId: String
|
||||
) : Identifiable
|
|
@ -1,44 +0,0 @@
|
|||
package com.wbrawner.twigs.model
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.randomString
|
||||
import io.ktor.server.auth.*
|
||||
|
||||
data class User(
|
||||
override val id: String = randomString(),
|
||||
val name: String = "",
|
||||
val password: String = "",
|
||||
val email: String? = null
|
||||
) : Principal, Identifiable
|
||||
|
||||
enum class Permission {
|
||||
/**
|
||||
* The user can read the content but cannot make any modifications.
|
||||
*/
|
||||
READ,
|
||||
|
||||
/**
|
||||
* The user can read and write the content but cannot make any modifications to the container of the content.
|
||||
*/
|
||||
WRITE,
|
||||
|
||||
/**
|
||||
* The user can read and write the content, and make modifications to the container of the content including things like name, description, and other users' permissions (with the exception of the owner user, whose role can never be removed by a user with only MANAGE permissions).
|
||||
*/
|
||||
MANAGE,
|
||||
|
||||
/**
|
||||
* The user has complete control over the resource. There can only be a single owner user at any given time.
|
||||
*/
|
||||
OWNER;
|
||||
|
||||
fun isAtLeast(wanted: Permission): Boolean {
|
||||
return ordinal >= wanted.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
data class UserPermission(
|
||||
val budgetId: String,
|
||||
val userId: String,
|
||||
val permission: Permission = Permission.READ
|
||||
)
|
1
db/.gitignore
vendored
1
db/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,21 +0,0 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
`java-library`
|
||||
}
|
||||
|
||||
val ktorVersion: String by rootProject.extra
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(project(":storage"))
|
||||
runtimeOnly(libs.postgres)
|
||||
runtimeOnly(libs.sqlite)
|
||||
api(libs.hikari)
|
||||
implementation(libs.logback)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
data class DatabaseMetadata(
|
||||
val version: Int = 0,
|
||||
val salt: String = ""
|
||||
)
|
|
@ -1,31 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.storage.BudgetRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcBudgetRepository(dataSource: DataSource) : JdbcRepository<Budget, JdbcBudgetRepository.Fields>(dataSource),
|
||||
BudgetRepository {
|
||||
override val tableName: String = TABLE_BUDGET
|
||||
override val fields: Map<Fields, (Budget) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun ResultSet.toEntity(): Budget = Budget(
|
||||
id = getString(ID),
|
||||
name = getString(Fields.NAME.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
currencyCode = getString(Fields.CURRENCY_CODE.name.lowercase())
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Budget) -> Any?) {
|
||||
NAME({ it.name }),
|
||||
DESCRIPTION({ it.description }),
|
||||
CURRENCY_CODE({ it.currencyCode })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_BUDGET = "budgets"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import com.wbrawner.twigs.storage.CategoryRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcCategoryRepository(dataSource: DataSource) :
|
||||
JdbcRepository<Category, JdbcCategoryRepository.Fields>(dataSource), CategoryRepository {
|
||||
override val tableName: String = TABLE_CATEGORY
|
||||
override val fields: Map<Fields, (Category) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>?,
|
||||
expense: Boolean?,
|
||||
archived: Boolean?
|
||||
): List<Category> = dataSource.connection.use { conn ->
|
||||
if (budgetIds.isEmpty()) {
|
||||
throw Error("budgetIds cannot be empty")
|
||||
}
|
||||
val sql =
|
||||
StringBuilder("SELECT * FROM $tableName WHERE ${Fields.BUDGET_ID.name.lowercase()} in (${budgetIds.questionMarks()})")
|
||||
val params = mutableListOf<Any?>()
|
||||
params.addAll(budgetIds)
|
||||
ids?.let {
|
||||
sql.append(" AND $ID IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
expense?.let {
|
||||
sql.append(" AND ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
archived?.let {
|
||||
sql.append(" AND ${Fields.ARCHIVED.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
sql.append(" ORDER BY ${Fields.TITLE.name.lowercase()} ASC")
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): Category = Category(
|
||||
id = getString(ID),
|
||||
title = getString(Fields.TITLE.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
amount = getLong(Fields.AMOUNT.name.lowercase()),
|
||||
expense = getBoolean(Fields.EXPENSE.name.lowercase()),
|
||||
archived = getBoolean(Fields.ARCHIVED.name.lowercase()),
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Category) -> Any?) {
|
||||
TITLE({ it.title }),
|
||||
DESCRIPTION({ it.description }),
|
||||
AMOUNT({ it.amount }),
|
||||
EXPENSE({ it.expense }),
|
||||
ARCHIVED({ it.archived }),
|
||||
BUDGET_ID({ it.budgetId }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_CATEGORY = "categories"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.storage.PasswordResetRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcPasswordResetRepository(dataSource: DataSource) :
|
||||
JdbcRepository<PasswordResetToken, JdbcPasswordResetRepository.Fields>(dataSource),
|
||||
PasswordResetRepository {
|
||||
override val tableName: String = TABLE_USER
|
||||
override val fields: Map<Fields, (PasswordResetToken) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun ResultSet.toEntity(): PasswordResetToken = PasswordResetToken(
|
||||
id = getString(ID),
|
||||
userId = getString(Fields.USER_ID.name.lowercase()),
|
||||
expiration = getInstant(Fields.EXPIRATION.name.lowercase())!!
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (PasswordResetToken) -> Any?) {
|
||||
USER_ID({ it.userId }),
|
||||
EXPIRATION({ it.expiration })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_USER = "password_reset_tokens"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Permission
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcPermissionRepository(dataSource: DataSource) :
|
||||
JdbcRepository<UserPermission, JdbcPermissionRepository.Fields>(dataSource), PermissionRepository {
|
||||
override val tableName: String = TABLE_PERMISSIONS
|
||||
override val fields: Map<Fields, (UserPermission) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
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> =
|
||||
dataSource.connection.use { conn ->
|
||||
if (budgetIds.isNullOrEmpty() && userId.isNullOrBlank()) {
|
||||
throw Error("budgetIds or userId must be provided")
|
||||
}
|
||||
val sql = StringBuilder("SELECT * FROM $tableName")
|
||||
val params = mutableListOf<String>()
|
||||
budgetIds?.let {
|
||||
sql.append(" WHERE ${Fields.BUDGET_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
userId?.let {
|
||||
sql.append(if (params.isEmpty()) " WHERE " else " AND ")
|
||||
sql.append("${Fields.USER_ID.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
|
||||
throw UnsupportedOperationException("UserPermission requires a userId and budgetId")
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): UserPermission = UserPermission(
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
userId = getString(Fields.USER_ID.name.lowercase()),
|
||||
permission = Permission.valueOf(getString(Fields.PERMISSION.name.lowercase()))
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (UserPermission) -> Any?) {
|
||||
BUDGET_ID(
|
||||
{ it.budgetId }),
|
||||
USER_ID(
|
||||
{ it.userId }),
|
||||
PERMISSION(
|
||||
{ it.permission })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_PERMISSIONS = "user_permissions"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.asFrequency
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcRecurringTransactionRepository(dataSource: DataSource) :
|
||||
JdbcRepository<RecurringTransaction, JdbcRecurringTransactionRepository.Fields>(dataSource),
|
||||
RecurringTransactionRepository {
|
||||
override val tableName: String = TABLE_RECURRING_TRANSACTION
|
||||
override val fields: Map<Fields, (RecurringTransaction) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override suspend fun findAll(now: Instant): List<RecurringTransaction> = dataSource.connection.use { conn ->
|
||||
conn.executeQuery("SELECT * FROM $tableName WHERE ${Fields.START.name.lowercase()} < ? AND (${Fields.FINISH.name.lowercase()} IS NULL OR ${Fields.FINISH.name.lowercase()} > ?)", listOf(now, now))
|
||||
}
|
||||
|
||||
override suspend fun findAll(budgetId: String): List<RecurringTransaction> = dataSource.connection.use { conn ->
|
||||
if (budgetId.isBlank()) throw IllegalArgumentException("budgetId cannot be null")
|
||||
conn.executeQuery("SELECT * FROM $tableName WHERE ${Fields.BUDGET_ID.name.lowercase()} = ?", listOf(budgetId))
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): RecurringTransaction = RecurringTransaction(
|
||||
id = getString(ID),
|
||||
title = getString(Fields.TITLE.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
frequency = getString(Fields.FREQUENCY.name.lowercase()).asFrequency(),
|
||||
start = getInstant(Fields.START.name.lowercase())!!,
|
||||
finish = getInstant(Fields.FINISH.name.lowercase()),
|
||||
lastRun = getInstant(Fields.LAST_RUN.name.lowercase()),
|
||||
amount = getLong(Fields.AMOUNT.name.lowercase()),
|
||||
expense = getBoolean(Fields.EXPENSE.name.lowercase()),
|
||||
createdBy = getString(Fields.CREATED_BY.name.lowercase()),
|
||||
categoryId = getString(Fields.CATEGORY_ID.name.lowercase()),
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (RecurringTransaction) -> Any?) {
|
||||
TITLE({ it.title }),
|
||||
DESCRIPTION({ it.description }),
|
||||
FREQUENCY({ it.frequency }),
|
||||
START({ it.start }),
|
||||
FINISH({ it.finish }),
|
||||
LAST_RUN({ it.lastRun }),
|
||||
AMOUNT({ it.amount }),
|
||||
EXPENSE({ it.expense }),
|
||||
CREATED_BY({ it.createdBy }),
|
||||
CATEGORY_ID({ it.categoryId }),
|
||||
BUDGET_ID({ it.budgetId }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_RECURRING_TRANSACTION = "recurring_transactions"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.sql.Connection
|
||||
import java.sql.PreparedStatement
|
||||
import java.sql.ResultSet
|
||||
import java.sql.Types.NULL
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
import java.time.temporal.ChronoField
|
||||
import javax.sql.DataSource
|
||||
|
||||
const val ID = "id"
|
||||
|
||||
abstract class JdbcRepository<Entity, Fields : Enum<Fields>>(protected val dataSource: DataSource) :
|
||||
Repository<Entity> {
|
||||
abstract val tableName: String
|
||||
abstract val fields: Map<Fields, (Entity) -> Any?>
|
||||
abstract val conflictFields: Collection<String>
|
||||
val logger = LoggerFactory.getLogger(this::class.java)
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<Entity> = dataSource.connection.use { conn ->
|
||||
val sql = if (!ids.isNullOrEmpty()) {
|
||||
"SELECT * FROM $tableName WHERE $ID in (${ids.questionMarks()})"
|
||||
} else {
|
||||
"SELECT * FROM $tableName"
|
||||
}
|
||||
conn.executeQuery(sql, ids ?: emptyList())
|
||||
}
|
||||
|
||||
override suspend fun save(item: Entity): Entity = dataSource.connection.use { conn ->
|
||||
val sql = StringBuilder("INSERT INTO $tableName (")
|
||||
val params = mutableListOf<Any?>()
|
||||
if (item is Identifiable) {
|
||||
sql.append("$ID, ")
|
||||
params.add(item.id)
|
||||
}
|
||||
params.addAll(fields.values.map { it(item) })
|
||||
sql.append(fields.keys.joinToString(", ") { it.name.lowercase() })
|
||||
sql.append(") VALUES (")
|
||||
sql.append(params.questionMarks())
|
||||
sql.append(")")
|
||||
if (conflictFields.isNotEmpty()) {
|
||||
sql.append(" ON CONFLICT (")
|
||||
sql.append(conflictFields.joinToString(","))
|
||||
sql.append(") DO UPDATE SET ")
|
||||
sql.append(fields.keys.joinToString(", ") {
|
||||
"${it.name.lowercase()} = EXCLUDED.${it.name.lowercase()}"
|
||||
})
|
||||
}
|
||||
return if (conn.executeUpdate(sql.toString(), params) == 1) {
|
||||
item
|
||||
} else {
|
||||
throw Error("Failed to save entity $item")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(item: Entity): Boolean = dataSource.connection.use { conn ->
|
||||
if (item !is Identifiable) {
|
||||
throw Error("No suitable delete operation implemented for ${item!!::class.simpleName}")
|
||||
}
|
||||
val statement = conn.prepareStatement("DELETE FROM $tableName WHERE $ID=?")
|
||||
statement.setString(1, item.id)
|
||||
statement.executeUpdate() == 1
|
||||
}
|
||||
|
||||
private fun ResultSet.toEntityList(): List<Entity> {
|
||||
val entities = mutableListOf<Entity>()
|
||||
while (next()) {
|
||||
entities.add(toEntity())
|
||||
}
|
||||
return entities
|
||||
}
|
||||
|
||||
abstract fun ResultSet.toEntity(): Entity
|
||||
|
||||
protected fun Connection.executeQuery(sql: String, params: List<Any?>) = prepareStatement(sql)
|
||||
.apply {
|
||||
logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}")
|
||||
}
|
||||
.setParameters(params)
|
||||
.executeQuery()
|
||||
.toEntityList()
|
||||
|
||||
protected fun Connection.executeUpdate(sql: String, params: List<Any?> = emptyList()) = prepareStatement(sql)
|
||||
.apply {
|
||||
logger.debug("QUERY: $sql\nPARAMS: ${params.joinToString(", ")}")
|
||||
}
|
||||
.setParameters(params)
|
||||
.executeUpdate()
|
||||
|
||||
fun PreparedStatement.setParameters(params: Iterable<Any?>): PreparedStatement = apply {
|
||||
params.forEachIndexed { index, param ->
|
||||
when (param) {
|
||||
is Boolean -> setBoolean(index + 1, param)
|
||||
is Instant -> setString(index + 1, dateFormatter.format(param))
|
||||
is Int -> setInt(index + 1, param)
|
||||
is Long -> setLong(index + 1, param)
|
||||
is String -> setString(index + 1, param)
|
||||
is Enum<*> -> setString(index + 1, param.name)
|
||||
is Frequency -> setString(index + 1, param.toString())
|
||||
null -> setNull(index + 1, NULL)
|
||||
else -> throw Error("Unhandled parameter type: ${param.javaClass.name}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> Collection<T>.questionMarks(): String = List(this.size) { "?" }.joinToString(", ")
|
||||
|
||||
|
||||
private val dateFormatter = DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyy-MM-dd HH:mm:ss")
|
||||
.appendFraction(ChronoField.MILLI_OF_SECOND, 0, 3, true)
|
||||
.toFormatter()
|
||||
.withZone(ZoneId.of("UTC"))
|
||||
|
||||
fun ResultSet.getInstant(column: String): Instant? = getString(column)?.let { dateFormatter.parse(it, Instant::from) }
|
|
@ -1,47 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcSessionRepository(dataSource: DataSource) : JdbcRepository<Session, JdbcSessionRepository.Fields>(dataSource),
|
||||
SessionRepository {
|
||||
override val tableName: String = TABLE_SESSION
|
||||
override val fields: Map<Fields, (Session) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(token: String): List<Session> = dataSource.connection.use { conn ->
|
||||
val sql = "SELECT * FROM $tableName WHERE ${Fields.TOKEN.name.lowercase()} = ?"
|
||||
val params = mutableListOf(token)
|
||||
conn.executeQuery(sql, params)
|
||||
}
|
||||
|
||||
override fun deleteExpired() {
|
||||
dataSource.connection.use { conn ->
|
||||
val sql = "DELETE FROM $tableName WHERE ${Fields.TOKEN.name.lowercase()} < ?"
|
||||
val params = mutableListOf(Instant.now())
|
||||
conn.executeUpdate(sql, params)
|
||||
}
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): Session = Session(
|
||||
id = getString(ID),
|
||||
userId = getString(Fields.USER_ID.name.lowercase()),
|
||||
token = getString(Fields.TOKEN.name.lowercase()),
|
||||
expiration = getInstant(Fields.EXPIRATION.name.lowercase())!!,
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Session) -> Any?) {
|
||||
USER_ID({ it.userId }),
|
||||
TOKEN({ it.token }),
|
||||
EXPIRATION({ it.expiration }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_SESSION = "sessions"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.sql.ResultSet
|
||||
import java.time.Instant
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcTransactionRepository(dataSource: DataSource) :
|
||||
JdbcRepository<Transaction, JdbcTransactionRepository.Fields>(dataSource), TransactionRepository {
|
||||
override val tableName: String = TABLE_TRANSACTION
|
||||
override val fields: Map<Fields, (Transaction) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun findAll(
|
||||
ids: List<String>?,
|
||||
budgetIds: List<String>?,
|
||||
categoryIds: List<String>?,
|
||||
expense: Boolean?,
|
||||
from: Instant?,
|
||||
to: Instant?
|
||||
): List<Transaction> = dataSource.connection.use { conn ->
|
||||
val sql = StringBuilder("SELECT * FROM $tableName")
|
||||
val params = mutableListOf<Any?>()
|
||||
fun queryWord(): String = if (params.isEmpty()) " WHERE" else " AND"
|
||||
ids?.let {
|
||||
sql.append("${queryWord()} $ID IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
budgetIds?.let {
|
||||
sql.append("${queryWord()} ${Fields.BUDGET_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
categoryIds?.let {
|
||||
sql.append("${queryWord()} ${Fields.CATEGORY_ID.name.lowercase()} IN (${it.questionMarks()})")
|
||||
params.addAll(it)
|
||||
}
|
||||
expense?.let {
|
||||
sql.append("${queryWord()} ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
params.add(it)
|
||||
}
|
||||
from?.let {
|
||||
sql.append("${queryWord()} ${Fields.DATE.name.lowercase()} >= ?")
|
||||
params.add(it)
|
||||
}
|
||||
to?.let {
|
||||
sql.append("${queryWord()} ${Fields.DATE.name.lowercase()} <= ?")
|
||||
params.add(it)
|
||||
}
|
||||
sql.append(" ORDER BY ${Fields.DATE.name.lowercase()} DESC")
|
||||
conn.executeQuery(sql.toString(), params)
|
||||
}
|
||||
|
||||
override fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long =
|
||||
querySum(Fields.BUDGET_ID, budgetId, from, to)
|
||||
|
||||
override fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long =
|
||||
querySum(Fields.CATEGORY_ID, categoryId, from, to)
|
||||
|
||||
private fun querySum(field: Fields, id: String, from: Instant?, to: Instant?): Long =
|
||||
dataSource.connection.use { conn ->
|
||||
val sql =
|
||||
StringBuilder("SELECT SUM(${Fields.AMOUNT.name.lowercase()}) FROM $tableName WHERE ${field.name.lowercase()} = ?")
|
||||
val params = mutableListOf<Any?>(id)
|
||||
from?.let {
|
||||
sql.append(" AND ${Fields.DATE.name.lowercase()} >= ?")
|
||||
params.add(it)
|
||||
}
|
||||
to?.let {
|
||||
sql.append(" AND ${Fields.DATE.name.lowercase()} <= ?")
|
||||
params.add(it)
|
||||
}
|
||||
sql.append(" AND ${Fields.EXPENSE.name.lowercase()} = ?")
|
||||
conn.prepareStatement("SELECT (${sql.toString().coalesce()}) - (${sql.toString().coalesce()})")
|
||||
.setParameters(params + false + params + true)
|
||||
.executeQuery()
|
||||
.run {
|
||||
next()
|
||||
getLong(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.coalesce(): String = "COALESCE(($this), 0)"
|
||||
|
||||
override fun ResultSet.toEntity(): Transaction = Transaction(
|
||||
id = getString(ID),
|
||||
title = getString(Fields.TITLE.name.lowercase()),
|
||||
description = getString(Fields.DESCRIPTION.name.lowercase()),
|
||||
date = getInstant(Fields.DATE.name.lowercase())!!,
|
||||
amount = getLong(Fields.AMOUNT.name.lowercase()),
|
||||
expense = getBoolean(Fields.EXPENSE.name.lowercase()),
|
||||
createdBy = getString(Fields.CREATED_BY.name.lowercase()),
|
||||
categoryId = getString(Fields.CATEGORY_ID.name.lowercase()),
|
||||
budgetId = getString(Fields.BUDGET_ID.name.lowercase()),
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (Transaction) -> Any?) {
|
||||
TITLE({ it.title }),
|
||||
DESCRIPTION({ it.description }),
|
||||
DATE({ it.date }),
|
||||
AMOUNT({ it.amount }),
|
||||
EXPENSE({ it.expense }),
|
||||
CREATED_BY({ it.createdBy }),
|
||||
CATEGORY_ID({ it.categoryId }),
|
||||
BUDGET_ID({ it.budgetId }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_TRANSACTION = "transactions"
|
||||
}
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.storage.UserRepository
|
||||
import java.sql.ResultSet
|
||||
import javax.sql.DataSource
|
||||
|
||||
class JdbcUserRepository(dataSource: DataSource) : JdbcRepository<User, JdbcUserRepository.Fields>(dataSource),
|
||||
UserRepository {
|
||||
override val tableName: String = TABLE_USER
|
||||
override val fields: Map<Fields, (User) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf(ID)
|
||||
|
||||
override fun ResultSet.toEntity(): User = User(
|
||||
id = getString(ID),
|
||||
name = getString(Fields.USERNAME.name.lowercase()),
|
||||
password = getString(Fields.PASSWORD.name.lowercase()),
|
||||
email = getString(Fields.EMAIL.name.lowercase())
|
||||
)
|
||||
|
||||
override fun findAll(nameLike: String): List<User> = dataSource.connection.use { conn ->
|
||||
conn.executeQuery(
|
||||
"SELECT * FROM $tableName WHERE ${Fields.USERNAME.name.lowercase()} LIKE ? || '%'",
|
||||
listOf(nameLike)
|
||||
)
|
||||
}
|
||||
|
||||
override fun findAll(nameOrEmail: String, password: String?): List<User> = dataSource.connection.use { conn ->
|
||||
var sql =
|
||||
"SELECT * FROM $tableName WHERE (${Fields.USERNAME.name.lowercase()} = ? OR ${Fields.EMAIL.name.lowercase()} = ?)"
|
||||
val params = mutableListOf(nameOrEmail, nameOrEmail)
|
||||
password?.let {
|
||||
sql += " AND ${Fields.PASSWORD.name.lowercase()} = ?"
|
||||
params.add(it)
|
||||
}
|
||||
conn.executeQuery(sql, params)
|
||||
}
|
||||
|
||||
enum class Fields(val entityField: (User) -> Any?) {
|
||||
USERNAME({ it.name }),
|
||||
PASSWORD({ it.password }),
|
||||
EMAIL({ it.email })
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_USER = "users"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package com.wbrawner.twigs.db
|
||||
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.sql.SQLException
|
||||
import javax.sql.DataSource
|
||||
|
||||
interface MetadataRepository : Repository<DatabaseMetadata> {
|
||||
fun runMigration(toVersion: Int)
|
||||
}
|
||||
|
||||
class JdbcMetadataRepository(dataSource: DataSource) :
|
||||
JdbcRepository<DatabaseMetadata, JdbcMetadataRepository.Fields>(dataSource), MetadataRepository {
|
||||
override val tableName: String = TABLE_METADATA
|
||||
override val fields: Map<Fields, (DatabaseMetadata) -> Any?> = Fields.values().associateWith { it.entityField }
|
||||
override val conflictFields: Collection<String> = listOf()
|
||||
|
||||
override fun runMigration(toVersion: Int) {
|
||||
val queries = MetadataRepository::class.java
|
||||
.getResource("/sql/$toVersion.sql")
|
||||
?.readText()
|
||||
?.split(";")
|
||||
?.filterNot { it.isBlank() }
|
||||
?: throw Error("No migration found for version $toVersion")
|
||||
dataSource.connection.use { conn ->
|
||||
queries.forEach { query ->
|
||||
conn.executeUpdate(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun delete(item: DatabaseMetadata): Boolean = throw Error("DatabaseMetadata cannot be deleted")
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<DatabaseMetadata> = try {
|
||||
super.findAll(null)
|
||||
} catch (e: SQLException) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override suspend fun save(item: DatabaseMetadata): DatabaseMetadata = dataSource.connection.use { conn ->
|
||||
conn.executeUpdate("DELETE FROM $tableName", emptyList())
|
||||
super.save(item)
|
||||
}
|
||||
|
||||
override fun ResultSet.toEntity(): DatabaseMetadata = DatabaseMetadata(
|
||||
version = getInt(Fields.VERSION.name.lowercase()),
|
||||
salt = getString(Fields.SALT.name.lowercase())
|
||||
)
|
||||
|
||||
enum class Fields(val entityField: (DatabaseMetadata) -> Any?) {
|
||||
VERSION({ it.version }),
|
||||
SALT({ it.salt }),
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TABLE_METADATA = "twigs_metadata"
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS budgets (id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT DEFAULT NULL, currency_code TEXT DEFAULT NULL);
|
||||
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password TEXT NOT NULL, email TEXT DEFAULT NULL UNIQUE);
|
||||
CREATE TABLE IF NOT EXISTS categories (id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT NULL, amount INTEGER NOT NULL, expense BOOLEAN NOT NULL, archived BOOLEAN NOT NULL, budget_id TEXT NOT NULL, CONSTRAINT fk_budgets FOREIGN KEY (budget_id) REFERENCES budgets (id) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, token TEXT NOT NULL, expiration TIMESTAMP NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS user_permissions ( budget_id TEXT NOT NULL, user_id TEXT NOT NULL, permission TEXT NOT NULL, PRIMARY KEY (budget_id, user_id), CONSTRAINT fk_budgets FOREIGN KEY (budget_id) REFERENCES budgets (id) ON DELETE CASCADE, CONSTRAINT fk_users FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS transactions (id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT NULL, amount INTEGER NOT NULL, date TIMESTAMP NOT NULL, expense BOOLEAN NOT NULL, created_by TEXT NOT NULL, category_id TEXT DEFAULT NULL, budget_id TEXT NOT NULL, CONSTRAINT fk_users FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE CASCADE, CONSTRAINT fk_categories FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE, CONSTRAINT fk_budgets FOREIGN KEY (budget_id) REFERENCES budgets (id) ON DELETE CASCADE);
|
||||
CREATE TABLE IF NOT EXISTS twigs_metadata (version INTEGER NOT NULL, salt VARCHAR(16) NOT NULL);
|
|
@ -1,17 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS recurring_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
frequency TEXT NOT NULL,
|
||||
start TIMESTAMP NOT NULL,
|
||||
finish TIMESTAMP,
|
||||
last_run TIMESTAMP,
|
||||
expense BOOLEAN NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
category_id TEXT DEFAULT NULL,
|
||||
budget_id TEXT NOT NULL,
|
||||
CONSTRAINT fk_users FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_categories FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_budgets FOREIGN KEY (budget_id) REFERENCES budgets (id) ON DELETE CASCADE
|
||||
);
|
|
@ -1,25 +0,0 @@
|
|||
CREATE TABLE IF NOT EXISTS password_reset_tokens
|
||||
(
|
||||
id
|
||||
TEXT
|
||||
PRIMARY
|
||||
KEY,
|
||||
user_id
|
||||
TEXT
|
||||
NOT
|
||||
NULL,
|
||||
expiration
|
||||
TIMESTAMP
|
||||
NOT
|
||||
NULL,
|
||||
CONSTRAINT
|
||||
fk_users
|
||||
FOREIGN
|
||||
KEY
|
||||
(
|
||||
user_id
|
||||
) REFERENCES users
|
||||
(
|
||||
id
|
||||
) ON DELETE CASCADE
|
||||
);
|
|
@ -1,52 +0,0 @@
|
|||
[versions]
|
||||
bcrypt = "0.9.0"
|
||||
hikari = "5.0.1"
|
||||
junit = "5.8.2"
|
||||
kotlin = "1.9.10"
|
||||
kotlinx-coroutines = "1.6.2"
|
||||
ktor = "2.3.4"
|
||||
logback = "1.2.11"
|
||||
mail = "1.6.2"
|
||||
postgres = "42.3.8"
|
||||
shadow = "7.0.0"
|
||||
sqlite = "3.42.0.0"
|
||||
|
||||
[libraries]
|
||||
bcrypt = { module = "at.favre.lib:bcrypt", version.ref = "bcrypt" }
|
||||
hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" }
|
||||
junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" }
|
||||
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" }
|
||||
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-server-auth = { module = "io.ktor:ktor-server-auth", version.ref = "ktor" }
|
||||
ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" }
|
||||
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-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" }
|
||||
mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" }
|
||||
postgres = { module = "org.postgresql:postgresql", version.ref = "postgres" }
|
||||
sqlite = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" }
|
||||
|
||||
[bundles]
|
||||
ktor-server = [
|
||||
"ktor-server-call-logging",
|
||||
"ktor-server-cio",
|
||||
"ktor-server-content-negotiation",
|
||||
"ktor-server-core",
|
||||
"ktor-server-cors",
|
||||
"ktor-server-sessions"
|
||||
]
|
||||
|
||||
[plugins]
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
shadow = { id = "com.github.johnrengelman.shadow", version.ref = "shadow" }
|
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
6
gradle/wrapper/gradle-wrapper.properties
vendored
6
gradle/wrapper/gradle-wrapper.properties
vendored
|
@ -1,6 +0,0 @@
|
|||
#Fri Feb 07 18:11:46 CST 2020
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
183
gradlew
vendored
183
gradlew
vendored
|
@ -1,183 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# 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
|
||||
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"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 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
|
||||
;;
|
||||
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"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
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.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
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" ;;
|
||||
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, 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"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
100
gradlew.bat
vendored
100
gradlew.bat
vendored
|
@ -1,100 +0,0 @@
|
|||
@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
|
|
@ -1,3 +0,0 @@
|
|||
rootProject.name = "twigs"
|
||||
include("core", "api", "app", "storage", "db", "web")
|
||||
include("testhelpers")
|
17
src/main.rs
Normal file
17
src/main.rs
Normal file
|
@ -0,0 +1,17 @@
|
|||
use std::env;
|
||||
|
||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||
|
||||
async fn pong() -> impl Responder {
|
||||
HttpResponse::Ok().body("PONG")
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let port: u16 = env::var("PORT").map_or(8080, |p| p.parse::<u16>().unwrap_or(8080));
|
||||
|
||||
HttpServer::new(|| App::new().route("/ping", web::get().to(pong)))
|
||||
.bind(("0.0.0.0", port))?
|
||||
.run()
|
||||
.await
|
||||
}
|
1
storage/.gitignore
vendored
1
storage/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,16 +0,0 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
`java-library`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(project(":core"))
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit.jupiter.api)
|
||||
testRuntimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks.getByName<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
|
||||
interface BudgetRepository : Repository<Budget>
|
|
@ -1,12 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
|
||||
interface CategoryRepository : Repository<Category> {
|
||||
fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>? = null,
|
||||
expense: Boolean? = null,
|
||||
archived: Boolean? = null
|
||||
): List<Category>
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
|
||||
interface PasswordResetRepository : Repository<PasswordResetToken> {}
|
|
@ -1,10 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
|
||||
interface PermissionRepository : Repository<UserPermission> {
|
||||
fun findAll(
|
||||
budgetIds: List<String>? = null,
|
||||
userId: String? = null
|
||||
): List<UserPermission>
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import java.time.Instant
|
||||
|
||||
interface RecurringTransactionRepository : Repository<RecurringTransaction> {
|
||||
suspend fun findAll(now: Instant): List<RecurringTransaction>
|
||||
suspend fun findAll(budgetId: String): List<RecurringTransaction>
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
/**
|
||||
* Base interface for an entity repository that provides basic CRUD methods
|
||||
*
|
||||
* @param T The type of the object supported by this repository
|
||||
*/
|
||||
interface Repository<T> {
|
||||
suspend fun findAll(ids: List<String>? = null): List<T>
|
||||
suspend fun save(item: T): T
|
||||
suspend fun delete(item: T): Boolean
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.Session
|
||||
|
||||
interface SessionRepository : Repository<Session> {
|
||||
fun findAll(
|
||||
token: String
|
||||
): List<Session>
|
||||
|
||||
fun deleteExpired()
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import java.time.Instant
|
||||
|
||||
interface TransactionRepository : Repository<Transaction> {
|
||||
fun findAll(
|
||||
ids: List<String>? = null,
|
||||
budgetIds: List<String>? = null,
|
||||
categoryIds: List<String>? = null,
|
||||
expense: Boolean? = null,
|
||||
from: Instant? = null,
|
||||
to: Instant? = null,
|
||||
): List<Transaction>
|
||||
|
||||
fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long
|
||||
|
||||
fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
package com.wbrawner.twigs.storage
|
||||
|
||||
import com.wbrawner.twigs.model.User
|
||||
|
||||
interface UserRepository : Repository<User> {
|
||||
fun findAll(
|
||||
nameOrEmail: String,
|
||||
password: String? = null
|
||||
): List<User>
|
||||
|
||||
fun findAll(nameLike: String): List<User>
|
||||
}
|
1
testhelpers/.gitignore
vendored
1
testhelpers/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
build/
|
|
@ -1,19 +0,0 @@
|
|||
plugins {
|
||||
kotlin("jvm")
|
||||
java
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":storage"))
|
||||
api(libs.kotlinx.coroutines.test)
|
||||
api(libs.junit.jupiter.api)
|
||||
implementation(project(mapOf("path" to ":db")))
|
||||
runtimeOnly(libs.junit.jupiter.engine)
|
||||
}
|
||||
|
||||
tasks {
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers
|
||||
|
||||
import com.wbrawner.twigs.EmailService
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
|
||||
class FakeEmailService : EmailService {
|
||||
val emails = mutableListOf<FakeEmail<*>>()
|
||||
|
||||
override fun sendPasswordResetEmail(token: PasswordResetToken, to: String) {
|
||||
emails.add(FakeEmail(to, token))
|
||||
}
|
||||
}
|
||||
|
||||
data class FakeEmail<Data>(val to: String, val data: Data)
|
|
@ -1,6 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.Budget
|
||||
import com.wbrawner.twigs.storage.BudgetRepository
|
||||
|
||||
class FakeBudgetRepository : FakeRepository<Budget>(), BudgetRepository
|
|
@ -1,18 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.Category
|
||||
import com.wbrawner.twigs.storage.CategoryRepository
|
||||
|
||||
class FakeCategoryRepository : FakeRepository<Category>(), CategoryRepository {
|
||||
override fun findAll(
|
||||
budgetIds: List<String>,
|
||||
ids: List<String>?,
|
||||
expense: Boolean?,
|
||||
archived: Boolean?
|
||||
): List<Category> = entities.filter {
|
||||
budgetIds.contains(it.budgetId)
|
||||
&& ids?.contains(it.id) ?: true
|
||||
&& it.expense == (expense ?: it.expense)
|
||||
&& it.archived == (archived ?: it.archived)
|
||||
}
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.db.DatabaseMetadata
|
||||
import com.wbrawner.twigs.db.MetadataRepository
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
|
||||
class FakeMetadataRepository : Repository<DatabaseMetadata>, MetadataRepository {
|
||||
var metadata = DatabaseMetadata()
|
||||
override fun runMigration(toVersion: Int) {
|
||||
metadata = metadata.copy(version = toVersion)
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<DatabaseMetadata> = listOf(metadata)
|
||||
|
||||
override suspend fun delete(item: DatabaseMetadata): Boolean = false
|
||||
|
||||
override suspend fun save(item: DatabaseMetadata): DatabaseMetadata {
|
||||
metadata = item
|
||||
return metadata
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.PasswordResetToken
|
||||
import com.wbrawner.twigs.storage.PasswordResetRepository
|
||||
|
||||
class FakePasswordResetRepository : FakeRepository<PasswordResetToken>(), PasswordResetRepository
|
|
@ -1,26 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.UserPermission
|
||||
import com.wbrawner.twigs.storage.PermissionRepository
|
||||
|
||||
class FakePermissionRepository : PermissionRepository {
|
||||
val permissions: MutableList<UserPermission> = mutableListOf()
|
||||
override fun findAll(budgetIds: List<String>?, userId: String?): List<UserPermission> =
|
||||
permissions.filter { userPermission ->
|
||||
budgetIds?.contains(userPermission.budgetId) ?: true
|
||||
&& userId?.let { it == userPermission.userId } ?: true
|
||||
}
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<UserPermission> {
|
||||
throw UnsupportedOperationException("UserPermission requires a userId and budgetId")
|
||||
}
|
||||
|
||||
override suspend fun save(item: UserPermission): UserPermission {
|
||||
permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId }
|
||||
permissions.add(item)
|
||||
return item
|
||||
}
|
||||
|
||||
override suspend fun delete(item: UserPermission): Boolean =
|
||||
permissions.removeIf { it.budgetId == item.budgetId && it.userId == item.userId }
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.RecurringTransaction
|
||||
import com.wbrawner.twigs.storage.RecurringTransactionRepository
|
||||
import java.time.Instant
|
||||
|
||||
class FakeRecurringTransactionRepository : FakeRepository<RecurringTransaction>(), RecurringTransactionRepository {
|
||||
override suspend fun findAll(now: Instant): List<RecurringTransaction> = entities.filter {
|
||||
(it.start == now || it.start.isBefore(now)) && it.finish?.isAfter(now) ?: true
|
||||
}
|
||||
|
||||
override suspend fun findAll(budgetId: String): List<RecurringTransaction> = entities.filter {
|
||||
it.budgetId == budgetId
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.Identifiable
|
||||
import com.wbrawner.twigs.storage.Repository
|
||||
|
||||
abstract class FakeRepository<T : Identifiable> : Repository<T> {
|
||||
val entities = mutableListOf<T>()
|
||||
|
||||
override suspend fun findAll(ids: List<String>?): List<T> = if (ids == null) {
|
||||
entities
|
||||
} else {
|
||||
entities.filter { ids.contains(it.id) }
|
||||
}
|
||||
|
||||
override suspend fun save(item: T): T {
|
||||
entities.removeIf { it.id == item.id }
|
||||
entities.add(item)
|
||||
return item
|
||||
}
|
||||
|
||||
override suspend fun delete(item: T): Boolean = entities.removeIf { it.id == item.id }
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.Session
|
||||
import com.wbrawner.twigs.storage.SessionRepository
|
||||
import java.util.function.Predicate
|
||||
|
||||
class FakeSessionRepository : FakeRepository<Session>(), SessionRepository {
|
||||
var expirationPredicate: Predicate<in Session> = Predicate { false }
|
||||
|
||||
override fun findAll(token: String): List<Session> = entities.filter { it.token == token }
|
||||
|
||||
override fun deleteExpired() {
|
||||
entities.removeIf(expirationPredicate)
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.Transaction
|
||||
import com.wbrawner.twigs.storage.TransactionRepository
|
||||
import java.time.Instant
|
||||
|
||||
class FakeTransactionRepository : FakeRepository<Transaction>(), TransactionRepository {
|
||||
override fun findAll(
|
||||
ids: List<String>?,
|
||||
budgetIds: List<String>?,
|
||||
categoryIds: List<String>?,
|
||||
expense: Boolean?,
|
||||
from: Instant?,
|
||||
to: Instant?
|
||||
): List<Transaction> = entities.filter { transaction ->
|
||||
ids?.contains(transaction.id) ?: true
|
||||
&& budgetIds?.contains(transaction.budgetId) ?: true
|
||||
&& categoryIds?.contains(transaction.categoryId) ?: true
|
||||
&& expense?.let { it == transaction.expense } ?: true
|
||||
&& from?.isBefore(transaction.date) ?: true
|
||||
&& to?.isAfter(transaction.date) ?: true
|
||||
}
|
||||
|
||||
override fun sumByBudget(budgetId: String, from: Instant, to: Instant): Long = entities.asSequence()
|
||||
.filter {
|
||||
it.budgetId == budgetId
|
||||
&& from.isBefore(it.date)
|
||||
&& to.isAfter(it.date)
|
||||
}
|
||||
.sumOf {
|
||||
val modifier = if (it.expense) -1 else 1
|
||||
it.amount * modifier
|
||||
}
|
||||
|
||||
override fun sumByCategory(categoryId: String, from: Instant, to: Instant): Long = entities.asSequence()
|
||||
.filter {
|
||||
it.categoryId == categoryId
|
||||
&& from.isBefore(it.date)
|
||||
&& to.isAfter(it.date)
|
||||
}
|
||||
.sumOf {
|
||||
val modifier = if (it.expense) -1 else 1
|
||||
it.amount * modifier
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
package com.wbrawner.twigs.test.helpers.repository
|
||||
|
||||
import com.wbrawner.twigs.model.User
|
||||
import com.wbrawner.twigs.storage.UserRepository
|
||||
|
||||
class FakeUserRepository : FakeRepository<User>(), UserRepository {
|
||||
override fun findAll(nameOrEmail: String, password: String?): List<User> {
|
||||
return entities.filter {
|
||||
(it.name.equals(nameOrEmail, ignoreCase = true) || it.email.equals(
|
||||
nameOrEmail,
|
||||
ignoreCase = true
|
||||
)) && it.password == password
|
||||
}
|
||||
}
|
||||
|
||||
override fun findAll(nameLike: String): List<User> {
|
||||
return entities.filter { it.name.contains(nameLike, ignoreCase = true) }
|
||||
}
|
||||
}
|
2
web/.gitignore
vendored
2
web/.gitignore
vendored
|
@ -1,2 +0,0 @@
|
|||
build/
|
||||
src/main/resources/twigs/
|
|
@ -1,51 +0,0 @@
|
|||
import java.util.*
|
||||
|
||||
plugins {
|
||||
`java-library`
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib"))
|
||||
api(libs.ktor.server.core)
|
||||
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")
|
||||
//}
|
|
@ -1,24 +0,0 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
fun Application.webRoutes() {
|
||||
routing {
|
||||
static {
|
||||
resources("web")
|
||||
default("index.html")
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
<svg height="1024" viewBox="0 0 270.93333 270.93334" width="1024" xmlns="http://www.w3.org/2000/svg"><g stroke-width=".586787" transform="matrix(1.3353892 0 0 1.3353892 -24.297326 -94.719682)"><path d="m74.206857 125.04091v11.17592 5.23648c0 17.67636 14.156819 32.18701 31.698213 32.76205v.02h5.58855 1.08205 1.47576v2.90108 10.82505 8.58703c0 7.40231 3.5483 14.37159 9.53353 18.7273 5.98522 4.35511 13.70792 5.5862 20.75111 3.30948l-3.43798-10.63435c-3.65216 1.18063-7.63409.54513-10.73703-1.71341-3.10351-2.25796-4.93251-5.85085-4.93251-9.68902v-8.58703h2.55428 4.5071 1.08145v-.0177c17.54199-.57505 31.69881-15.08569 31.69881-32.76148v-5.58855-10.82503h-5.58858-7.06137-1.08203-5.58798v.0177c-8.5307.27989-16.26044 3.85518-21.9511 9.48776-4.00892-13.08417-16.02397-22.74562-30.272914-23.21329v-.0177h-1.08143-4.50652-8.14286zm11.176529 11.17592h2.55488 4.50652c11.997434 0 21.603714 9.60688 21.603714 21.60431v5.23648h-1.47283-1.08205-4.50652c-11.997444 0-21.603714-9.60627-21.603714-21.60431zm61.448874 13.72613h4.50595 1.08203 1.47342v5.23705c0 11.99744-9.6063 21.60374-21.60373 21.60374h-4.5071-2.55428v-5.2365c0-11.99744 9.6057-21.60429 21.60371-21.60429z" fill="#1a1a1a"/><path d="m85.383386 136.21683v5.23648c0 11.99804 9.60627 21.60431 21.603714 21.60431h4.50652 1.08205 1.47283v-5.23648c0-11.99743-9.60628-21.60431-21.603714-21.60431h-4.50652z" fill="#bfff00"/></g></svg>
|
Before Width: | Height: | Size: 1.3 KiB |
|
@ -1,68 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- 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">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="0.35"
|
||||
inkscape:cx="463.34737"
|
||||
inkscape:cy="466.36823"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="g831"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1299"
|
||||
inkscape:window-height="704"
|
||||
inkscape:window-x="67"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-26.06665)">
|
||||
<g
|
||||
id="g831"
|
||||
transform="matrix(1.3353892,0,0,1.3353892,-24.297326,-68.653032)"
|
||||
style="fill:#1a1a1a">
|
||||
<path
|
||||
style="fill:#ffffff;stroke-width:2.9615941"
|
||||
d="M 282.69922 273.10352 L 282.69922 329.50977 L 282.69922 355.93945 C 282.69922 445.15452 354.15167 518.39066 442.68555 521.29297 L 442.68555 521.39453 L 470.89062 521.39453 L 476.35352 521.39453 L 483.80078 521.39453 L 483.80078 536.03711 L 483.80078 590.67188 L 483.80078 634.01172 C 483.80078 671.37222 501.70967 706.54737 531.91797 728.53125 C 562.12621 750.5121 601.10438 756.7253 636.65234 745.23438 L 619.30078 691.5625 C 600.86782 697.52131 580.77035 694.31323 565.10938 682.91406 C 549.44552 671.51782 540.21289 653.3835 540.21289 634.01172 L 540.21289 590.67188 L 553.10547 590.67188 L 575.85352 590.67188 L 581.3125 590.67188 L 581.3125 590.58398 C 669.84938 587.68163 741.30078 514.44266 741.30078 425.23047 L 741.30078 397.02539 L 741.30078 342.38867 L 713.09375 342.38867 L 677.45312 342.38867 L 671.99219 342.38867 L 643.78906 342.38867 L 643.78906 342.47852 C 600.73342 343.89116 561.72156 361.93681 533 390.36523 C 512.76642 324.32759 452.12345 275.56547 380.20703 273.20508 L 380.20703 273.11523 L 374.75 273.11523 L 352.00391 273.11523 L 310.90625 273.11523 L 282.69922 273.10352 z M 649.25 398.78711 L 671.99219 398.78711 L 677.45312 398.78711 L 684.89062 398.78711 L 684.89062 425.21875 C 684.89062 485.77151 636.40623 534.25586 575.85352 534.25586 L 553.10547 534.25586 L 540.21289 534.25586 L 540.21289 507.82812 C 540.21289 447.27536 588.69436 398.78711 649.25 398.78711 z "
|
||||
transform="matrix(0.198132,0,0,0.198132,18.19494,70.930394)"
|
||||
id="path1339" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 3.3 KiB |
|
@ -1,18 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Twigs</title>
|
||||
<link rel="stylesheet" href="style.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="center">
|
||||
<div class="logo"></div>
|
||||
<noscript>
|
||||
<p>JavaScript is required to use this app</p>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,16 +0,0 @@
|
|||
window.onload = () => {
|
||||
const container = document.getElementsByClassName('center')[0]
|
||||
container.innerHTML += `
|
||||
<div class="flex-full-width">
|
||||
<button class="button-primary" onclick="login()">Login</button>
|
||||
<button class="button-secondary" onclick="register()">Register</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function login() {
|
||||
console.log('show login form')
|
||||
}
|
||||
|
||||
function register() {
|
||||
console.log('show registration form')
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Segoe UI", "Product Sans", "Roboto", "San Francisco", sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-accent: #004800;
|
||||
--color-on-accent: #FFFFFF;
|
||||
--color-on-background: #000000;
|
||||
--logo: url("/img/logo-color.svg");
|
||||
--background-color-primary: #ffffff;
|
||||
--background-color-secondary: #bbbbbb;
|
||||
}
|
||||
|
||||
@media all and (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-accent: #baff33;
|
||||
--color-on-accent: #000000;
|
||||
--color-on-background: #FFFFFF;
|
||||
--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));
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.center {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background-image: var(--logo);
|
||||
background-size: contain;
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 400px) {
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-full-width {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue