diff --git a/.gitignore b/.gitignore
index a6934dd..1f47ad7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,3 +72,6 @@ out/
# Development and application files
backend/app-data
backend/dev-data
+
+# Env files
+.env
diff --git a/backend/.env.debug b/backend/.env.debug
new file mode 100644
index 0000000..83e7344
--- /dev/null
+++ b/backend/.env.debug
@@ -0,0 +1,4 @@
+POSTGRES_HOST=127.0.0.1
+POSTGRES_DB=simplesplit
+POSTGRES_USER=changeme
+POSTGRES_PASSWORD=changeme
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index bd9fe51..cbd7d68 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -8,4 +8,4 @@ FROM openjdk:17
EXPOSE 8080 8443
RUN mkdir /app /data /app-data
COPY --from=build /home/sbtuser/src/app/target/scala-*/simple-split-backend.jar /app/simplesplit-backend.jar
-ENTRYPOINT ["java", "-jar", "/app/simplesplit-backend.jar"]
+ENTRYPOINT ["java", "-jar", "/app/simplesplit-backend.jar", "--protocol", "http"]
diff --git a/backend/app/src/main/resources/application.conf b/backend/app/src/main/resources/application.conf
deleted file mode 100644
index 40fb8bb..0000000
--- a/backend/app/src/main/resources/application.conf
+++ /dev/null
@@ -1,17 +0,0 @@
-h2db {
- dataSourceClassName = "org.h2.jdbcx.JdbcDataSource"
- dataSource {
- url = "jdbc:h2:file:./app-data/db/simplesplit;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:init.sql'"
- user = "sa"
- password = ""
- }
-}
-
-test-h2db {
- dataSourceClassName = "org.h2.jdbcx.JdbcDataSource"
- dataSource {
- url = "jdbc:h2:mem:simplesplit;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM 'classpath:init.sql'"
- user = "sa"
- password = ""
- }
-}
\ No newline at end of file
diff --git a/backend/app/src/main/resources/init.sql b/backend/app/src/main/resources/init.sql
deleted file mode 100644
index bf0f808..0000000
--- a/backend/app/src/main/resources/init.sql
+++ /dev/null
@@ -1,51 +0,0 @@
-CREATE TABLE IF NOT EXISTS users (
- uid UUID PRIMARY KEY,
- name VARCHAR(255) NOT NULL
-);
-
-CREATE TABLE IF NOT EXISTS groups (
- uid UUID PRIMARY KEY,
- title VARCHAR(255) NOT NULL,
- description VARCHAR(255) NOT NULL,
- password_hash VARCHAR(255),
- currency_iso_code VARCHAR(3) NOT NULL,
- created TIMESTAMP NOT NULL,
- modified TIMESTAMP NOT NULl
-);
-
-CREATE TABLE IF NOT EXISTS group_members (
- uid UUID PRIMARY KEY,
- group_uid UUID NOT NULL REFERENCES groups(uid) ON DELETE CASCADE,
- user_uid UUID NOT NULL REFERENCES users(uid) ON DELETE CASCADE
-);
-
-CREATE TABLE IF NOT EXISTS expenses (
- uid UUID PRIMARY KEY,
- group_uid UUID NOT NULL,
- title VARCHAR(255) NOT NULL,
- description VARCHAR(255) NOT NULL,
- amount DOUBLE NOT NULL,
- is_split_between_all BOOLEAN DEFAULT FALSE,
- created TIMESTAMP NOT NULL,
- modified TIMESTAMP NOT NULl
-);
-
-CREATE TABLE IF NOT EXISTS paid_by (
- expense_uid UUID NOT NULL REFERENCES expenses(uid) ON DELETE CASCADE,
- group_uid UUID NOT NULL,
- member_uid UUID NOT NULL,
- PRIMARY KEY (expense_uid, member_uid) -- Composite primary key to ensure uniqueness
-);
-
-CREATE TABLE IF NOT EXISTS split_between (
- expense_uid UUID NOT NULL REFERENCES expenses(uid) ON DELETE CASCADE,
- group_uid UUID NOT NULL,
- member_uid UUID NOT NULL,
- PRIMARY KEY (expense_uid, member_uid) -- Composite primary key to ensure uniqueness
-);
-
-CREATE TABLE IF NOT EXISTS currencies (
- iso_code VARCHAR(3) PRIMARY KEY,
- name VARCHAR(255) NOT NULL,
- symbol VARCHAR(16) NOT NULL
-);
\ No newline at end of file
diff --git a/backend/app/src/main/resources/logback.xml b/backend/app/src/main/resources/logback.xml
index e0941f5..5256d16 100644
--- a/backend/app/src/main/resources/logback.xml
+++ b/backend/app/src/main/resources/logback.xml
@@ -22,5 +22,5 @@
-
+
\ No newline at end of file
diff --git a/backend/app/src/main/scala/com/github/ai/split/Layers.scala b/backend/app/src/main/scala/com/github/ai/split/Layers.scala
index bea31de..cf7015f 100644
--- a/backend/app/src/main/scala/com/github/ai/split/Layers.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/Layers.scala
@@ -2,6 +2,7 @@ package com.github.ai.split
import com.github.ai.split.data.JsonSerializer
import com.github.ai.split.data.currency.CurrencyParser
+import com.github.ai.split.data.db.{AppDatabase, DatabaseConnectionFactory}
import com.github.ai.split.data.db.dao.{
CurrencyEntityDao,
ExpenseEntityDao,
@@ -49,8 +50,15 @@ import zio.{ZIO, ZLayer}
object Layers {
+ // Database
+ val appDatabase = ZLayer.fromZIO {
+ for {
+ db <- DatabaseConnectionFactory().create()
+ } yield AppDatabase(db)
+ }
+
// Dao's
- val userDao = ZLayer.fromFunction(UserEntityDao(_))
+ val userDao = ZLayer.fromFunction(UserEntityDao(_, _))
val groupDao = ZLayer.fromFunction(GroupEntityDao(_))
val groupMemberDao = ZLayer.fromFunction(GroupMemberEntityDao(_))
val expenseDao = ZLayer.fromFunction(ExpenseEntityDao(_))
@@ -86,7 +94,7 @@ object Layers {
val removeExpenseUseCase = ZLayer.fromFunction(RemoveExpenseUseCase(_))
val exportGroupDataUseCase = ZLayer.fromFunction(ExportGroupDataUseCase(_, _))
val updateMemberUseCase = ZLayer.fromFunction(UpdateMemberUseCase(_, _, _, _))
- val startUpServerUseCase = ZLayer.fromFunction(StartUpServerUseCase(_, _, _))
+ val startUpServerUseCase = ZLayer.fromFunction(StartUpServerUseCase(_, _, _, _))
val fillCurrencyDataUseCase = ZLayer.fromFunction(FillCurrencyDataUseCase(_, _))
val validateCurrencyUseCase = ZLayer.fromFunction(ValidateCurrencyUseCase(_))
diff --git a/backend/app/src/main/scala/com/github/ai/split/Main.scala b/backend/app/src/main/scala/com/github/ai/split/Main.scala
index e718251..6e37c0c 100644
--- a/backend/app/src/main/scala/com/github/ai/split/Main.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/Main.scala
@@ -1,13 +1,12 @@
package com.github.ai.split
import com.github.ai.split.data.currency.CurrencyParser
+import com.github.ai.split.data.db.AppDatabase
import com.github.ai.split.domain.CliArgumentParser
import com.github.ai.split.domain.usecases.{FillTestDataUseCase, StartUpServerUseCase}
import com.github.ai.split.entity.CliArguments
import com.github.ai.split.entity.HttpProtocol.{HTTP, HTTPS}
import com.github.ai.split.presentation.routes.{CurrencyRoutes, ExpenseRoutes, ExportRoutes, GroupRoutes, MemberRoutes}
-import io.getquill.SnakeCase
-import io.getquill.jdbczio.Quill
import zio.*
import zio.http.*
import zio.logging.LogFormat
@@ -102,6 +101,9 @@ object Main extends ZIOAppDefault {
Layers.passwordService,
Layers.accessResolverService,
+ // Database
+ Layers.appDatabase,
+
// Repositories
Layers.expenseRepository,
Layers.groupRepository,
@@ -120,13 +122,7 @@ object Main extends ZIOAppDefault {
Layers.currencyParser,
Layers.jsonSerialized,
Server.live,
- ZLayer.succeed(serverConfig),
- Quill.H2.fromNamingStrategy(SnakeCase),
- if (arguments.isUseInMemoryDatabase) {
- Quill.DataSource.fromPrefix("test-h2db")
- } else {
- Quill.DataSource.fromPrefix("h2db")
- }
+ ZLayer.succeed(serverConfig)
)
} yield ()
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala
new file mode 100644
index 0000000..3eb8f84
--- /dev/null
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/AppDatabase.scala
@@ -0,0 +1,160 @@
+package com.github.ai.split.data.db
+
+import com.github.ai.split.data.db.model.DatabaseConnection
+import com.github.ai.split.entity.db.{
+ CurrencyEntity,
+ ExpenseEntity,
+ ExpenseUid,
+ GroupEntity,
+ GroupMemberEntity,
+ GroupUid,
+ MemberUid,
+ PaidByEntity,
+ SplitBetweenEntity,
+ Timestamp,
+ UserEntity,
+ UserUid
+}
+import com.github.ai.split.entity.exception.DomainError
+import com.github.ai.split.utils.{toDomainError, toProperties}
+import slick.jdbc.PostgresProfile.api.*
+import slick.lifted.ProvenShape
+import zio.{IO, ZIO}
+import zio.direct.*
+
+import java.util.{Properties, UUID}
+
+class AppDatabase(
+ private val connection: DatabaseConnection
+) {
+
+ val CurrencyTable = TableQuery[CurrencyEntityTable]
+ val UserTable = TableQuery[UserEntityTable]
+ val GroupMemberTable = TableQuery[GroupMemberEntityTable]
+ val PaidByTable = TableQuery[PaidByEntityTable]
+ val SplitBetweenTable = TableQuery[SplitBetweenEntityTable]
+ val ExpenseTable = TableQuery[ExpenseEntityTable]
+ val GroupTable = TableQuery[GroupEntityTable]
+
+ val context = Database.forURL(
+ url = connection.url,
+ user = connection.user,
+ password = connection.password,
+ driver = "org.postgresql.Driver",
+ keepAliveConnection = true,
+ prop = Map(("connectionPool", "HikariCP")).toProperties()
+ )
+
+ def initialize(): IO[DomainError, Unit] =
+ defer {
+ ZIO
+ .fromFuture { _ =>
+ context.run(
+ DBIO.seq(
+ CurrencyTable.schema.createIfNotExists,
+ UserTable.schema.createIfNotExists,
+ GroupMemberTable.schema.createIfNotExists,
+ PaidByTable.schema.createIfNotExists,
+ SplitBetweenTable.schema.createIfNotExists,
+ ExpenseTable.schema.createIfNotExists,
+ GroupTable.schema.createIfNotExists
+ )
+ )
+ }
+ .mapError(_.toDomainError())
+ .run
+
+ ()
+ }
+}
+
+class CurrencyEntityTable(tag: Tag) extends Table[CurrencyEntity](tag, None, "CurrencyEntity") {
+ val isoCode = column[String]("iso_code", O.PrimaryKey)
+ val name = column[String]("name")
+ val symbol = column[String]("symbol")
+
+ override def * = (isoCode, name, symbol).mapTo[CurrencyEntity]
+}
+
+class UserEntityTable(tag: Tag) extends Table[UserEntity](tag, None, "UserEntity") {
+ val uid = column[UserUid]("uid", O.PrimaryKey)
+ val name = column[String]("name")
+
+ override def * = (uid, name).mapTo[UserEntity]
+}
+
+class GroupMemberEntityTable(tag: Tag) extends Table[GroupMemberEntity](tag, None, "GroupMemberEntity") {
+ val uid = column[MemberUid]("uid", O.PrimaryKey)
+ val groupUid = column[GroupUid]("group_uid")
+ val userUid = column[UserUid]("user_uid")
+
+ override def * = (uid, groupUid, userUid).mapTo[GroupMemberEntity]
+}
+
+class PaidByEntityTable(tag: Tag) extends Table[PaidByEntity](tag, None, "PaidByEntity") {
+ val groupUid = column[GroupUid]("group_uid")
+ val expenseUid = column[ExpenseUid]("expense_uid")
+ val memberUid = column[MemberUid]("member_uid")
+
+ override def * = (groupUid, expenseUid, memberUid).mapTo[PaidByEntity]
+}
+
+class SplitBetweenEntityTable(tag: Tag) extends Table[SplitBetweenEntity](tag, None, "SplitBetweenEntity") {
+ val groupUid = column[GroupUid]("group_uid")
+ val expenseUid = column[ExpenseUid]("expense_uid")
+ val memberUid = column[MemberUid]("member_uid")
+
+ override def * = (groupUid, expenseUid, memberUid).mapTo[SplitBetweenEntity]
+}
+
+class ExpenseEntityTable(tag: Tag) extends Table[ExpenseEntity](tag, None, "ExpenseEntity") {
+ val uid = column[ExpenseUid]("uid", O.PrimaryKey)
+ val groupUid = column[GroupUid]("group_uid")
+ val title = column[String]("title")
+ val description = column[String]("description")
+ val amount = column[Double]("amount")
+ val isSplitBetweenAll = column[Boolean]("is_split_between_all")
+ val created = column[Timestamp]("created")
+ val modified = column[Timestamp]("modified")
+
+ override def * =
+ (uid, groupUid, title, description, amount, isSplitBetweenAll, created, modified).mapTo[ExpenseEntity]
+}
+
+class GroupEntityTable(tag: Tag) extends Table[GroupEntity](tag, None, "GroupEntity") {
+ val uid = column[GroupUid]("uid", O.PrimaryKey)
+ val title = column[String]("title")
+ val description = column[String]("description")
+ val passwordHash = column[String]("password_hash")
+ val currencyIsoCode = column[String]("currency_iso_code")
+ val created = column[Timestamp]("created")
+ val modified = column[Timestamp]("modified")
+
+ override def * =
+ (uid, title, description, passwordHash, currencyIsoCode, created, modified).mapTo[GroupEntity]
+}
+
+given userUidColumnType: BaseColumnType[UserUid] = MappedColumnType.base[UserUid, UUID](
+ uid => uid.value, // UserUid to UUID
+ uuid => UserUid(uuid) // UUID to UserUid
+)
+
+given groupUidColumnType: BaseColumnType[GroupUid] = MappedColumnType.base[GroupUid, UUID](
+ uid => uid.value, // GroupUid to UUID
+ uuid => GroupUid(uuid) // UUID to GroupUid
+)
+
+given memberUidColumnType: BaseColumnType[MemberUid] = MappedColumnType.base[MemberUid, UUID](
+ uid => uid.value, // MemberUid to UUID
+ uuid => MemberUid(uuid) // UUID to MemberUid
+)
+
+given expenseUidColumnType: BaseColumnType[ExpenseUid] = MappedColumnType.base[ExpenseUid, UUID](
+ uid => uid.value, // ExpenseUid to UUID
+ uuid => ExpenseUid(uuid) // UUID to ExpenseUid
+)
+
+given timestampColumnType: BaseColumnType[Timestamp] = MappedColumnType.base[Timestamp, Long](
+ timestamp => timestamp.seconds, // Timestamp to Long
+ seconds => Timestamp(seconds) // Long to Timestamp
+)
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala
new file mode 100644
index 0000000..9d34678
--- /dev/null
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/DatabaseConnectionFactory.scala
@@ -0,0 +1,111 @@
+package com.github.ai.split.data.db
+
+import com.github.ai.split.data.db.DatabaseConnectionFactory.{
+ POSTGRES_DB,
+ POSTGRES_HOST,
+ POSTGRES_PASSWORD,
+ POSTGRES_USER
+}
+import com.github.ai.split.data.db.model.DatabaseConnection
+import com.github.ai.split.entity.exception.DomainError
+import com.github.ai.split.utils.some
+import zio.direct.{defer, run}
+import zio.{IO, ZIO}
+
+import java.io.File
+import scala.io.Source
+
+class DatabaseConnectionFactory {
+
+ def create(): IO[DomainError, DatabaseConnection] = {
+ defer {
+ val host = sys.env.getOrElse(POSTGRES_HOST, "")
+ val db = sys.env.getOrElse(POSTGRES_DB, "")
+ val user = sys.env.getOrElse(POSTGRES_USER, "")
+ val password = sys.env.getOrElse(POSTGRES_PASSWORD, "")
+
+ val envFileContent = readEnvironmentFile()
+ .flatMap { content => parseEnvironmentFile(content) }
+
+ if (host.nonEmpty && db.nonEmpty && user.nonEmpty) {
+ newConnectionFrom(
+ host = host,
+ database = db,
+ user = user,
+ password = password
+ )
+ } else if (envFileContent.isDefined) {
+ envFileContent.get
+ } else {
+ ZIO.fail(DomainError(message = "Database connection is not specified".some)).run
+ }
+ }
+ }
+
+ private def readEnvironmentFile(): Option[String] = {
+ val envFile = File(".env")
+ if (!envFile.exists()) {
+ return None
+ }
+
+ val source = Source.fromFile(envFile)
+ try {
+ Some(source.mkString)
+ } finally {
+ source.close()
+ }
+ }
+
+ private def parseEnvironmentFile(
+ content: String
+ ): Option[DatabaseConnection] = {
+ val keyToValueMap = content
+ .split("\n")
+ .map(line => line.trim)
+ .filter(line => line.nonEmpty)
+ .flatMap { line =>
+ val values = line.split("=").toList
+ if (values.size == 2) {
+ Some((values.head, values(1)))
+ } else {
+ None
+ }
+ }
+ .toMap
+
+ val host = keyToValueMap.getOrElse(POSTGRES_HOST, "")
+ val db = keyToValueMap.getOrElse(POSTGRES_DB, "")
+ val user = keyToValueMap.getOrElse(POSTGRES_USER, "")
+ val password = keyToValueMap.getOrElse(POSTGRES_PASSWORD, "")
+ if (host.nonEmpty && db.nonEmpty && user.nonEmpty) {
+ Some(
+ newConnectionFrom(
+ host = host,
+ database = db,
+ user = user,
+ password = password
+ )
+ )
+ } else {
+ None
+ }
+ }
+
+ private def newConnectionFrom(
+ host: String,
+ database: String,
+ user: String,
+ password: String
+ ) = DatabaseConnection(
+ url = s"jdbc:postgresql://$host:5432/$database",
+ user = user,
+ password = password
+ )
+}
+
+object DatabaseConnectionFactory {
+ val POSTGRES_HOST = "POSTGRES_HOST"
+ val POSTGRES_DB = "POSTGRES_DB"
+ val POSTGRES_USER = "POSTGRES_USER"
+ val POSTGRES_PASSWORD = "POSTGRES_PASSWORD"
+}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala
index 244b5c0..20e47c0 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/CurrencyEntityDao.scala
@@ -1,111 +1,58 @@
package com.github.ai.split.data.db.dao
+import com.github.ai.split.data.db.AppDatabase
import com.github.ai.split.entity.db.CurrencyEntity
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
import com.github.ai.split.utils.some
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
+import zio.{IO, ZIO}
+import slick.jdbc.PostgresProfile.api.*
class CurrencyEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.CurrencyTable) {
- import quill._
+ private val table = db.CurrencyTable
def getAll(): IO[DomainError, List[CurrencyEntity]] = {
- val query = quote {
- querySchema[CurrencyEntity]("currencies")
- }
-
- run(query)
- .mapError(_.toDomainError())
+ queryAll()
}
def findByIsoCode(isoCode: String): IO[DomainError, Option[CurrencyEntity]] = {
- val query = quote {
- querySchema[CurrencyEntity]("currencies")
- .filter(_.isoCode == lift(isoCode))
- }
-
- for {
- currencies <- run(query).mapError(_.toDomainError())
- } yield currencies.headOption
+ queryOne(t => t.isoCode === isoCode)
}
def getByIsoCode(isoCode: String): IO[DomainError, CurrencyEntity] = {
- val query = quote {
- querySchema[CurrencyEntity]("currencies")
- .filter(_.isoCode == lift(isoCode))
- }
-
- for {
- currencies <- run(query).mapError(_.toDomainError())
- currency <-
- if (currencies.nonEmpty) {
- ZIO.succeed(currencies.head)
- } else {
- ZIO.fail(DomainError(message = s"Failed to find currency by ISO code: $isoCode".some))
- }
- } yield currency
+ queryOne(t => t.isoCode === isoCode)
+ .flatMap { option =>
+ ZIO
+ .fromOption(option)
+ .mapError(_ => DomainError(message = s"Failed to find currency by ISO code: $isoCode".some))
+ }
}
def getByIsoCodes(isoCodes: List[String]): IO[DomainError, List[CurrencyEntity]] = {
val isoCodeSet = isoCodes.toSet
- val query = quote {
- querySchema[CurrencyEntity]("currencies")
- .filter(currency => liftQuery(isoCodeSet).contains(currency.isoCode))
- }
-
- for {
- currencies <- run(query).mapError(_.toDomainError())
- _ <-
- if (currencies.size != isoCodeSet.size) {
+ query(t => t.isoCode inSet isoCodeSet)
+ .flatMap { currencies =>
+ if (currencies.size == isoCodeSet.size) {
+ ZIO.succeed(currencies.toList)
+ } else {
val foundIsoCodes = currencies.map(_.isoCode).toSet
val notFoundIsoCodes = isoCodeSet.diff(foundIsoCodes).mkString(", ")
ZIO.fail(DomainError(message = s"Failed to find currencies: $notFoundIsoCodes".some))
- } else {
- ZIO.succeed(())
}
- } yield currencies
- }
-
- def add(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = {
- run(
- quote {
- querySchema[CurrencyEntity]("currencies")
- .insertValue(lift(currency))
}
- )
- .map(_ => currency)
- .mapError(_.toDomainError())
}
- def addBatch(currencies: List[CurrencyEntity]): IO[DomainError, List[CurrencyEntity]] = {
- run(
- quote {
- liftQuery(currencies).foreach(currency =>
- querySchema[CurrencyEntity]("currencies")
- .insertValue(currency)
- )
- }
- )
- .map(_ => currencies)
- .mapError(_.toDomainError())
+ def add(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = {
+ insert(currency)
}
def update(currency: CurrencyEntity): IO[DomainError, CurrencyEntity] = {
- val updateQuery = quote {
- querySchema[CurrencyEntity]("currencies")
- .filter(_.isoCode == lift(currency.isoCode))
- .updateValue(lift(currency))
- }
-
- run(updateQuery)
- .map(_ => currency)
- .mapError(_.toDomainError())
+ updateOne(
+ predicate = { entity => entity.isoCode === currency.isoCode },
+ entity = currency
+ )
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala
new file mode 100644
index 0000000..f8624cc
--- /dev/null
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/Dao.scala
@@ -0,0 +1,100 @@
+package com.github.ai.split.data.db.dao
+
+import com.github.ai.split.entity.exception.DomainError
+import com.github.ai.split.utils.{toDomainError, some}
+import zio.{IO, ZIO}
+import slick.jdbc.PostgresProfile.api.*
+
+abstract class Dao[E, TableType <: Table[E]](
+ protected val db: Database,
+ protected val table: TableQuery[TableType]
+) {
+
+ protected def query(
+ predicate: TableType => Rep[Boolean]
+ ): IO[DomainError, List[E]] = {
+ ZIO
+ .fromFuture { _ =>
+ db.run[Seq[E]](table.filter(predicate).result)
+ }
+ .map(_.toList)
+ .mapError(_.toDomainError())
+ }
+
+ protected def queryAll(): IO[DomainError, List[E]] = {
+ query(_ => true)
+ }
+
+ protected def queryOne(
+ predicate: TableType => Rep[Boolean]
+ ): IO[DomainError, Option[E]] = {
+ query(predicate)
+ .map(_.headOption)
+ }
+
+ protected def insertAll(entities: List[E]): IO[DomainError, List[E]] = {
+ ZIO.collectAll(
+ entities.map(entity => insert(entity))
+ )
+ }
+
+ protected def insert(entity: E): IO[DomainError, E] = {
+ ZIO
+ .fromFuture { _ => db.run(table += entity) }
+ .flatMap { count =>
+ if (count != 0) {
+ ZIO.succeed(entity)
+ } else {
+ ZIO.fail(DomainError(message = s"Failed to insert entity: $entity".some))
+ }
+ }
+ .mapError(_.toDomainError())
+ }
+
+ protected def updateOne(
+ predicate: TableType => Rep[Boolean],
+ entity: E
+ ): IO[DomainError, E] = {
+ ZIO
+ .fromFuture { _ =>
+ db.run(table.filter(predicate).update(entity))
+ }
+ .flatMap { count =>
+ if (count != 0) {
+ ZIO.succeed(entity)
+ } else {
+ ZIO.fail(DomainError(message = s"Unable to update entity: $entity".some))
+ }
+ }
+ .mapError(_.toDomainError())
+ }
+
+ protected def delete(
+ predicate: TableType => Rep[Boolean]
+ ): IO[DomainError, Unit] = {
+ ZIO
+ .fromFuture { _ =>
+ db.run(table.filter(predicate).delete)
+ }
+ .map(_ => ())
+ .mapError(_.toDomainError())
+ }
+
+ protected def deleteOne(
+ predicate: TableType => Rep[Boolean]
+ ): IO[DomainError, Unit] = {
+ ZIO
+ .fromFuture { _ =>
+ db.run(table.filter(predicate).delete)
+ }
+ .flatMap { count =>
+ if (count != 0) {
+ ZIO.succeed(())
+ } else {
+ ZIO.fail(DomainError(message = s"Unable to delete entity".some))
+ }
+ }
+ .mapError(_.toDomainError())
+ }
+
+}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala
index e5c027c..65328be 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/ExpenseEntityDao.scala
@@ -1,101 +1,49 @@
package com.github.ai.split.data.db.dao
-import com.github.ai.split.entity.db.{ExpenseEntity, ExpenseUid, GroupUid, UserEntity}
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
+import com.github.ai.split.entity.db.{ExpenseEntity, ExpenseUid, GroupUid}
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
import com.github.ai.split.utils.some
-import io.getquill.SnakeCase
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
+import slick.jdbc.PostgresProfile.api.*
+import zio.{IO, ZIO}
class ExpenseEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
-
- import quill._
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.ExpenseTable) {
def getByUid(uid: ExpenseUid): IO[DomainError, ExpenseEntity] = {
- val query = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(_.uid == lift(uid))
- }
-
- for {
- expenses <- run(query).mapError(_.toDomainError())
- expense <- ZIO
- .fromOption(expenses.find(_.uid == uid))
- .mapError(_ => DomainError(message = s"Failed to find expense by uid: $uid".some))
- } yield expense
+ queryOne(table => table.uid === uid)
+ .flatMap { expenseOption =>
+ ZIO
+ .fromOption(expenseOption)
+ .mapError(_ => DomainError(message = s"Failed to find expense by uid: $uid".some))
+ }
}
def getByUids(uids: List[ExpenseUid]): IO[DomainError, List[ExpenseEntity]] = {
val uidSet = uids.toSet
-
- val query = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(expense => liftQuery(uidSet).contains(expense.uid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.uid inSet uidSet)
}
def getByGroupUids(groupUids: List[GroupUid]): IO[DomainError, List[ExpenseEntity]] = {
- val groupUidSet = groupUids.toSet
-
- val query = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(expense => liftQuery(groupUidSet).contains(expense.groupUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ val uidSet = groupUids.toSet
+ query(table => table.groupUid inSet uidSet)
}
def getByGroupUid(groupUid: GroupUid): IO[DomainError, List[ExpenseEntity]] = {
- val query = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(_.groupUid == lift(groupUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.groupUid === groupUid)
}
def add(expense: ExpenseEntity): IO[DomainError, ExpenseEntity] = {
- run(
- quote {
- querySchema[ExpenseEntity]("expenses")
- .insertValue(lift(expense))
- }
- )
- .map(_ => expense)
- .mapError(_.toDomainError())
+ insert(expense)
}
def update(expense: ExpenseEntity): IO[DomainError, ExpenseEntity] = {
- val updateQuery = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(_.uid == lift(expense.uid))
- .updateValue(lift(expense))
- }
-
- run(updateQuery)
- .map(_ => expense)
- .mapError(_.toDomainError())
+ updateOne(table => table.uid === expense.uid, entity = expense)
}
def delete(uid: ExpenseUid): IO[DomainError, Unit] = {
- val deleteQuery = quote {
- querySchema[ExpenseEntity]("expenses")
- .filter(_.uid == lift(uid))
- .delete
- }
-
- run(deleteQuery)
- .map(_ => ())
- .mapError(_.toDomainError())
+ deleteOne(table => table.uid === uid)
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala
index 6cdd324..6916980 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupEntityDao.scala
@@ -1,78 +1,40 @@
package com.github.ai.split.data.db.dao
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
import com.github.ai.split.entity.db.{GroupEntity, GroupUid}
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
import com.github.ai.split.utils.some
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
-
-import java.sql.SQLException
+import slick.jdbc.PostgresProfile.api.*
+import zio.{IO, ZIO}
class GroupEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
-
- import quill._
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.GroupTable) {
def getByUids(uids: List[GroupUid]): IO[DomainError, List[GroupEntity]] = {
val uidSet = uids.toSet
-
- val query = quote {
- querySchema[GroupEntity]("groups")
- .filter(gr => liftQuery(uidSet).contains(gr.uid))
- }
-
- run(query).mapError(_.toDomainError())
+ query(table => table.uid inSet uidSet)
}
def getByUid(uid: GroupUid): IO[DomainError, GroupEntity] = {
- val query = quote {
- querySchema[GroupEntity]("groups")
- .filter(_.uid == lift(uid))
- }
-
- for {
- groups <- run(query).mapError(_.toDomainError())
- group <- ZIO
- .fromOption(groups.find(_.uid == uid))
- .mapError(_ => DomainError(message = s"Failed to find group by uid: $uid".some))
- } yield group
+ queryOne(table => table.uid === uid)
+ .flatMap { groupOption =>
+ ZIO
+ .fromOption(groupOption)
+ .mapError(_ => DomainError(message = s"Failed to find group by uid: $uid".some))
+ }
}
def findByUid(uid: GroupUid): IO[DomainError, Option[GroupEntity]] = {
- val query = quote {
- querySchema[GroupEntity]("groups")
- .filter(_.uid == lift(uid))
- }
-
- for {
- groups <- run(query).mapError(_.toDomainError())
- } yield groups.headOption
+ queryOne(table => table.uid === uid)
}
def add(group: GroupEntity): IO[DomainError, GroupEntity] = {
- run(
- quote {
- querySchema[GroupEntity]("groups")
- .insertValue(lift(group))
- }
- )
- .map(_ => group)
- .mapError(_.toDomainError())
+ insert(group)
}
def update(group: GroupEntity): IO[DomainError, GroupEntity] = {
- val updateQuery = quote {
- querySchema[GroupEntity]("groups")
- .filter(_.uid == lift(group.uid))
- .updateValue(lift(group))
- }
-
- run(updateQuery)
- .map(_ => group)
- .mapError(_.toDomainError())
+ updateOne(table => table.uid === group.uid, entity = group)
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala
index 24bf494..b5ca194 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/GroupMemberEntityDao.scala
@@ -1,118 +1,59 @@
package com.github.ai.split.data.db.dao
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
import com.github.ai.split.entity.db.{GroupMemberEntity, GroupUid, MemberUid, UserUid}
-import com.github.ai.split.entity.db.UserUid._
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.{some, toDomainError}
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
-
-import java.sql.SQLException
+import com.github.ai.split.utils.some
+import zio.{IO, ZIO}
+import slick.jdbc.PostgresProfile.api.*
class GroupMemberEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
-
- import quill._
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.GroupMemberTable) {
// TODO: refactor
def getAll(): IO[DomainError, List[GroupMemberEntity]] = {
- val query = quote {
- querySchema[GroupMemberEntity]("group_members")
- }
-
- run(query)
- .mapError(_.toDomainError())
+ queryAll()
}
def getByGroupUid(groupUid: GroupUid): IO[DomainError, List[GroupMemberEntity]] = {
- val query = quote {
- querySchema[GroupMemberEntity]("group_members")
- .filter(_.groupUid == lift(groupUid))
- }
-
- run(query).mapError(_.toDomainError())
+ query(table => table.groupUid === groupUid)
}
def getByUid(uid: MemberUid): IO[DomainError, GroupMemberEntity] = {
- val query = quote {
- querySchema[GroupMemberEntity]("group_members")
- .filter(_.uid == lift(uid))
- }
-
- for {
- members <- run(query).mapError(_.toDomainError())
- member <- ZIO.fromOption(members.headOption).mapError { _ =>
- DomainError(message = s"Failed to find member by uid: $uid".some)
+ queryOne(table => table.uid === uid)
+ .flatMap { option =>
+ ZIO
+ .fromOption(option)
+ .mapError(_ => DomainError(message = s"Failed to find member by uid: $uid".some))
}
- } yield member
}
def getByUserUid(userUid: UserUid): IO[DomainError, GroupMemberEntity] = {
- val query = quote {
- querySchema[GroupMemberEntity]("group_members")
- .filter(_.userUid == lift(userUid))
- }
-
- for {
- members <- run(query).mapError(_.toDomainError())
- member <- ZIO.fromOption(members.headOption).mapError { _ =>
- DomainError(message = s"Failed to find member by user uid: $userUid".some)
+ queryOne(table => table.userUid === userUid)
+ .flatMap { option =>
+ ZIO
+ .fromOption(option)
+ .mapError(_ => DomainError(message = s"Failed to find member by user uid: $userUid".some))
}
- } yield member
}
def add(member: GroupMemberEntity): IO[DomainError, GroupMemberEntity] = {
- run(
- quote {
- querySchema[GroupMemberEntity]("group_members")
- .insertValue(lift(member))
- }
- )
- .map(_ => member)
- .mapError(_.toDomainError())
+ insert(member)
}
def add(members: List[GroupMemberEntity]): IO[DomainError, List[GroupMemberEntity]] = {
- val insertQuery = quote {
- liftQuery(members).foreach { member =>
- querySchema[GroupMemberEntity]("group_members")
- .insertValue(member)
- }
- }
-
- val result: IO[SQLException, List[Long]] = run(insertQuery)
-
- result
- .map(_ => members)
- .mapError(_.toDomainError())
+ insertAll(members)
}
def removeByGroupUid(groupUid: GroupUid): IO[DomainError, Unit] = {
- val deleteQuery = quote {
- querySchema[GroupMemberEntity]("group_members")
- .filter(_.groupUid == lift(groupUid))
- .delete
- }
-
- run(deleteQuery)
- .map(_ => ())
- .mapError(_.toDomainError())
+ delete(table => table.groupUid === groupUid)
}
def removeByUid(
uid: MemberUid
): IO[DomainError, Unit] = {
- val deleteQuery = quote {
- querySchema[GroupMemberEntity]("group_members")
- .filter(member => member.uid == lift(uid))
- .delete
- }
-
- run(deleteQuery)
- .map(_ => ())
- .mapError(_.toDomainError())
+ deleteOne(table => table.uid === uid)
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala
index b1a6a0f..8407b10 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/PaidByEntityDao.scala
@@ -1,75 +1,33 @@
package com.github.ai.split.data.db.dao
-import com.github.ai.split.entity.db.{ExpenseUid, PaidByEntity, GroupUid}
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
+import com.github.ai.split.entity.db.{ExpenseUid, GroupUid, PaidByEntity}
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
-import io.getquill.{SnakeCase, querySchema}
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
-
-import java.sql.SQLException
+import slick.jdbc.PostgresProfile.api.*
+import zio.{IO, ZIO}
class PaidByEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
-
- import quill._
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.PaidByTable) {
def getAll(): IO[DomainError, List[PaidByEntity]] = {
- val query = quote {
- querySchema[PaidByEntity]("paid_by")
- }
-
- run(query)
- .mapError(_.toDomainError())
+ queryAll()
}
def getByExpenseUid(expenseUid: ExpenseUid): IO[DomainError, List[PaidByEntity]] = {
- val query = quote {
- querySchema[PaidByEntity]("paid_by")
- .filter(_.expenseUid == lift(expenseUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.expenseUid === expenseUid)
}
def getByGroupUid(groupUid: GroupUid): IO[DomainError, List[PaidByEntity]] = {
- val query = quote {
- querySchema[PaidByEntity]("paid_by")
- .filter(_.groupUid == lift(groupUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.groupUid === groupUid)
}
def add(payers: List[PaidByEntity]): IO[DomainError, List[PaidByEntity]] = {
- val insertQuery = quote {
- liftQuery(payers).foreach { payer =>
- querySchema[PaidByEntity]("paid_by")
- .insertValue(payer)
- }
- }
-
- val result: IO[SQLException, List[Long]] = run(insertQuery)
-
- result
- .map(_ => payers)
- .mapError(_.toDomainError())
+ insertAll(payers)
}
def removeByExpenseUid(expenseUid: ExpenseUid): IO[DomainError, Unit] = {
- val deleteQuery = quote {
- querySchema[PaidByEntity]("paid_by")
- .filter(_.expenseUid == lift(expenseUid))
- .delete
- }
-
- run(deleteQuery)
- .map(_ => ())
- .mapError(_.toDomainError())
+ delete(table => table.expenseUid === expenseUid)
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala
index 6cd0844..71b85e7 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/SplitBetweenEntityDao.scala
@@ -1,75 +1,33 @@
package com.github.ai.split.data.db.dao
-import com.github.ai.split.entity.db.{PaidByEntity, SplitBetweenEntity, ExpenseUid, GroupUid}
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
+import com.github.ai.split.entity.db.{ExpenseUid, GroupUid, SplitBetweenEntity}
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
-import io.getquill.{SnakeCase, querySchema}
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
+import slick.jdbc.PostgresProfile.api.*
import zio.IO
-import java.sql.SQLException
-
class SplitBetweenEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
-
- import quill._
+ db: AppDatabase
+) extends Dao(db = db.context, table = db.SplitBetweenTable) {
def getAll(): IO[DomainError, List[SplitBetweenEntity]] = {
- val query = quote {
- querySchema[SplitBetweenEntity]("split_between")
- }
-
- run(query)
- .mapError(_.toDomainError())
+ queryAll()
}
def getByExpenseUid(expenseUid: ExpenseUid): IO[DomainError, List[SplitBetweenEntity]] = {
- val query = quote {
- querySchema[SplitBetweenEntity]("split_between")
- .filter(_.expenseUid == lift(expenseUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.expenseUid === expenseUid)
}
def getByGroupUid(groupUid: GroupUid): IO[DomainError, List[SplitBetweenEntity]] = {
- val query = quote {
- querySchema[SplitBetweenEntity]("split_between")
- .filter(_.groupUid == lift(groupUid))
- }
-
- run(query)
- .mapError(_.toDomainError())
+ query(table => table.groupUid === groupUid)
}
def add(splits: List[SplitBetweenEntity]): IO[DomainError, List[SplitBetweenEntity]] = {
- val insertQuery = quote {
- liftQuery(splits).foreach { split =>
- querySchema[SplitBetweenEntity]("split_between")
- .insertValue(split)
- }
- }
-
- val result: IO[SQLException, List[Long]] = run(insertQuery)
-
- result
- .map(_ => splits)
- .mapError(_.toDomainError())
+ insertAll(splits)
}
def removeByExpenseUid(expenseUid: ExpenseUid): IO[DomainError, Unit] = {
- val deleteQuery = quote {
- querySchema[SplitBetweenEntity]("split_between")
- .filter(_.expenseUid == lift(expenseUid))
- .delete
- }
-
- run(deleteQuery)
- .map(_ => ())
- .mapError(_.toDomainError())
+ delete(table => table.expenseUid === expenseUid)
}
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala
index dc45c2f..b0811db 100644
--- a/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/dao/UserEntityDao.scala
@@ -1,117 +1,71 @@
package com.github.ai.split.data.db.dao
-import com.github.ai.split.entity.db.{GroupMemberEntity, GroupUid, UserEntity, UserUid}
+import com.github.ai.split.data.db.AppDatabase
+import com.github.ai.split.data.db.{given}
+import com.github.ai.split.entity.db.{GroupUid, UserEntity, UserUid}
import com.github.ai.split.entity.exception.DomainError
-import com.github.ai.split.utils.toDomainError
-import com.github.ai.split.utils.some
-import io.getquill.jdbczio.Quill
-import io.getquill.generic.*
-import io.getquill.*
-import zio.*
+import com.github.ai.split.utils.{some}
+import zio.{IO, ZIO}
+import zio.direct.*
+import slick.jdbc.PostgresProfile.api.*
class UserEntityDao(
- quill: Quill.H2[SnakeCase]
-) {
+ db: AppDatabase,
+ private val groupMemberDao: GroupMemberEntityDao
+) extends Dao(db = db.context, table = db.UserTable) {
- import quill._
+ private val table = db.UserTable
// TODO: refactor
def getAll(): IO[DomainError, List[UserEntity]] = {
- val query = quote {
- querySchema[UserEntity]("users")
- }
-
- run(query)
- .mapError(_.toDomainError())
+ queryAll()
}
def getByGroupUid(groupUid: GroupUid): IO[DomainError, List[UserEntity]] = {
- val query = quote {
- for {
- member <- querySchema[GroupMemberEntity]("group_members")
- .filter(_.groupUid == lift(groupUid))
- u <- querySchema[UserEntity]("users") if member.userUid == u.uid
- } yield (member, u)
+ defer {
+ val users = groupMemberDao.getByGroupUid(groupUid).run
+ val userUids = users.map(_.userUid).toSet
+ query(table => table.uid inSet userUids).run
}
-
- for {
- members <- run(query).mapError(_.toDomainError())
- } yield members.map((member, user) => user)
}
def findByUid(uid: UserUid): IO[DomainError, Option[UserEntity]] = {
- val query = quote {
- querySchema[UserEntity]("users")
- .filter(_.uid == lift(uid))
- }
-
- for {
- users <- run(query).mapError(_.toDomainError())
- } yield users.headOption
+ queryOne(table => table.uid === uid)
}
def getByUids(uids: List[UserUid]): IO[DomainError, List[UserEntity]] = {
val uidSet = uids.toSet
- val query = quote {
- querySchema[UserEntity]("users")
- .filter(usr => liftQuery(uidSet).contains(usr.uid))
- }
-
- for {
- users <- run(query).mapError(_.toDomainError())
- _ <-
- if (users.size != uids.size) {
- val notFoundUids = users
- .map(_.uid)
- .filter(uid => !uids.contains(uid))
- .mkString(", ")
-
- ZIO.fail(DomainError(message = s"Failed to find users: $notFoundUids".some))
+ query(t => t.uid inSet uidSet)
+ .flatMap { users =>
+ if (users.size == uidSet.size) {
+ ZIO.succeed(users)
} else {
- ZIO.succeed(())
+ val foundUids = users.map(_.uid).toSet
+ val notFoundUids = uidSet.diff(foundUids).mkString(", ")
+ ZIO.fail(DomainError(message = s"Failed to find users: $notFoundUids".some))
}
- } yield users
+ }
}
def getByUid(uid: UserUid): IO[DomainError, UserEntity] = {
- val query = quote {
- querySchema[UserEntity]("users")
- .filter(_.uid == lift(uid))
- }
-
- for {
- users <- run(query).mapError(_.toDomainError())
- user <-
- if (users.nonEmpty) {
- ZIO.succeed(users.head)
- } else {
- ZIO.fail(DomainError(message = s"Failed to find user by uid: $uid".some))
- }
- } yield user
+ queryOne(table => table.uid === uid)
+ .flatMap { option =>
+ ZIO
+ .fromOption(option)
+ .mapError(_ => DomainError(message = s"Failed to find user by uid: $uid".some))
+ }
}
def add(user: UserEntity): IO[DomainError, UserEntity] = {
- run(
- quote {
- querySchema[UserEntity]("users")
- .insertValue(lift(user))
- }
- )
- .map(_ => user)
- .mapError(_.toDomainError())
+ insert(user)
}
def update(user: UserEntity): IO[DomainError, UserEntity] = {
- val updateQuery = quote {
- querySchema[UserEntity]("users")
- .filter(_.uid == lift(user.uid))
- .updateValue(lift(user))
- }
-
- run(updateQuery)
- .map(_ => user)
- .mapError(_.toDomainError())
+ updateOne(
+ predicate = { entity => entity.uid === user.uid },
+ entity = user
+ )
}
// TODO: remove function and refactor
@@ -123,7 +77,3 @@ class UserEntityDao(
}
}
}
-
-object UserEntityDao {
- val TableName = "users"
-}
diff --git a/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala b/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala
new file mode 100644
index 0000000..fe873a1
--- /dev/null
+++ b/backend/app/src/main/scala/com/github/ai/split/data/db/model/DatabaseConnection.scala
@@ -0,0 +1,7 @@
+package com.github.ai.split.data.db.model
+
+case class DatabaseConnection(
+ url: String,
+ user: String,
+ password: String
+)
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/AccessResolverService.scala b/backend/app/src/main/scala/com/github/ai/split/domain/AccessResolverService.scala
index f3b0d12..e0578a8 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/AccessResolverService.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/AccessResolverService.scala
@@ -35,7 +35,7 @@ class AccessResolverService(
case Some(group) =>
AccessResolutionResult(
uid = groupUid,
- access = if (passwordService.isPasswordMatch(password, group.passwordHash.getOrElse(""))) {
+ access = if (passwordService.isPasswordMatch(password, group.passwordHash)) {
GRANTED
} else {
DENIED
@@ -93,14 +93,14 @@ class AccessResolverService(
private def isPasswordMatch(
password: String,
- passwordHash: Option[String]
+ passwordHash: String
): IO[DomainError, Unit] = {
if (password.isEmpty && passwordHash.isEmpty) {
ZIO.unit
} else {
val isMatch = passwordService.isPasswordMatch(
password = password,
- hashedPassword = passwordHash.getOrElse("")
+ hashedPassword = passwordHash
)
if (isMatch) {
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala
index 582f3cd..fa886d5 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddExpenseUseCase.scala
@@ -20,6 +20,7 @@ import com.github.ai.split.entity.db.{
MemberUid,
PaidByEntity,
SplitBetweenEntity,
+ Timestamp,
UserEntity
}
import com.github.ai.split.utils.*
@@ -93,7 +94,7 @@ class AddExpenseUseCase(
)
}
- val time = LocalDateTime.now(ZoneOffset.UTC)
+ val time = Timestamp.now()
val expense = expenseRepository
.add(
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala
index 674c0d4..4027918 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/AddGroupUseCase.scala
@@ -1,6 +1,6 @@
package com.github.ai.split.domain.usecases
-import com.github.ai.split.entity.db.{GroupEntity, GroupUid}
+import com.github.ai.split.entity.db.{GroupEntity, GroupUid, Timestamp}
import com.github.ai.split.data.db.dao.{GroupEntityDao, GroupMemberEntityDao}
import com.github.ai.split.domain.PasswordService
import com.github.ai.split.entity.NewGroup
@@ -30,7 +30,7 @@ class AddGroupUseCase(
validateData(newGroup).run
val groupUid = GroupUid(UUID.randomUUID())
- val created = LocalDateTime.now(ZoneOffset.UTC)
+ val created = Timestamp.now()
val group = groupDao
.add(
@@ -39,9 +39,9 @@ class AddGroupUseCase(
title = newGroup.title,
description = newGroup.description,
passwordHash = if (newGroup.password.nonEmpty) {
- passwordService.hashPassword(newGroup.password).some
+ passwordService.hashPassword(newGroup.password)
} else {
- None
+ ""
},
currencyIsoCode = newGroup.currencyIsoCode,
created = created,
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala
index 3c07339..9284c65 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/FillTestDataUseCase.scala
@@ -20,6 +20,7 @@ import com.github.ai.split.entity.db.{
MemberUid,
PaidByEntity,
SplitBetweenEntity,
+ Timestamp,
UserEntity,
UserUid
}
@@ -99,14 +100,14 @@ class FillTestDataUseCase(
}
private def insertGroup(group: Group): IO[DomainError, Unit] = {
- val time = LocalDateTime.now(ZoneOffset.UTC)
+ val time = Timestamp.now()
for {
_ <- groupDao.add(
GroupEntity(
uid = group.uid,
title = group.title,
description = group.description,
- passwordHash = Some(passwordService.hashPassword(group.password)),
+ passwordHash = passwordService.hashPassword(group.password),
currencyIsoCode = "EUR",
created = time,
modified = time
@@ -140,7 +141,7 @@ class FillTestDataUseCase(
groupUid: GroupUid,
expense: Expense
): IO[DomainError, Unit] = {
- val time = LocalDateTime.now(ZoneOffset.UTC)
+ val time = Timestamp.now()
for {
members <- groupRepository.getMembers(groupUid)
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala
index 230abe4..b8f70f1 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/StartUpServerUseCase.scala
@@ -1,11 +1,13 @@
package com.github.ai.split.domain.usecases
+import com.github.ai.split.data.db.AppDatabase
import com.github.ai.split.entity.CliArguments
import com.github.ai.split.entity.exception.DomainError
import zio.*
import zio.direct.*
class StartUpServerUseCase(
+ private val db: AppDatabase,
private val fillTestDataUseCase: FillTestDataUseCase,
private val fillCurrencyDataUseCase: FillCurrencyDataUseCase,
private val cliArguments: CliArguments
@@ -13,6 +15,8 @@ class StartUpServerUseCase(
def startUpServer(): IO[DomainError, Unit] = {
defer {
+ db.initialize().run
+
if (cliArguments.isPopulateTestData) {
fillTestDataUseCase.createTestData().run
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala
index 7488bd6..374f361 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateExpenseUseCase.scala
@@ -10,7 +10,15 @@ import com.github.ai.split.entity.{
SplitBetweenMembers,
UserReference
}
-import com.github.ai.split.entity.db.{ExpenseEntity, ExpenseUid, GroupUid, MemberUid, PaidByEntity, SplitBetweenEntity}
+import com.github.ai.split.entity.db.{
+ ExpenseEntity,
+ ExpenseUid,
+ GroupUid,
+ MemberUid,
+ PaidByEntity,
+ SplitBetweenEntity,
+ Timestamp
+}
import com.github.ai.split.entity.exception.DomainError
import com.github.ai.split.utils.some
import com.github.ai.split.domain.usecases.ResolveUserReferencesUseCase
@@ -111,7 +119,7 @@ class UpdateExpenseUseCase(
amount = newAmount.getOrElse(expense.entity.amount),
isSplitBetweenAll = isSplitBetweenAll,
created = expense.entity.created,
- modified = modified
+ modified = Timestamp.now()
),
paidBy = paidBy,
splitBetween = splitBetween
diff --git a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala
index 5099388..1c9aff8 100644
--- a/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/domain/usecases/UpdateGroupUseCase.scala
@@ -9,7 +9,7 @@ import com.github.ai.split.data.db.dao.{
}
import com.github.ai.split.domain.usecases.AddMembersUseCase
import com.github.ai.split.domain.PasswordService
-import com.github.ai.split.entity.db.{GroupEntity, GroupMemberEntity, GroupUid, MemberUid, UserUid}
+import com.github.ai.split.entity.db.{GroupEntity, GroupMemberEntity, GroupUid, MemberUid, Timestamp, UserUid}
import com.github.ai.split.entity.exception.DomainError
import zio.*
import zio.direct.*
@@ -68,21 +68,19 @@ class UpdateGroupUseCase(
_ <- updateMembers(groupUid = groupUid, newMembersOption = newMemberUids)
_ <- {
- val modified = LocalDateTime.now(ZoneOffset.UTC)
-
groupDao.update(
GroupEntity(
uid = groupUid,
title = newTitle.getOrElse(group.title),
description = newDescription.getOrElse(group.description),
- passwordHash = if (newPassword.isDefined) {
- Some(passwordService.hashPassword(newPassword.get))
+ passwordHash = if (newPassword.nonEmpty) {
+ passwordService.hashPassword(newPassword.get)
} else {
group.passwordHash
},
currencyIsoCode = newCurrencyIsoCode.getOrElse(group.currencyIsoCode),
created = group.created,
- modified = modified
+ modified = Timestamp.now()
)
)
}
diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala
index 79f27b5..9109d7b 100644
--- a/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/ExpenseEntity.scala
@@ -1,7 +1,5 @@
package com.github.ai.split.entity.db
-import java.time.LocalDateTime
-
case class ExpenseEntity(
uid: ExpenseUid,
groupUid: GroupUid,
@@ -9,6 +7,6 @@ case class ExpenseEntity(
description: String,
amount: Double,
isSplitBetweenAll: Boolean,
- created: LocalDateTime,
- modified: LocalDateTime
+ created: Timestamp,
+ modified: Timestamp
)
diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala
index 1b6f562..2ddc2ab 100644
--- a/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/GroupEntity.scala
@@ -1,15 +1,13 @@
package com.github.ai.split.entity.db
-import java.time.LocalDateTime
-
case class GroupEntity(
uid: GroupUid,
title: String,
description: String,
- passwordHash: Option[String],
+ passwordHash: String,
currencyIsoCode: String,
- created: LocalDateTime,
- modified: LocalDateTime
+ created: Timestamp,
+ modified: Timestamp
)
object GroupEntity {
diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/Timestamp.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/Timestamp.scala
new file mode 100644
index 0000000..fd85d9b
--- /dev/null
+++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/Timestamp.scala
@@ -0,0 +1,11 @@
+package com.github.ai.split.entity.db
+
+case class Timestamp(
+ seconds: Long
+) extends AnyVal
+
+object Timestamp {
+
+ def now() =
+ Timestamp(seconds = System.currentTimeMillis() / 1000L)
+}
diff --git a/backend/app/src/main/scala/com/github/ai/split/entity/db/Uids.scala b/backend/app/src/main/scala/com/github/ai/split/entity/db/Uids.scala
index a5a22f8..f19a7db 100644
--- a/backend/app/src/main/scala/com/github/ai/split/entity/db/Uids.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/entity/db/Uids.scala
@@ -1,37 +1,8 @@
package com.github.ai.split.entity.db
import java.util.UUID
-import io.getquill.MappedEncoding
-opaque type UserUid = UUID
-opaque type GroupUid = UUID
-opaque type MemberUid = UUID
-opaque type ExpenseUid = UUID
-
-object UserUid {
- def apply(uid: UUID): UserUid = uid
-
- implicit val encodeUserUid: MappedEncoding[UserUid, UUID] = MappedEncoding[UserUid, UUID](identity)
- implicit val decodeUserUid: MappedEncoding[UUID, UserUid] = MappedEncoding[UUID, UserUid](UserUid(_))
-}
-
-object GroupUid {
- def apply(uid: UUID): GroupUid = uid
-
- implicit val encodeGroupUid: MappedEncoding[GroupUid, UUID] = MappedEncoding[GroupUid, UUID](identity)
- implicit val decodeGroupUid: MappedEncoding[UUID, GroupUid] = MappedEncoding[UUID, GroupUid](GroupUid(_))
-}
-
-object MemberUid {
- def apply(uid: UUID): MemberUid = uid
-
- implicit val encodeMemberUid: MappedEncoding[MemberUid, UUID] = MappedEncoding[MemberUid, UUID](identity)
- implicit val decodeMemberUid: MappedEncoding[UUID, MemberUid] = MappedEncoding[UUID, MemberUid](MemberUid(_))
-}
-
-object ExpenseUid {
- def apply(uid: UUID): ExpenseUid = uid
-
- implicit val encodeExpenseUid: MappedEncoding[ExpenseUid, UUID] = MappedEncoding[ExpenseUid, UUID](identity)
- implicit val decodeExpenseUid: MappedEncoding[UUID, ExpenseUid] = MappedEncoding[UUID, ExpenseUid](ExpenseUid(_))
-}
+case class UserUid(value: UUID) extends AnyVal
+case class GroupUid(value: UUID) extends AnyVal
+case class MemberUid(value: UUID) extends AnyVal
+case class ExpenseUid(value: UUID) extends AnyVal
diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala b/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala
index 78b7180..ecb0b06 100644
--- a/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/utils/CollectionExtensions.scala
@@ -1,5 +1,6 @@
package com.github.ai.split.utils
+import java.util.Properties
import scala.jdk.CollectionConverters.*
extension [T](list: List[T]) {
@@ -9,3 +10,16 @@ extension [T](list: List[T]) {
extension [T](javaList: java.util.List[T]) {
def toScalaList(): List[T] = javaList.asScala.toList
}
+
+extension (values: Map[String, String]) {
+
+ def toProperties(): Properties = {
+ val properties = Properties()
+
+ for ((key, value) <- values) {
+ properties.put(key, value)
+ }
+
+ properties
+ }
+}
diff --git a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala
index 449b239..e39dbff 100644
--- a/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala
+++ b/backend/app/src/main/scala/com/github/ai/split/utils/DataConverters.scala
@@ -11,6 +11,7 @@ import com.github.ai.split.entity.db.{
MemberUid,
PaidByEntity,
SplitBetweenEntity,
+ Timestamp,
UserEntity,
UserUid
}
@@ -68,7 +69,7 @@ def toExpenseDto(
}
}
} yield ExpenseDto(
- expense.uid.toString,
+ expense.uid.value.toString,
expense.title,
expense.description,
expense.amount,
@@ -95,7 +96,7 @@ def toMemberDtos(
.fromOption(userOption)
.map(user =>
MemberDto(
- memberUid.toString,
+ memberUid.value.toString,
user.name
)
)
@@ -158,7 +159,7 @@ def toGroupDto(
)
)
} yield GroupDto(
- group.uid.toString,
+ group.uid.value.toString,
group.title,
group.description,
toCurrencyDto(currency),
@@ -174,8 +175,8 @@ def toTransactionDto(
transaction: Transaction
): TransactionDto =
TransactionDto(
- transaction.creditor.toString,
- transaction.debtor.toString,
+ transaction.creditor.value.toString,
+ transaction.debtor.value.toString,
transaction.amount
)
@@ -189,11 +190,14 @@ def toCurrencyDto(
)
def toTimestampDto(
- dateTime: LocalDateTime
-): TimestampDto =
+ timestamp: Timestamp
+): TimestampDto = {
+ val time = LocalDateTime.ofEpochSecond(timestamp.seconds, 0, ZoneOffset.UTC)
+
TimestampDto(
- dateTime.toEpochSecond(ZoneOffset.UTC),
- dateTime.format(TIMESTAMP_FORMAT)
+ timestamp.seconds,
+ time.format(TIMESTAMP_FORMAT)
)
+}
private val TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
diff --git a/backend/build.sbt b/backend/build.sbt
index 3ef889f..42177a6 100644
--- a/backend/build.sbt
+++ b/backend/build.sbt
@@ -56,9 +56,10 @@ lazy val app = project
"com.auth0" % "java-jwt" % "4.5.0",
// Database
- "io.getquill" %% "quill-zio" % "4.8.6",
- "io.getquill" %% "quill-jdbc-zio" % "4.8.6",
"com.h2database" % "h2" % "2.3.232",
+ "org.postgresql" % "postgresql" % "42.7.3",
+ "com.typesafe.slick" %% "slick" % "3.6.1",
+ "com.typesafe.slick" %% "slick-hikaricp" % "3.6.1",
// Password Hashing
"org.mindrot" % "jbcrypt" % "0.4",
diff --git a/backend/docker-compose.yaml b/backend/docker-compose.yaml
new file mode 100644
index 0000000..a89c1ea
--- /dev/null
+++ b/backend/docker-compose.yaml
@@ -0,0 +1,40 @@
+version: "3.8"
+services:
+ db:
+ image: postgres:15
+ restart: always
+ environment:
+ - POSTGRES_DB=${POSTGRES_DB}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ ports:
+ - "5432:5432"
+ volumes:
+ - ss-db:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build: .
+ ports:
+ - "4281:8080"
+ - "4282:8443"
+ depends_on:
+ db:
+ condition: service_healthy
+ environment:
+ - POSTGRES_HOST=db
+ - POSTGRES_DB=${POSTGRES_DB}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ volumes:
+ - ss-app-data:/app-data
+
+volumes:
+ ss-db:
+ driver: local
+ ss-app-data:
+ driver: local