Compare commits

...

4 commits
main ... rust

Author SHA1 Message Date
6bbb421167
Add actix-web 2023-10-18 19:48:49 -06:00
04802434fd
Cargo init 2023-10-04 20:10:54 -06:00
f3edf5044b
Ignore tags file 2023-10-04 20:46:14 -06:00
f0e937e8b4
Nuke 2023-10-04 20:45:58 -06:00
99 changed files with 1250 additions and 5170 deletions

28
.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

9
Cargo.toml Normal file
View 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
View file

@ -1 +0,0 @@
build/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
package com.wbrawner.twigs
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(val message: String)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1 +0,0 @@
build/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1 +0,0 @@
build/

View file

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

View file

@ -1,7 +0,0 @@
package com.wbrawner.twigs
import com.wbrawner.twigs.model.PasswordResetToken
interface EmailService {
fun sendPasswordResetEmail(token: PasswordResetToken, to: String)
}

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs
interface Identifiable {
val id: String
}

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs
interface Logger {
fun log(message: String)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1 +0,0 @@
build/

View file

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

View file

@ -1,6 +0,0 @@
package com.wbrawner.twigs.db
data class DatabaseMetadata(
val version: Int = 0,
val salt: String = ""
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

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

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

View file

@ -1,3 +0,0 @@
rootProject.name = "twigs"
include("core", "api", "app", "storage", "db", "web")
include("testhelpers")

17
src/main.rs Normal file
View 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
View file

@ -1 +0,0 @@
build/

View file

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

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.Budget
interface BudgetRepository : Repository<Budget>

View file

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

View file

@ -1,5 +0,0 @@
package com.wbrawner.twigs.storage
import com.wbrawner.twigs.model.PasswordResetToken
interface PasswordResetRepository : Repository<PasswordResetToken> {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
build/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1,2 +0,0 @@
build/
src/main/resources/twigs/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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