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