diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..d83c709 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + `java-library` +} + +val ktorVersion: String by rootProject.extra + +dependencies { + implementation(kotlin("stdlib")) + api(project(":core")) + implementation(project(":storage")) + api("io.ktor:ktor-server-core:$ktorVersion") + api("io.ktor:ktor-auth:$ktorVersion") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.getByName("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt new file mode 100644 index 0000000..827d68d --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetApi.kt @@ -0,0 +1,32 @@ +package com.wbrawner.twigs + +import com.wbrawner.twigs.model.Budget +import com.wbrawner.twigs.model.UserPermission +import java.util.* + +data class BudgetRequest( + val name: String? = null, + val description: String? = null, + val users: Set? = null +) + +data class BudgetResponse( + val id: String, + val name: String?, + val description: String?, + private val users: List +) { + constructor(budget: Budget, users: Iterable) : this( + Objects.requireNonNull(budget.id), + budget.name, + budget.description, + users.map { userPermission: UserPermission -> + UserPermissionResponse( + userPermission.userId, + userPermission.permission + ) + } + ) +} + +data class BudgetBalanceResponse(val id: String, val balance: Long) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt new file mode 100644 index 0000000..ef2b4cf --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/BudgetRoutes.kt @@ -0,0 +1,124 @@ +package com.wbrawner.twigs + +import com.wbrawner.twigs.model.Budget +import com.wbrawner.twigs.model.Permission +import com.wbrawner.twigs.model.UserPermission +import com.wbrawner.twigs.storage.BudgetRepository +import com.wbrawner.twigs.storage.PermissionRepository +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.util.pipeline.* + +fun Application.budgetRoutes( + budgetRepository: BudgetRepository, + permissionRepository: PermissionRepository +) { + suspend fun PipelineContext.budgetWithPermission( + budgetId: String, + permission: Permission, + block: suspend (Budget) -> Unit + ) { + val session = call.principal()!! + val userPermission = + permissionRepository.findAllByUserId(session.userId).firstOrNull { it.budgetId == budgetId } + if (userPermission?.permission?.isNotAtLeast(permission) != true) { + call.respond(HttpStatusCode.Forbidden) + return + } + block(budgetRepository.findAllByIds(listOf(budgetId)).first()) + } + + routing { + authenticate(optional = false) { + get("/") { + val session = call.principal()!! + val budgetIds = permissionRepository.findAllByUserId(session.userId).map { it.budgetId } + val budgets = budgetRepository.findAllByIds(budgetIds).map { + BudgetResponse(it, permissionRepository.findAllByBudgetId(it.id)) + } + if (call.request.contentType() == ContentType.Application.Json) { + + } else { + call.respondHtml() + } + call.respond(budgets) + } + + get("/{id}") { + budgetWithPermission(budgetId = call.parameters["id"]!!, Permission.READ) { budget -> + val users = permissionRepository.findAllByBudgetId(budget.id) + call.respond(BudgetResponse(budget, users)) + } + } + + post("/{id}") { + val session = call.principal()!! + val request = call.receive() + if (request.name.isNullOrBlank()) { + call.respond(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(call.parameters["id"]!!, Permission.MANAGE) { budget -> + val request = call.receive() + 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.findAllByBudgetId(budget.id) + permissionRepository.findAllByBudgetId(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(budgetId = call.parameters["id"]!!, Permission.READ) { budget -> + budgetRepository.delete(budget) + call.respond(HttpStatusCode.NoContent) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/category/Category.kt b/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt similarity index 60% rename from app/src/main/kotlin/com/wbrawner/twigs/server/category/Category.kt rename to api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt index f291925..38c7789 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/category/Category.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/CategoryApi.kt @@ -1,23 +1,6 @@ -package com.wbrawner.twigs.server.category +package com.wbrawner.twigs -import com.wbrawner.twigs.server.budget.Budget -import com.wbrawner.twigs.server.randomString -import javax.persistence.* - -@Entity -data class Category( - @Id - val id: String = randomString(), - var title: String= "", - var description: String? = null, - var amount: Long = 0L, - @field:ManyToOne - @field:JoinColumn(nullable = false) - var budget: Budget? = null, - var expense: Boolean = true, - @field:Column(nullable = false, columnDefinition = "boolean default false") - var archived: Boolean = false -) +import com.wbrawner.twigs.model.Category data class NewCategoryRequest( val title: String, diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/ErrorResponse.kt b/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt similarity index 57% rename from app/src/main/kotlin/com/wbrawner/twigs/server/ErrorResponse.kt rename to api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt index 5df9157..23613f6 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/ErrorResponse.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/ErrorResponse.kt @@ -1,3 +1,3 @@ -package com.wbrawner.twigs.server +package com.wbrawner.twigs data class ErrorResponse(val message: String) \ No newline at end of file diff --git a/api/src/main/kotlin/com/wbrawner/twigs/Session.kt b/api/src/main/kotlin/com/wbrawner/twigs/Session.kt new file mode 100644 index 0000000..d91a063 --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/Session.kt @@ -0,0 +1,12 @@ +package com.wbrawner.twigs + +import io.ktor.auth.* +import java.util.* + +data class Session( + val userId: String = "", + val id: String = randomString(), + val token: String = randomString(255), + var expiration: Date = twoWeeksFromNow +) : Principal + diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/Transaction.kt b/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt similarity index 57% rename from app/src/main/kotlin/com/wbrawner/twigs/server/transaction/Transaction.kt rename to api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt index a06b815..f1fb3ec 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/Transaction.kt +++ b/api/src/main/kotlin/com/wbrawner/twigs/TransactionApi.kt @@ -1,28 +1,6 @@ -package com.wbrawner.twigs.server.transaction +package com.wbrawner.twigs -import com.wbrawner.twigs.server.budget.Budget -import com.wbrawner.twigs.server.category.Category -import com.wbrawner.twigs.server.randomString -import com.wbrawner.twigs.server.user.User -import java.time.Instant -import javax.persistence.Entity -import javax.persistence.Id -import javax.persistence.JoinColumn -import javax.persistence.ManyToOne - -@Entity -data class Transaction( - @Id - val id: String = randomString(), - var title: String? = null, - var description: String? = null, - var date: Instant? = null, - var amount: Long? = null, - @field:ManyToOne var category: Category? = null, - var expense: Boolean? = null, - @field:JoinColumn(nullable = false) @field:ManyToOne val createdBy: User? = null, - @field:JoinColumn(nullable = false) @field:ManyToOne var budget: Budget? = null -) +import com.wbrawner.twigs.model.Transaction data class NewTransactionRequest( val title: String, diff --git a/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt new file mode 100644 index 0000000..cb6ce0c --- /dev/null +++ b/api/src/main/kotlin/com/wbrawner/twigs/UserApi.kt @@ -0,0 +1,41 @@ +package com.wbrawner.twigs + +import com.wbrawner.twigs.model.Permission +import com.wbrawner.twigs.model.User +import java.util.* + +data class NewUserRequest( + val username: String, + val password: String, + val email: String? = null +) + +data class UpdateUserRequest( + val username: String? = null, + val password: String? = null, + val email: String? = null +) + +data class LoginRequest(val username: String? = null, val password: String? = null) + +data class UserResponse(val id: String, val username: String, val email: String?) { + constructor(user: User) : this(user.id, user.name, user.email) +} + +data class UserPermissionRequest( + val user: String, + val permission: Permission = Permission.READ +) + +data class UserPermissionResponse(val user: String, val permission: Permission?) + +data class SessionResponse(val token: String, val expiration: String) { + constructor(session: Session) : this(session.token, session.expiration.toInstant().toString()) +} + +data class PasswordResetRequest( + val userId: Long, + val id: String = randomString(), + private val date: Calendar = GregorianCalendar(), + private val token: String = randomString() +) \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d66c510..5bfbda7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,9 @@ val kotlinVersion: String by rootProject.extra val ktorVersion: String by rootProject.extra dependencies { + implementation(project(":api")) + implementation(project(":core")) + implementation(project(":storage")) implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion") implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion") implementation("io.ktor:ktor-server-core:$ktorVersion") diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index 7994042..88e4485 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -1,15 +1,13 @@ package com.wbrawner.twigs.server +import com.wbrawner.twigs.budgetRoutes import io.ktor.application.* -import io.ktor.response.* -import io.ktor.routing.* +import io.ktor.auth.* fun main(args: Array): Unit = io.ktor.server.cio.EngineMain.main(args) -fun Application.module(testing: Boolean = false) { - routing { - get("/") { - call.respondText("Hello, world!") - } - } +fun Application.module(budgetReposi) { + + install(Authentication) + budgetRoutes() } \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/TwigsServerApplication.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/TwigsServerApplication.kt deleted file mode 100644 index d41e457..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/TwigsServerApplication.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.wbrawner.twigs.server - -import org.springframework.boot.SpringApplication -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.scheduling.annotation.EnableScheduling - -@SpringBootApplication -@EnableScheduling -open class TwigsServerApplication { - companion object { - @JvmStatic - fun main(args: Array) { - SpringApplication.run(TwigsServerApplication::class.java, *args) - } - } -} diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Utils.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Utils.kt deleted file mode 100644 index 9de5c05..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Utils.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.wbrawner.twigs.server - -import com.wbrawner.twigs.server.budget.Budget -import com.wbrawner.twigs.server.permission.Permission -import com.wbrawner.twigs.server.permission.UserPermissionRepository -import com.wbrawner.twigs.server.transaction.TransactionRepository -import com.wbrawner.twigs.server.user.User -import org.springframework.http.HttpStatus -import org.springframework.http.ResponseEntity -import org.springframework.security.core.context.SecurityContextHolder -import java.util.* - -private val CALENDAR_FIELDS = intArrayOf( - Calendar.MILLISECOND, - Calendar.SECOND, - Calendar.MINUTE, - Calendar.HOUR_OF_DAY, - Calendar.DATE -) - -val firstOfMonth: Date - get() = GregorianCalendar().run { - for (calField in CALENDAR_FIELDS) { - set(calField, getActualMinimum(calField)) - } - time - } - -val endOfMonth: Date - get() = GregorianCalendar().run { - for (calField in CALENDAR_FIELDS) { - set(calField, getActualMaximum(calField)) - } - time - } - -val twoWeeksFromNow: Date - get() = GregorianCalendar().run { - add(Calendar.DATE, 14) - time - } - -val currentUser: User? - get() = SecurityContextHolder.getContext().authentication.principal as? User - -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() -} - -fun getBudgetWithPermission( - transactionRepository: TransactionRepository, - userPermissionsRepository: UserPermissionRepository, - transactionId: String, - permission: Permission, - action: (Budget) -> ResponseEntity -): ResponseEntity { - val transaction = transactionRepository.findById(transactionId).orElse(null) - ?: return ResponseEntity.notFound().build() - val userPermission = userPermissionsRepository.findByUserAndBudget_Id( - currentUser, - transaction.budget!!.id - ).orElse(null) - ?: return ResponseEntity.notFound().build() - if (userPermission.permission.isNotAtLeast(permission)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build() - } - return action(userPermission.budget!!) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/Budget.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/budget/Budget.kt deleted file mode 100644 index 7e62e44..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/Budget.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.wbrawner.twigs.server.budget - -import com.wbrawner.twigs.server.category.Category -import com.wbrawner.twigs.server.permission.UserPermission -import com.wbrawner.twigs.server.permission.UserPermissionRequest -import com.wbrawner.twigs.server.permission.UserPermissionResponse -import com.wbrawner.twigs.server.randomString -import com.wbrawner.twigs.server.transaction.Transaction -import java.util.* -import javax.persistence.Entity -import javax.persistence.Id -import javax.persistence.OneToMany - -@Entity -data class Budget( - @Id - var id: String = randomString(), - var name: String? = null, - var description: String? = null, - var currencyCode: String? = "USD", - @OneToMany(mappedBy = "budget") - val transactions: Set = TreeSet(), - @OneToMany(mappedBy = "budget") - val categories: Set = TreeSet(), - @OneToMany(mappedBy = "budget") - val users: Set = HashSet() -) - -data class BudgetRequest( - val name: String = "", - val description: String = "", - val users: Set = emptySet() -) - -data class BudgetResponse( - val id: String, - val name: String?, - val description: String?, - private val users: List -) { - constructor(budget: Budget, users: List) : this( - Objects.requireNonNull(budget.id), - budget.name, - budget.description, - users.map { userPermission: UserPermission -> UserPermissionResponse(userPermission) } - ) -} - -data class BudgetBalanceResponse(val id: String, val balance: Long) \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetController.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetController.kt deleted file mode 100644 index fa43e17..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetController.kt +++ /dev/null @@ -1,165 +0,0 @@ -package com.wbrawner.twigs.server.budget - -import com.wbrawner.twigs.server.currentUser -import com.wbrawner.twigs.server.permission.Permission -import com.wbrawner.twigs.server.permission.UserPermission -import com.wbrawner.twigs.server.permission.UserPermissionRepository -import com.wbrawner.twigs.server.permission.UserPermissionRequest -import com.wbrawner.twigs.server.transaction.TransactionRepository -import com.wbrawner.twigs.server.user.User -import com.wbrawner.twigs.server.user.UserRepository -import org.slf4j.LoggerFactory -import org.springframework.data.domain.PageRequest -import org.springframework.http.HttpStatus -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.* -import java.time.Instant -import java.util.function.Consumer -import java.util.function.Function -import javax.transaction.Transactional - -@RestController -@RequestMapping(value = ["/budgets"]) -@Transactional -open class BudgetController( - private val budgetRepository: BudgetRepository, - private val transactionRepository: TransactionRepository, - private val userRepository: UserRepository, - private val userPermissionsRepository: UserPermissionRepository -) { - private val logger = LoggerFactory.getLogger(BudgetController::class.java) - - @GetMapping(value = [""], produces = [MediaType.APPLICATION_JSON_VALUE]) - open fun getBudgets(page: Int?, count: Int?): ResponseEntity> { - val user = currentUser ?: return ResponseEntity.status(401).build() - val budgets: List = userPermissionsRepository.findAllByUser( - user, - PageRequest.of( - page ?: 0, - count ?: 1000 - ) - ).mapNotNull { userPermission: UserPermission -> - val budget = userPermission.budget ?: return@mapNotNull null - BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null)) - } - return ResponseEntity.ok(budgets) - } - - @GetMapping(value = ["/{id}"], produces = [MediaType.APPLICATION_JSON_VALUE]) - open fun getBudget(@PathVariable id: String): ResponseEntity { - return getBudgetWithPermission(id, Permission.READ) { budget: Budget -> - ResponseEntity.ok(BudgetResponse(budget, userPermissionsRepository.findAllByBudget(budget, null))) - } - } - - @GetMapping(value = ["/{id}/balance"], produces = [MediaType.APPLICATION_JSON_VALUE]) - open fun getBudgetBalance( - @PathVariable id: String, - @RequestParam(value = "from", required = false) from: String? = null, - @RequestParam(value = "to", required = false) to: String? = null - ): ResponseEntity { - return getBudgetWithPermission(id, Permission.READ) { budget: Budget -> - val fromInstant: Instant = try { - Instant.parse(from) - } catch (e: Exception) { - if (e !is NullPointerException) logger.error( - "Failed to parse '$from' to Instant for 'from' parameter", - e - ) - Instant.ofEpochSecond(0) - } - val toInstant: Instant = try { - Instant.parse(to) - } catch (e: Exception) { - if (e !is NullPointerException) logger.error("Failed to parse '$to' to Instant for 'to' parameter", e) - Instant.now() - } - val balance = transactionRepository.sumBalanceByBudgetId(budget.id, fromInstant, toInstant) - ResponseEntity.ok(BudgetBalanceResponse(budget.id, balance)) - } - } - - @PostMapping( - value = [""], - consumes = [MediaType.APPLICATION_JSON_VALUE], - produces = [MediaType.APPLICATION_JSON_VALUE] - ) - open fun newBudget(@RequestBody request: BudgetRequest): ResponseEntity { - val budget = budgetRepository.save(Budget(request.name, request.description)) - val users: MutableSet = request.users - .mapNotNull { userPermissionRequest: UserPermissionRequest -> - val user = userRepository.findById(userPermissionRequest.user!!).orElse(null) ?: return@mapNotNull null - userPermissionsRepository.save( - UserPermission(budget, user, userPermissionRequest.permission) - ) - } - .toMutableSet() - val currentUserIncluded = users.any { userPermission: UserPermission -> userPermission.user!!.id == currentUser!!.id } - if (!currentUserIncluded) { - users.add( - userPermissionsRepository.save( - UserPermission(budget, currentUser!!, Permission.OWNER) - ) - ) - } - return ResponseEntity.ok(BudgetResponse(budget, ArrayList(users))) - } - - @PutMapping( - value = ["/{id}"], - consumes = [MediaType.APPLICATION_JSON_VALUE], - produces = [MediaType.APPLICATION_JSON_VALUE] - ) - open fun updateBudget( - @PathVariable id: String, - @RequestBody request: BudgetRequest - ): ResponseEntity { - return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget -> - budget.name = request.name - budget.description = request.description - val users = ArrayList() - if (request.users.isNotEmpty()) { - request.users.forEach(Consumer { userPermissionRequest: UserPermissionRequest -> - userRepository.findById(userPermissionRequest.user!!).ifPresent { requestedUser: User -> - users.add( - userPermissionsRepository.save( - UserPermission( - budget, - requestedUser, - userPermissionRequest.permission - ) - ) - ) - } - }) - } else { - users.addAll(userPermissionsRepository.findAllByBudget(budget, null)) - } - ResponseEntity.ok(BudgetResponse(budgetRepository.save(budget), users)) - } - } - - @DeleteMapping(value = ["/{id}"], produces = [MediaType.TEXT_PLAIN_VALUE]) - open fun deleteBudget(@PathVariable id: String): ResponseEntity { - return getBudgetWithPermission(id, Permission.MANAGE) { budget: Budget -> - budgetRepository.delete(budget) - ResponseEntity.ok().build() - } - } - - private fun getBudgetWithPermission( - budgetId: String, - permission: Permission, - callback: Function> - ): ResponseEntity { - val user = currentUser ?: return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() - val userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null) - ?: return ResponseEntity.notFound().build() - if (userPermission.permission.isNotAtLeast(permission)) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).build() - } - val budget = userPermission.budget ?: return ResponseEntity.notFound().build() - return callback.apply(budget) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetRepository.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetRepository.kt deleted file mode 100644 index aee9941..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/budget/BudgetRepository.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.wbrawner.twigs.server.budget - -import org.springframework.data.repository.PagingAndSortingRepository - -interface BudgetRepository : PagingAndSortingRepository \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/category/CategoryController.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/category/CategoryController.kt index 27c8a17..4c91611 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/category/CategoryController.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/category/CategoryController.kt @@ -1,6 +1,6 @@ package com.wbrawner.twigs.server.category -import com.wbrawner.twigs.server.ErrorResponse +import com.wbrawner.twigs.ErrorResponse import com.wbrawner.twigs.server.currentUser import com.wbrawner.twigs.server.firstOfMonth import com.wbrawner.twigs.server.permission.Permission diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/JdbcUserDetailsService.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/JdbcUserDetailsService.kt deleted file mode 100644 index 8b6e0e9..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/JdbcUserDetailsService.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.wbrawner.twigs.server.config - -import com.wbrawner.twigs.server.user.UserRepository -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.security.core.userdetails.UserDetails -import org.springframework.security.core.userdetails.UserDetailsService -import org.springframework.security.core.userdetails.UsernameNotFoundException -import org.springframework.stereotype.Component - -@Component -open class JdbcUserDetailsService @Autowired constructor(private val userRepository: UserRepository) : UserDetailsService { - @Throws(UsernameNotFoundException::class) - override fun loadUserByUsername(username: String): UserDetails { - var userDetails: UserDetails? - userDetails = userRepository.findByName(username).orElse(null) - if (userDetails != null) { - return userDetails - } - userDetails = userRepository.findByEmail(username).orElse(null) - if (userDetails != null) { - return userDetails - } - throw UsernameNotFoundException("Unable to find user with username \$username") - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/MethodSecurity.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/MethodSecurity.kt deleted file mode 100644 index 36a30fc..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/MethodSecurity.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.wbrawner.twigs.server.config - -import org.springframework.context.annotation.Configuration -import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity -import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration - -@Configuration -@EnableGlobalMethodSecurity(prePostEnabled = true) -open class MethodSecurity : GlobalMethodSecurityConfiguration() \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SecurityConfig.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/SecurityConfig.kt deleted file mode 100644 index e3cd0be..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SecurityConfig.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.wbrawner.twigs.server.config - -import com.wbrawner.twigs.server.passwordresetrequest.PasswordResetRequestRepository -import com.wbrawner.twigs.server.session.UserSessionRepository -import com.wbrawner.twigs.server.user.UserRepository -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.core.env.Environment -import org.springframework.http.HttpMethod -import org.springframework.security.authentication.dao.DaoAuthenticationProvider -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder -import org.springframework.security.config.annotation.web.builders.HttpSecurity -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter -import org.springframework.security.config.http.SessionCreationPolicy -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.security.provisioning.JdbcUserDetailsManager -import org.springframework.web.cors.CorsConfiguration -import java.util.* -import javax.sql.DataSource - -@Configuration -@EnableWebSecurity -open class SecurityConfig( - private val env: Environment, - private val datasource: DataSource, - private val userSessionRepository: UserSessionRepository, - private val userRepository: UserRepository, - private val passwordResetRequestRepository: PasswordResetRequestRepository, - private val userDetailsService: JdbcUserDetailsService, - private val environment: Environment -) : WebSecurityConfigurerAdapter() { - @get:Bean - open val userDetailsManager: JdbcUserDetailsManager - get() { - val userDetailsManager = JdbcUserDetailsManager() - userDetailsManager.dataSource = datasource - return userDetailsManager - } - - @get:Bean - open val authenticationProvider: DaoAuthenticationProvider - get() { - val authProvider = TokenAuthenticationProvider(userSessionRepository, userRepository) - authProvider.setPasswordEncoder(passwordEncoder) - authProvider.setUserDetailsService(userDetailsService) - return authProvider - } - - @get:Bean - open val passwordEncoder: PasswordEncoder - get() = BCryptPasswordEncoder() - - public override fun configure(auth: AuthenticationManagerBuilder) { - auth.authenticationProvider(authenticationProvider) - } - - @Throws(Exception::class) - public override fun configure(http: HttpSecurity) { - http.authorizeRequests() - .antMatchers("/users/new", "/users/login") - .permitAll() - .anyRequest() - .authenticated() - .and() - .httpBasic() - .authenticationEntryPoint(SilentAuthenticationEntryPoint()) - .and() - .cors() - .configurationSource { - val corsConfig = CorsConfiguration() - corsConfig.applyPermitDefaultValues() - val corsDomains = environment.getProperty("twigs.cors.domains", "*") - corsConfig.allowedOrigins = Arrays.asList(*corsDomains.split(",").toTypedArray()) - corsConfig.allowedMethods = listOf( - HttpMethod.GET, - HttpMethod.POST, - HttpMethod.PUT, - HttpMethod.DELETE, - HttpMethod.OPTIONS - ).map { obj: HttpMethod -> obj.name } - corsConfig.allowCredentials = true - corsConfig - } - .and() - .csrf() - .disable() - .addFilter(TokenAuthenticationFilter(authenticationManager())) - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SessionAuthenticationToken.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/SessionAuthenticationToken.kt deleted file mode 100644 index e95461a..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SessionAuthenticationToken.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.wbrawner.twigs.server.config - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.GrantedAuthority - -/** - * Creates a token with the supplied array of authorities. - * - * @param authorities the collection of GrantedAuthoritys for the principal - * represented by this authentication object. - * @param credentials - * @param principal - */ -class SessionAuthenticationToken( - principal: Any?, - credentials: Any?, - authorities: Collection -) : UsernamePasswordAuthenticationToken(principal, credentials, authorities) \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SilentAuthenticationEntryPoint.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/SilentAuthenticationEntryPoint.kt deleted file mode 100644 index de5545a..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/SilentAuthenticationEntryPoint.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.wbrawner.twigs.server.config - -import org.springframework.security.core.AuthenticationException -import org.springframework.security.web.AuthenticationEntryPoint -import java.io.IOException -import javax.servlet.ServletException -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -/** - * Used to avoid browser prompts for authentication - */ -class SilentAuthenticationEntryPoint : AuthenticationEntryPoint { - @Throws(IOException::class, ServletException::class) - override fun commence( - request: HttpServletRequest, - response: HttpServletResponse, - authException: AuthenticationException - ) { - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.message) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationFilter.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationFilter.kt deleted file mode 100644 index 2fbe811..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationFilter.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.wbrawner.twigs.server.config - -import org.springframework.security.authentication.AuthenticationManager -import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter -import java.io.IOException -import javax.servlet.FilterChain -import javax.servlet.ServletException -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -class TokenAuthenticationFilter(authenticationManager: AuthenticationManager?) : - BasicAuthenticationFilter(authenticationManager) { - @Throws(IOException::class, ServletException::class) - override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { - val authHeader = request.getHeader("Authorization") - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - chain.doFilter(request, response) - return - } - val token = authHeader.substring(7) - val authentication = authenticationManager.authenticate(SessionAuthenticationToken(null, token, emptyList())) - SecurityContextHolder.getContext().authentication = authentication - chain.doFilter(request, response) - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationProvider.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationProvider.kt deleted file mode 100644 index 407572b..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/config/TokenAuthenticationProvider.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.wbrawner.twigs.server.config - -import com.wbrawner.twigs.server.session.UserSessionRepository -import com.wbrawner.twigs.server.twoWeeksFromNow -import com.wbrawner.twigs.server.user.UserRepository -import org.springframework.security.authentication.BadCredentialsException -import org.springframework.security.authentication.InternalAuthenticationServiceException -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.authentication.dao.DaoAuthenticationProvider -import org.springframework.security.core.Authentication -import org.springframework.security.core.AuthenticationException -import org.springframework.security.core.userdetails.UserDetails -import java.util.* - -class TokenAuthenticationProvider( - private val userSessionRepository: UserSessionRepository, - private val userRepository: UserRepository -) : DaoAuthenticationProvider() { - @Throws(AuthenticationException::class) - override fun additionalAuthenticationChecks( - userDetails: UserDetails, - authentication: UsernamePasswordAuthenticationToken - ) { - if (authentication !is SessionAuthenticationToken) { - // Additional checks aren't needed since they've already been handled - super.additionalAuthenticationChecks(userDetails, authentication) - } - } - - @Throws(AuthenticationException::class) - override fun authenticate(authentication: Authentication): Authentication { - return if (authentication is SessionAuthenticationToken) { - val session = userSessionRepository.findByToken(authentication.getCredentials() as String) - if (session!!.isEmpty || session.get().expiration.before(Date())) { - throw BadCredentialsException("Credentials expired") - } - val user = userRepository.findById(session.get().userId) - if (user.isEmpty) { - throw InternalAuthenticationServiceException("Failed to find user for token") - } - Thread { - - // Update the session on a background thread to avoid holding up the request longer than necessary - val updatedSession = session.get() - updatedSession.expiration = twoWeeksFromNow - userSessionRepository.save(updatedSession) - }.start() - SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities()) - } else { - super.authenticate(authentication) - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/passwordresetrequest/PasswordResetRequest.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/passwordresetrequest/PasswordResetRequest.kt deleted file mode 100644 index 8dbda85..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/passwordresetrequest/PasswordResetRequest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.wbrawner.twigs.server.passwordresetrequest - -import com.wbrawner.twigs.server.randomString -import com.wbrawner.twigs.server.user.User -import java.util.* -import javax.persistence.Entity -import javax.persistence.Id -import javax.persistence.ManyToOne - -@Entity -data class PasswordResetRequest( - @Id - val id: String = randomString(), - @field:ManyToOne private val user: User? = null, - private val date: Calendar = GregorianCalendar(), - private val token: String = randomString() -) \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/permission/Permission.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/permission/Permission.kt deleted file mode 100644 index c201ec7..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/permission/Permission.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.wbrawner.twigs.server.permission - -import com.wbrawner.twigs.server.budget.Budget -import com.wbrawner.twigs.server.user.User -import com.wbrawner.twigs.server.user.UserResponse -import java.io.Serializable -import javax.persistence.* - -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 isNotAtLeast(wanted: Permission): Boolean { - return ordinal < wanted.ordinal - } -} - -@Entity -data class UserPermission( - @field:EmbeddedId - val id: UserPermissionKey? = null, - @field:JoinColumn( - nullable = false, - name = "budget_id" - ) - @field:MapsId( - "budgetId" - ) - @field:ManyToOne - val budget: Budget? = null, - @field:JoinColumn( - nullable = false, - name = "user_id" - ) - @field:MapsId("userId") - @field:ManyToOne - val user: User? = null, - @field:Enumerated( - EnumType.STRING - ) - @field:JoinColumn( - nullable = false - ) - val permission: Permission = Permission.READ -) { - constructor(budget: Budget, user: User, permission: Permission) : this( - UserPermissionKey(budget.id, user.id), - budget, - user, - permission - ) -} - -@Embeddable -data class UserPermissionKey( - private val budgetId: String? = null, - private val userId: String? = null -) : Serializable - -data class UserPermissionRequest( - val user: String? = null, - val permission: Permission = Permission.READ -) - -data class UserPermissionResponse(val user: UserResponse, val permission: Permission?) { - constructor(userPermission: UserPermission) : this( - UserResponse(userPermission.user!!), - userPermission.permission - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/session/Session.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/session/Session.kt deleted file mode 100644 index 8594f95..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/session/Session.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.wbrawner.twigs.server.session - -import com.wbrawner.twigs.server.randomString -import com.wbrawner.twigs.server.twoWeeksFromNow -import javax.persistence.Entity -import javax.persistence.Id - -@Entity -data class Session(val userId: String = "") { - @Id - val id = randomString() - val token = randomString(255) - var expiration = twoWeeksFromNow -} - -data class SessionResponse(val token: String, val expiration: String) { - constructor(session: Session) : this(session.token, session.expiration.toInstant().toString()) -} diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/TransactionController.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/TransactionController.kt index 2471895..081e40e 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/TransactionController.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/transaction/TransactionController.kt @@ -1,6 +1,6 @@ package com.wbrawner.twigs.server.transaction -import com.wbrawner.twigs.server.ErrorResponse +import com.wbrawner.twigs.ErrorResponse import com.wbrawner.twigs.server.category.Category import com.wbrawner.twigs.server.category.CategoryRepository import com.wbrawner.twigs.server.currentUser diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/user/User.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/user/User.kt deleted file mode 100644 index 3f2dec5..0000000 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/user/User.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.wbrawner.twigs.server.user - -import com.wbrawner.twigs.server.randomString -import org.springframework.security.core.GrantedAuthority -import org.springframework.security.core.authority.SimpleGrantedAuthority -import org.springframework.security.core.userdetails.UserDetails -import javax.persistence.Column -import javax.persistence.Entity -import javax.persistence.Id -import javax.persistence.Transient - -@Entity -data class User( - @Id - val id: String = randomString(), - @field:Column(name = "username") - var name: String = "", - @field:Column(name = "password") - var passphrase: String = "", - @Transient - private val _authorities: Collection = listOf(SimpleGrantedAuthority("USER")), - var email: String? = null -) : UserDetails { - - override fun getUsername(): String = name - - override fun getPassword(): String = passphrase - - override fun isAccountNonExpired(): Boolean { - return true - } - - override fun isAccountNonLocked(): Boolean { - return true - } - - override fun isCredentialsNonExpired(): Boolean { - return true - } - - override fun isEnabled(): Boolean { - return true - } - - override fun getAuthorities(): Collection { - return _authorities - } -} - -data class NewUserRequest( - val username: String, - val password: String, - val email: String? = null -) - -data class UpdateUserRequest( - val username: String? = null, - val password: String? = null, - val email: String? = null -) - -data class LoginRequest(val username: String? = null, val password: String? = null) - -data class UserResponse(val id: String, val username: String, val email: String?) { - constructor(user: User) : this(user.id, user.username, user.email) -} \ No newline at end of file diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/user/UserController.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/user/UserController.kt index de0a6b7..9a153c5 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/user/UserController.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/user/UserController.kt @@ -1,6 +1,6 @@ package com.wbrawner.twigs.server.user -import com.wbrawner.twigs.server.ErrorResponse +import com.wbrawner.twigs.ErrorResponse import com.wbrawner.twigs.server.budget.BudgetRepository import com.wbrawner.twigs.server.currentUser import com.wbrawner.twigs.server.permission.UserPermission diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..4366218 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("jvm") + `java-library` +} + +val ktorVersion: String by rootProject.extra + +dependencies { + implementation(kotlin("stdlib")) + testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.getByName("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt new file mode 100644 index 0000000..a796f34 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/Utils.kt @@ -0,0 +1,43 @@ +package com.wbrawner.twigs + +import java.util.* + +private val CALENDAR_FIELDS = intArrayOf( + Calendar.MILLISECOND, + Calendar.SECOND, + Calendar.MINUTE, + Calendar.HOUR_OF_DAY, + Calendar.DATE +) + +val firstOfMonth: Date + get() = GregorianCalendar().run { + for (calField in CALENDAR_FIELDS) { + set(calField, getActualMinimum(calField)) + } + time + } + +val endOfMonth: Date + get() = GregorianCalendar().run { + for (calField in CALENDAR_FIELDS) { + set(calField, getActualMaximum(calField)) + } + time + } + +val twoWeeksFromNow: Date + get() = GregorianCalendar().run { + add(Calendar.DATE, 14) + time + } + +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() +} diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt new file mode 100644 index 0000000..d82b204 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Budget.kt @@ -0,0 +1,10 @@ +package com.wbrawner.twigs.model + +import com.wbrawner.twigs.randomString + +data class Budget( + var id: String = randomString(), + var name: String? = null, + var description: String? = null, + var currencyCode: String? = "USD", +) diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt new file mode 100644 index 0000000..2d6607e --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Category.kt @@ -0,0 +1,13 @@ +package com.wbrawner.twigs.model + +import com.wbrawner.twigs.randomString + +data class Category( + val id: String = randomString(), + var title: String = "", + var description: String? = null, + var amount: Long = 0L, + var budget: Budget? = null, + var expense: Boolean = true, + var archived: Boolean = false +) diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt new file mode 100644 index 0000000..592ae20 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/Transaction.kt @@ -0,0 +1,16 @@ +package com.wbrawner.twigs.model + +import com.wbrawner.twigs.randomString +import java.time.Instant + +data class Transaction( + val id: String = randomString(), + val title: String? = null, + val description: String? = null, + val date: Instant? = null, + val amount: Long? = null, + val category: Category? = null, + val expense: Boolean? = null, + val createdBy: User? = null, + val budget: Budget? = null +) diff --git a/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt b/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt new file mode 100644 index 0000000..dadfcd7 --- /dev/null +++ b/core/src/main/kotlin/com/wbrawner/twigs/model/User.kt @@ -0,0 +1,42 @@ +package com.wbrawner.twigs.model + +import com.wbrawner.twigs.randomString + +data class User( + val id: String = randomString(), + val name: String = "", + val password: String = "", + val email: String? = null +) + +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 isNotAtLeast(wanted: Permission): Boolean { + return ordinal < wanted.ordinal + } +} + +data class UserPermission( + val budgetId: String, + val userId: String, + val permission: Permission = Permission.READ +) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 80d27e4..46cc111 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ rootProject.name = "twigs" -include(":app") \ No newline at end of file +include("core", "api", "app") +include("storage") diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts new file mode 100644 index 0000000..6625996 --- /dev/null +++ b/storage/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("jvm") + `java-library` +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(project(":core")) + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0") + implementation("org.postgresql:postgresql:42.2.23") + testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") +} + +tasks.getByName("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/BudgetRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/BudgetRepository.kt new file mode 100644 index 0000000..b83b24a --- /dev/null +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/BudgetRepository.kt @@ -0,0 +1,5 @@ +package com.wbrawner.twigs.storage + +import com.wbrawner.twigs.model.Budget + +interface BudgetRepository : Repository \ No newline at end of file diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt new file mode 100644 index 0000000..ebc21a5 --- /dev/null +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/PermissionRepository.kt @@ -0,0 +1,8 @@ +package com.wbrawner.twigs.storage + +import com.wbrawner.twigs.model.UserPermission + +interface PermissionRepository : Repository { + fun findAllByBudgetId(budgetId: String): List + fun findAllByUserId(userId: String): List +} \ No newline at end of file diff --git a/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt new file mode 100644 index 0000000..feddcb8 --- /dev/null +++ b/storage/src/main/kotlin/com/wbrawner/twigs/storage/Repository.kt @@ -0,0 +1,13 @@ +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 { + suspend fun findAll(): List + suspend fun findAllByIds(id: List): List + suspend fun save(item: T): T + suspend fun delete(item: T): Boolean +} \ No newline at end of file