Skip to content
3 changes: 2 additions & 1 deletion backend/src/main/scala/com/softwaremill/bootzooka/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import ox.OxApp.Settings
import ox.otel.context.PropagatingVirtualThreadFactory

object Main extends OxApp.Simple with Logging:
// route JUL to SLF4J (JUL is used by Magnum & OTEL for logging)
// route JUL to SLF4J (JUL is used by OTEL for logging); parlance instead logs via java.lang.System.Logger,
// which is routed to SLF4J by the slf4j-jdk-platform-logging runtime dependency, not by this bridge
SLF4JBridgeHandler.removeHandlersForRootLogger()
SLF4JBridgeHandler.install()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
package com.softwaremill.bootzooka.email

import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, Spec, SqlNameMapper, Table}
import com.softwaremill.bootzooka.infrastructure.Magnum.given
import ma.chinespirit.parlance.{EntityMeta, QueryBuilder, Repo, SqlNameMapper, Table}
import com.softwaremill.bootzooka.infrastructure.Codecs.given
import com.softwaremill.bootzooka.infrastructure.Tx
import com.softwaremill.bootzooka.util.Strings.Id
import ox.discard

/** Model for storing and retrieving scheduled emails. */
class EmailModel:
private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]
private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]()

def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email))
def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail)
def count()(using DbTx): Long = emailRepo.count
def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard
def insert(email: Email)(using Tx): Unit = emailRepo.rawInsert(ScheduledEmails(email))
def find(limit: Int)(using Tx): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail)
def count()(using Tx): Long = emailRepo.count
def delete(ids: Vector[Id[Email]])(using Tx): Unit = emailRepo.deleteAllById(ids).discard

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String):
@Table(SqlNameMapper.CamelToSnakeCase)
private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String) derives EntityMeta:
def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content)))

private object ScheduledEmails:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.softwaremill.bootzooka.email

import com.augustnagro.magnum.DbTx
import com.softwaremill.bootzooka.email.sender.EmailSender
import com.softwaremill.bootzooka.infrastructure.DB
import com.softwaremill.bootzooka.infrastructure.{DB, Tx}
import com.softwaremill.bootzooka.logging.Logging
import com.softwaremill.bootzooka.metrics.Metrics
import com.softwaremill.bootzooka.util.IdGenerator
Expand All @@ -21,7 +20,7 @@ class EmailService(
) extends EmailScheduler
with Logging:

def schedule(data: EmailData)(using DbTx): Unit =
def schedule(data: EmailData)(using Tx): Unit =
logger.debug(s"Scheduling email to be sent to: ${data.recipient}")
val id = idGenerator.nextId[Email]()
emailModel.insert(Email(id, data))
Expand Down Expand Up @@ -57,4 +56,4 @@ class EmailService(
end EmailService

trait EmailScheduler:
def schedule(data: EmailData)(using DbTx): Unit
def schedule(data: EmailData)(using Tx): Unit
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package com.softwaremill.bootzooka.infrastructure

import com.augustnagro.magnum.DbCodec
import com.softwaremill.bootzooka.logging.Logging
import ma.chinespirit.parlance.DbCodec
import com.softwaremill.bootzooka.util.Strings.*

import java.time.{Instant, OffsetDateTime, ZoneOffset}

/** Magnum codecs for custom types, useful when writing SQL queries. */
object Magnum extends Logging:
/** parlance [[DbCodec]]s for custom types, useful when writing SQL queries. */
object Codecs:
given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC))

given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString)
given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString)
given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString)
end Magnum
end Codecs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.softwaremill.bootzooka.infrastructure

import com.augustnagro.magnum.{DbCodec, DbTx, SqlLogger, Transactor, connect, sql}
import ma.chinespirit.parlance.{Postgres, SqlLogger, Transactor, sql}
import com.softwaremill.bootzooka.infrastructure.DB.LeftException
import com.softwaremill.bootzooka.logging.Logging
import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
Expand All @@ -15,21 +15,20 @@ import scala.util.NotGiven
import scala.util.control.{NoStackTrace, NonFatal}

class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable:
private val transactor = Transactor(
dataSource = dataSource,
sqlLogger = SqlLogger.logSlowQueries(200.millis)
)
// the database type is pinned explicitly so that the `Tx` context type matches throughout the codebase
// (otherwise `Transactor(Postgres, ...)` would infer the singleton type `Postgres.type`)
private val transactor = Transactor[Postgres](Postgres, dataSource, SqlLogger.logSlowQueries(200.millis))

/** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */
def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] =
try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity)))
def transactEither[E, T](f: Tx ?=> Either[E, T]): Either[E, T] =
try transactor.transact(Right(f.fold(e => throw LeftException(e), identity)))
catch case e: LeftException[E] @unchecked => Left(e.left)

/** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if
* no exception is thrown.
*/
def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T =
com.augustnagro.magnum.transact(transactor)(f)
def transact[T](f: Tx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T =
transactor.transact(f)

override def close(): Unit = dataSource.close()
end DB
Expand All @@ -56,7 +55,7 @@ object DB extends Logging:
.load()

def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard
def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard
def testConnection(ds: DataSource): Unit = Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard

@aikido-pr-checks aikido-pr-checks Bot May 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard combines transactor construction, nested query execution and an IO connect/discard on one line; extract parts (build transactor, prepare query, then connect) to improve readability.

Show fix
Suggested change
def testConnection(ds: DataSource): Unit = Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard
def testConnection(ds: DataSource): Unit =
val transactor = Transactor[Postgres](Postgres, ds)
val query = sql"SELECT 1".query[Int].run()
transactor.connect(query).discard
Details

✨ AI Reasoning
​The testConnection helper was changed to construct a Transactor and on a single line call connect with a nested query expression and then discard the result. This packs transactor construction, a nested sql.query.run call, and an IO-producing connect/discard sequence into one line, increasing cognitive load when reasoning about side effects and resource usage.

Reply @AikidoSec feedback: [FEEDBACK] to get better review comments in the future.
Reply @AikidoSec ignore: [REASON] to ignore this issue.
More info


@tailrec
def connectAndMigrate(ds: DataSource): Unit =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.softwaremill.bootzooka.infrastructure

import ma.chinespirit.parlance.{DbTx, Postgres}

/** A transaction context for the application's single (Postgres) database. Use as a `(using Tx)` parameter on methods that must run within a
* transaction; an instance is provided by [[DB.transact]] / [[DB.transactEither]]. Centralises the choice of database type so call sites
* don't repeat `DbTx[Postgres]`.
*/
type Tx = DbTx[Postgres]
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
package com.softwaremill.bootzooka.passwordreset

import com.augustnagro.magnum.{DbCodec, DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table}
import com.softwaremill.bootzooka.infrastructure.Magnum.given
import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table}
import com.softwaremill.bootzooka.infrastructure.Codecs.given
import com.softwaremill.bootzooka.infrastructure.Tx
import com.softwaremill.bootzooka.security.AuthTokenOps
import com.softwaremill.bootzooka.user.User
import com.softwaremill.bootzooka.util.Strings.Id

import java.time.Instant

class PasswordResetCodeModel:
private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]
private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]()

def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr)
def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id)
def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id)
def insert(pr: PasswordResetCode)(using Tx): Unit = passwordResetCodeRepo.rawInsert(pr)
def delete(id: Id[PasswordResetCode])(using Tx): Unit = passwordResetCodeRepo.deleteById(id)
def findById(id: Id[PasswordResetCode])(using Tx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id)

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
@Table(SqlNameMapper.CamelToSnakeCase)
@SqlName("password_reset_codes")
case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant)
case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) derives EntityMeta

class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]:
override def tokenName: String = "PasswordResetCode"
override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById
override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id)
override def findById: Tx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById
override def delete: Tx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id)
override def userId: PasswordResetCode => Id[User] = _.userId
override def validUntil: PasswordResetCode => Instant = _.validUntil
// password reset code is a one-time token
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.softwaremill.bootzooka.passwordreset

import com.augustnagro.magnum.DbTx
import com.softwaremill.bootzooka.Fail
import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates}
import com.softwaremill.bootzooka.infrastructure.DB
import com.softwaremill.bootzooka.infrastructure.{DB, Tx}
import com.softwaremill.bootzooka.logging.Logging
import com.softwaremill.bootzooka.security.Auth
import com.softwaremill.bootzooka.user.{User, UserModel}
Expand All @@ -23,22 +22,22 @@ class PasswordResetService(
clock: Clock,
db: DB
) extends Logging:
def forgotPassword(loginOrEmail: String)(using DbTx): Unit =
def forgotPassword(loginOrEmail: String)(using Tx): Unit =
userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match
case None => logger.debug(s"Could not find user with $loginOrEmail login/email")
case Some(user) =>
val pcr = createCode(user)
sendCode(user, pcr)

private def createCode(user: User)(using DbTx): PasswordResetCode =
private def createCode(user: User)(using Tx): PasswordResetCode =
logger.debug(s"Creating password reset code for user: ${user.id}")
val id = idGenerator.nextId[PasswordResetCode]()
val validUntil = clock.now().plusMillis(config.codeValid.toMillis)
val passwordResetCode = PasswordResetCode(id, user.id, validUntil)
passwordResetCodeModel.insert(passwordResetCode)
passwordResetCode

private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit =
private def sendCode(user: User, code: PasswordResetCode)(using Tx): Unit =
logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}")
emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code)))

Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
package com.softwaremill.bootzooka.security

import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql}
import com.softwaremill.bootzooka.infrastructure.Magnum.given
import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql}
import com.softwaremill.bootzooka.infrastructure.Codecs.given
import com.softwaremill.bootzooka.infrastructure.Tx
import com.softwaremill.bootzooka.user.User
import com.softwaremill.bootzooka.util.Strings.Id
import ox.discard

import java.time.Instant

class ApiKeyModel:
private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]
private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]()
private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]]

def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey)
def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id)
def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard
def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id)
def insert(apiKey: ApiKey)(using Tx): Unit = apiKeyRepo.rawInsert(apiKey)
def findById(id: Id[ApiKey])(using Tx): Option[ApiKey] = apiKeyRepo.findById(id)
def deleteAllForUser(id: Id[User])(using Tx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard
def delete(id: Id[ApiKey])(using Tx): Unit = apiKeyRepo.deleteById(id)
end ApiKeyModel

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
@Table(SqlNameMapper.CamelToSnakeCase)
@SqlName("api_keys")
case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant)
case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) derives EntityMeta

class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]:
override def tokenName: String = "ApiKey"
override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById
override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id)
override def findById: Tx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById
override def delete: Tx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id)
override def userId: ApiKey => Id[User] = _.userId
override def validUntil: ApiKey => Instant = _.validUntil
override def deleteWhenValid: Boolean = false
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.softwaremill.bootzooka.security

import com.augustnagro.magnum.DbTx
import com.softwaremill.bootzooka.infrastructure.Tx
import com.softwaremill.bootzooka.logging.Logging
import com.softwaremill.bootzooka.user.User
import com.softwaremill.bootzooka.util.Strings.Id
Expand All @@ -10,7 +10,7 @@ import java.time.temporal.ChronoUnit
import scala.concurrent.duration.Duration

class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging:
def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey =
def create(userId: Id[User], valid: Duration)(using Tx): ApiKey =
val id = idGenerator.nextId[ApiKey]()
val now = clock.now()
val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS)
Expand All @@ -20,11 +20,11 @@ class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: C
apiKey
end create

def invalidate(id: Id[ApiKey])(using DbTx): Unit =
def invalidate(id: Id[ApiKey])(using Tx): Unit =
logger.debug(s"Invalidating api key $id")
apiKeyModel.delete(id)

def invalidateAllForUser(userId: Id[User])(using DbTx): Unit =
def invalidateAllForUser(userId: Id[User])(using Tx): Unit =
logger.debug(s"Invalidating all api keys for user $userId")
apiKeyModel.deleteAllForUser(userId)
end ApiKeyService
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
package com.softwaremill.bootzooka.security

import com.augustnagro.magnum.DbTx
import com.softwaremill.bootzooka.*
import com.softwaremill.bootzooka.infrastructure.DB
import com.softwaremill.bootzooka.infrastructure.{DB, Tx}
import com.softwaremill.bootzooka.logging.Logging
import com.softwaremill.bootzooka.user.User
import com.softwaremill.bootzooka.util.*
Expand Down Expand Up @@ -44,8 +43,8 @@ end Auth
*/
trait AuthTokenOps[T]:
def tokenName: String
def findById: DbTx ?=> Id[T] => Option[T]
def delete: DbTx ?=> T => Unit
def findById: Tx ?=> Id[T] => Option[T]
def delete: Tx ?=> T => Unit
def userId: T => Id[User]
def validUntil: T => Instant
def deleteWhenValid: Boolean
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package com.softwaremill.bootzooka.user

import com.augustnagro.magnum.{DbCodec, DbTx, Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo, sql}
import ma.chinespirit.parlance.{===, ||, EntityMeta, QueryBuilder, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql}
import com.password4j.{Argon2Function, Password}
import com.softwaremill.bootzooka.infrastructure.Magnum.given
import com.softwaremill.bootzooka.infrastructure.Codecs.given
import com.softwaremill.bootzooka.infrastructure.Tx
import com.softwaremill.bootzooka.user.User.PasswordHashing
import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.*
import com.softwaremill.bootzooka.util.PasswordVerificationStatus
Expand All @@ -12,37 +13,33 @@ import ox.discard
import java.time.Instant

class UserModel:
private val userRepo = Repo[User, User, Id[User]]
private val userRepo = Repo[User, User, Id[User]]()
private val u = TableInfo[User, User, Id[User]]

export userRepo.{insert, findById}
export userRepo.{rawInsert as insert, findById}

def findByEmail(email: LowerCased)(using DbTx): Option[User] = findBy(
Spec[User].where(sql"${u.emailLowerCase} = $email")
)
def findByLogin(login: LowerCased)(using DbTx): Option[User] = findBy(
Spec[User].where(sql"${u.loginLowerCase} = $login")
)
def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx): Option[User] =
findBy(Spec[User].where(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail"))
// queries use parlance's typed column DSL (`_.col === value`), which is checked against the entity's fields at compile time
def findByEmail(email: LowerCased)(using Tx): Option[User] =
QueryBuilder.from[User].where(_.emailLowerCase === email).first()
def findByLogin(login: LowerCased)(using Tx): Option[User] =
QueryBuilder.from[User].where(_.loginLowerCase === login).first()
def findByLoginOrEmail(loginOrEmail: LowerCased)(using Tx): Option[User] =
QueryBuilder.from[User].where(u => u.loginLowerCase === loginOrEmail || u.emailLowerCase === loginOrEmail).first()

private def findBy(by: Spec[User])(using DbTx): Option[User] =
userRepo.findAll(by).headOption

def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx): Unit =
def updatePassword(userId: Id[User], newPassword: Hashed)(using Tx): Unit =
sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard

def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx): Unit =
def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using Tx): Unit =
sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update
.run()
.discard

def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx): Unit =
def updateEmail(userId: Id[User], newEmail: LowerCased)(using Tx): Unit =
sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard

end UserModel

@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
@Table(SqlNameMapper.CamelToSnakeCase)
@SqlName("users")
case class User(
id: Id[User],
Expand All @@ -51,7 +48,7 @@ case class User(
@SqlName("email_lowercase") emailLowerCase: LowerCased,
@SqlName("password") passwordHash: Hashed,
createdOn: Instant
):
) derives EntityMeta:
def verifyPassword(password: String): PasswordVerificationStatus =
if Password.check(password, passwordHash).`with`(PasswordHashing.Argon2) then PasswordVerificationStatus.Verified
else PasswordVerificationStatus.VerificationFailed
Expand Down
Loading
Loading