Compare commits

...
12 Commits
Author SHA1 Message Date
artemis d90ae86e1f Processing new bid MQTT messages 2026-09-21 15:03:45 +03:00
artemis e9d1f93723 Auctions show 2026-09-18 19:38:38 +03:00
artemis d1360b2917 Some ETag refactoring 2026-09-18 19:36:53 +03:00
artemis e144a7f3c0 Refactored checkFresh 2026-09-17 15:08:38 +03:00
artemis 621fcf90fa Caching and user data endpoint 2026-09-16 23:56:40 +03:00
artemis c45889838b Replaced Ints with Longs as database Ids. 2026-09-16 16:22:00 +03:00
artemis 98fa797a05 Added Artemis to compose.yaml 2026-09-13 00:37:40 +03:00
artemis 8c0deee2df Small refactoring of the sessions controller 2026-09-13 00:37:27 +03:00
artemis 2754c86c63 Refactored Auctions index 2026-09-09 22:42:01 +03:00
artemis eadf793882 Auctions index and CORS filter 2026-09-03 00:02:34 +03:00
artemis f2ec6889cb Dev actions controller/router + user registration 2026-09-02 00:40:09 +03:00
artemis a6bbef1bf3 Reworked AuthenticatedAction 2026-09-01 00:42:38 +03:00
18 changed files with 415 additions and 75 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ object AuctionPublisher extends ActorModule with Logging {
sealed trait Command
private case object Ready extends Command
case class Publish(auction: Auction, author: User) extends Command
private case class AuctionConfirmed(id: Int, responseTopic: String) extends Command
private case class AuctionConfirmed(id: Long, responseTopic: String) extends Command
private final val topic = "auctions/created"
+44 -12
View File
@@ -3,23 +3,43 @@ package controllers
import actors.auctions.AuctionPublisher
import controllers.authentication.AuthenticatedAction
import dao.{AuctionsDAO, AuthTokensDAO}
import models.Auction
import org.apache.pekko.actor.typed.ActorRef
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsError, JsPath, JsValue, Json, Reads, Writes}
import play.api.libs.json.*
import play.api.mvc.{BaseController, ControllerComponents}
import java.security.SecureRandom
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
import models.Auction
import caching.{CheckFresh, DigestEtagable, Etagable}
import java.io.DataOutputStream
@Singleton
class AuctionsController @Inject()(val controllerComponents: ControllerComponents,
val auctionsDAO: AuctionsDAO,
val authTokensDAO: AuthTokensDAO,
val auctionPublisher: ActorRef[AuctionPublisher.Command],
implicit val ec: ExecutionContext) extends BaseController {
def create = Action.andThen(AuthenticatedAction(authTokensDAO)).async(parse.json) { implicit request =>
authenticatedAction: AuthenticatedAction,
val auctionPublisher: ActorRef[AuctionPublisher.Command])
(implicit val ec: ExecutionContext)
extends BaseController with CheckFresh {
def index = authenticatedAction.async { request =>
for {
auctions <- auctionsDAO.list_available(request.user)
json = Json.toJson(auctions)
} yield Ok(json)
}
def show(id: Long) = authenticatedAction.async { implicit request =>
auctionsDAO.showAvailable(request.user, id).map { auctionOpt =>
auctionOpt.fold(NotFound) { auction =>
checkFresh(auction) {
Ok(Json.toJson(auction)).withHeaders("etag" -> auction.etag)
}
}
}
}
def create = authenticatedAction.async(parse.json) { implicit request =>
val auctionCreateDataResult = request.body.validate[AuctionCreateData]
auctionCreateDataResult.fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
@@ -27,15 +47,27 @@ class AuctionsController @Inject()(val controllerComponents: ControllerComponent
for {
auction <- auctionsDAO.create(auctionCreateData.name, auctionCreateData.startingPrice, request.user)
json = Json.toJson(auction)
response = Created(json)
_ = auctionPublisher ! AuctionPublisher.Publish(auction, request.user)
} yield response
} yield Created(json)
}
)
}
private case class AuctionCreateData(name: String, startingPrice: BigDecimal)
private implicit val auctionCreateDataReads: Reads[AuctionCreateData] = (
(JsPath \ "name").read[String] and (JsPath \ "starting_price").read[BigDecimal]
)(AuctionCreateData.apply)
private given Reads[AuctionCreateData] = Json.reads[AuctionCreateData]
private given DigestEtagable[Auction] with {
override protected def writeDigestData(a: Auction, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
s.writeLong(a.authorId)
s.writeUTF(a.gid)
s.writeUTF(a.name)
s.writeLong(a.createdAt.getEpochSecond)
s.writeLong(a.currentBidderId.getOrElse(0))
a.currentBid.foreach(b => s.writeLong((b * 1000).toLong))
a.currentBidAt.foreach(ba => s.writeLong(ba.getEpochSecond))
}
}
}
+29 -4
View File
@@ -1,15 +1,24 @@
package controllers
import javax.inject._
import play.api._
import play.api.mvc._
import play.api.*
import _root_.controllers.authentication.{AuthenticatedAction, AuthenticatedRequest}
import _root_.controllers.caching.{CheckFresh, DigestEtagable}
import models.User
import play.api.libs.json.Json
import play.api.mvc.*
import java.io.DataOutputStream
import java.nio.charset.StandardCharsets.UTF_8
import javax.inject.*
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {
class HomeController @Inject()(val controllerComponents: ControllerComponents,
authenticatedAction: AuthenticatedAction)
extends BaseController with Logging with CheckFresh {
/**
* Create an Action to render an HTML page.
@@ -21,4 +30,20 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e
def index() = Action { implicit request: Request[AnyContent] =>
Ok(views.html.index())
}
def userData = authenticatedAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user = request.user
checkFresh(user) {
// TODO: put the max-age somewhere into config
Ok(Json.toJson(user)).withHeaders("Cache-Control" -> "private, max-age=15, must-revalidate")
}
}
private given DigestEtagable[models.User] with {
override protected def writeDigestData(a: User, s: DataOutputStream): Unit = {
s.writeLong(a.id.getOrElse(0))
s.write(a.gid.getBytes(UTF_8))
s.write(a.username.getBytes(UTF_8))
}
}
}
@@ -5,13 +5,15 @@ import jakarta.inject.Inject
import models.{AuthToken, User}
import play.api.libs.json.*
import play.api.mvc.Results.Unauthorized
import play.api.mvc.{ActionRefiner, Request, Result, WrappedRequest}
import play.api.mvc.{ActionBuilder, ActionRefiner, AnyContent, BodyParsers, Request, Result, WrappedRequest}
import scala.concurrent.{ExecutionContext, Future}
class AuthenticatedRequest[A](val token: AuthToken, val user: User, request: Request[A]) extends WrappedRequest[A](request)
class AuthenticatedAction @Inject()(authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext) extends ActionRefiner[Request, AuthenticatedRequest] {
class AuthenticatedAction @Inject()(val parser: BodyParsers.Default, authTokensDao: AuthTokensDAO)(implicit val executionContext: ExecutionContext)
extends ActionRefiner[Request, AuthenticatedRequest]
with ActionBuilder[AuthenticatedRequest, AnyContent]{
override protected def refine[A](request: Request[A]): Future[Either[Result, AuthenticatedRequest[A]]] = {
val authHeader = request.headers.get("Authorization")
.map(_.split(" ", 2)(1))
@@ -35,5 +37,4 @@ class AuthenticatedAction @Inject()(authTokensDao: AuthTokensDAO)(implicit val e
case None => unauthenticatedRequestError
}
}
}
@@ -9,39 +9,45 @@ import play.api.mvc.{AbstractController, ControllerComponents}
import scala.concurrent.{ExecutionContext, Future}
case class LoginData(username: String, password: String)
implicit val loginDataReads: Reads[LoginData] = (
(JsPath \ "username").read[String] and
(JsPath \ "password").read[String]
)(LoginData.apply)
implicit val tokenWrites: Writes[AuthToken] = (token: AuthToken) => Json.obj(
"token" -> token.token,
"createdAt" -> token.createdAt,
"expiresAt" -> token.expiresAt
)
object SessionsController {
case class LoginData(username: String, password: String)
given Reads[LoginData] = Json.reads[LoginData]
}
@Singleton
class SessionsController @Inject()(controllerComponents: ControllerComponents, usersDAO: UsersDAO, authTokensDAO: AuthTokensDAO, implicit val ec: ExecutionContext) extends AbstractController(controllerComponents) {
class SessionsController @Inject()(
controllerComponents: ControllerComponents,
usersDAO: UsersDAO,
authTokensDAO: AuthTokensDAO,
authenticatedAction: AuthenticatedAction)
(implicit val ec: ExecutionContext)
extends AbstractController(controllerComponents) {
import SessionsController.{LoginData, given}
def login() = Action.async(parse.json) { implicit request =>
val loginDataResult = request.body.validate[LoginData]
loginDataResult.fold(
errors => {
Future.successful(BadRequest(Json.obj("message" -> JsError.toJson(errors))))
},
errors => Future.successful(BadRequest(JsError.toJson(errors))),
loginData => {
usersDAO.authenticate(loginData.username, loginData.password).flatMap { userOption =>
userOption.fold(
Future.successful(UnprocessableEntity(Json.obj("message" -> "Invalid authentication")))
){ user =>
authTokensDAO.createToken(user).map(token => Ok(Json.toJson(token)))
authTokensDAO.createToken(user).map { token =>
val json = Json.toJsObject(token) ++ Json.obj(
"userId" -> user.id.get,
"userGid" -> user.gid
)
Ok(json)
}
}
}
}
)
}
def logout = Action.andThen(new AuthenticatedAction(authTokensDAO)).async { request =>
def logout = authenticatedAction.async { request =>
authTokensDAO.destroy(request.token).map{ _ => Ok("") }
}
}
+57
View File
@@ -0,0 +1,57 @@
package controllers.caching
import play.api.mvc.Results.NotModified
import play.api.mvc.{Request, Result}
import java.io.{ByteArrayOutputStream, DataOutputStream, OutputStream}
import java.security.{DigestOutputStream, MessageDigest}
import java.util.Base64
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Using
trait Etagable[A]:
extension (a: A) def etag: String
trait DigestEtagable[A] extends Etagable[A]:
private val base64 = Base64.getUrlEncoder.withoutPadding
protected def writeDigestData(a: A, s: DataOutputStream): Unit
extension (a: A) {
override def etag: String = {
val md = MessageDigest.getInstance("SHA-1")
Using(DataOutputStream(DigestOutputStream(OutputStream.nullOutputStream(), md))) { dos =>
writeDigestData(a, dos)
dos.flush()
}
base64.encodeToString(md.digest())
}
}
trait CheckFresh {
protected def checkFreshAsync[A, E: Etagable](entity: E)(onStale: => Future[Result])(using request: Request[A], ec: ExecutionContext): Future[Result] = {
compareEtag(request, entity).fold(
entityEtag => onStale.map(_.withHeaders("etag" -> entityEtag)),
requestEtag => Future.successful(notModified(requestEtag))
)
}
protected def checkFresh[A, E: Etagable](entity: E)(onStale: => Result)(using request: Request[A]): Result = {
compareEtag(request, entity).fold(
entityEtag => onStale.withHeaders("etag" -> entityEtag),
requestEtag => notModified(requestEtag)
)
}
private def compareEtag[A, E: Etagable](request: Request[A], entity: E) = {
val entityEtag = entity.etag
request.headers.get("If-None-Match").filter(e => e == entityEtag) match {
case Some(etag) => Right(etag)
case None => Left(entityEtag)
}
}
private def notModified(etag: String) = NotModified.withHeaders("etag" -> etag)
}
@@ -0,0 +1,32 @@
package controllers.devel
import dao.UsersDAO
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsError, JsPath, Json, Reads}
import play.api.mvc.*
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
class DevActionsController @Inject()(val controllerComponents: ControllerComponents,
val usersDAO: UsersDAO)
(implicit val ec: ExecutionContext)
extends BaseController {
private case class UserData(username: String, password: String)
private implicit val userDataReads: Reads[UserData] = (
(JsPath \ "username").read[String] and (JsPath \ "password").read[String]
)(UserData.apply)
def createUser = Action.async(parse.json) { implicit request =>
request.body.validate[UserData].fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
userData => usersDAO.create(userData.username, userData.password)
.map {
case Left(UsersDAO.UsernameTaken) => BadRequest(Json.obj("error" -> "Username exists"))
case Left(_ex) => BadRequest(Json.obj("error" -> "User creation error"))
case Right(user) => Ok(Json.toJson(user))
}
)
}
}
+24
View File
@@ -0,0 +1,24 @@
package controllers.devel
import play.api.Environment
import play.api.Mode.Dev
import play.api.mvc.*
import play.api.routing.Router.Routes
import play.api.routing.SimpleRouter
import play.api.routing.sird.{POST, UrlContext}
import javax.inject.Inject
class DevRouter @Inject()(environment: Environment, controller: DevActionsController) extends SimpleRouter {
override def routes: Routes = {
if (environment.mode == Dev) {
devRoutes
} else {
PartialFunction.empty
}
}
private val devRoutes: PartialFunction[RequestHeader, Handler] = {
case POST(p"/users") => controller.createUser()
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ class LoggingFilter @Inject()(implicit val mat: Materializer, ec: ExecutionConte
val endTime = System.currentTimeMillis()
val requestTime = endTime - startTime
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} $requestTime")
logger.info(s"${requestHeader.method} ${requestHeader.uri} ${result.header.status} ${requestTime}ms")
result
}
+37 -6
View File
@@ -1,21 +1,35 @@
package dao
import models.{Auction, AuctionStatus}
import schemas.Auctions
import schemas.auctionStatusColumnType
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import slick.jdbc.PostgresProfile
import slick.jdbc.PostgresProfile.api.*
import java.time.Instant
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Random
import models.{Auction, AuctionStatus, User}
import schemas.Auctions
import schemas.given
class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider)(implicit val ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
def create(name: String, startingPrice: BigDecimal, author: models.User): Future[Auction] = {
def list_available(user: User): Future[Seq[Auction]] = {
db.run(availableScope(user).result)
}
def showAvailable(user: User, id: Long): Future[Option[Auction]] = {
db.run {
availableScope(user).filter(_.id === id).take(1).result.headOption
}
}
def list_created_by(user: User): Future[Seq[Auction]] = {
db.run(Auctions.filter(_.authorId === user.id).result)
}
def create(name: String, startingPrice: BigDecimal, author: User): Future[Auction] = {
val auction = Auction(
id = None, name = name,
gid = Random.alphanumeric.take(24).mkString,
@@ -33,8 +47,25 @@ class AuctionsDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvid
db.run((Auctions returning Auctions) += auction)
}
def changeStatus(auctionId: Int, status: AuctionStatus): Future[Boolean] = {
def changeStatus(auctionId: Long, status: AuctionStatus): Future[Boolean] = {
db.run(Auctions.filter(_.id === auctionId).map(_.status).update(status))
.map(_ == 1)
}
def find_by_gid(gid: String): Future[Option[Auction]] = {
db.run(Auctions.filter(_.gid === gid).take(1).result.headOption)
}
def newBid(auction: Auction, bidder: User, bid: BigDecimal, timestamp: Instant): Future[Option[Auction]] = {
db.run {
Auctions.filter(_.id === auction.id.get).map{ it => (it.currentBid, it.currentBidAt, it.currentBidderId) }
.update((Some(bid), Some(timestamp), Some(bidder.id.get)))
.andThen { Auctions.filter(_.id === auction.id.get).result.headOption }
.transactionally
}
}
private def availableScope(user: User) = {
Auctions.filter(row => (row.status === AuctionStatus.Ready) && (row.authorId =!= user.id.get))
}
}
+57 -5
View File
@@ -1,5 +1,6 @@
package dao
import dao.UsersDAO.{UnexpectedError, UsernameTaken}
import de.mkammerer.argon2.Argon2Factory
import execution.CryptoExecutionContext
import jakarta.inject.Inject
@@ -9,23 +10,74 @@ import slick.jdbc.PostgresProfile.api.*
import scala.concurrent.{ExecutionContext, Future}
import models.User
import org.postgresql.util.{PSQLException, PSQLState}
import schemas.Users
class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)(implicit val ec: ExecutionContext)
import java.nio.charset.StandardCharsets.UTF_8
import scala.util.Random
class UsersDAO @Inject()(protected val dbConfigProvider: DatabaseConfigProvider, private val cryptoEC: CryptoExecutionContext)
(using ec: ExecutionContext)
extends HasDatabaseConfigProvider[PostgresProfile] {
private val argon2 = Argon2Factory.create()
private object ArgonParameters {
val iterations = 3
val parallelism = 1
val memory = 65536
}
private final val dummyDigest = "$argon2i$v=19$m=65536,t=3,p=1$EB9QibLwzqkO7ne2bJoU/Q$ADkdyweXKylJORvWXbBVekURYaazkW/OplTq2wdN1So"
def authenticate(username: String, password: String): Future[Option[User]] = {
val query = Users.filter(_.username === username).take(1).result.headOption
val userFuture = db.run(query)
userFuture.map { userOption =>
for {
user <- userOption
digest <- user.passwordDigest
if argon2.verify(digest, password.getBytes("UTF-8"))
} yield user
}(using cryptoEC)
} yield (user, digest)
}.map {
case Some((user, digest)) => (Some(user), digest)
case None => (None, dummyDigest)
}.flatMap { (userOpt, digest) =>
for {
validHash <- Future(argon2.verify(digest, password.getBytes(UTF_8)))(using cryptoEC)
opt = userOpt.flatMap(u => if validHash then Some(u) else None)
} yield opt
}
}
def create(username: String, password: String): Future[Either[UsersDAO.RegistrationError, User]] = {
Future(argonHash(password))(using cryptoEC)
.flatMap{ passwordDigest =>
val gid = Random.alphanumeric.take(12).mkString
val user = User(
id = None,
gid = gid,
username = username,
passwordDigest = Some(passwordDigest)
)
db.run((Users returning Users) += user).map(Right(_))
}.recover {
case ex: PSQLException if ex.getSQLState == PSQLState.UNIQUE_VIOLATION.getState =>
Left(UsernameTaken)
case ex: Throwable =>
Left(UnexpectedError(ex))
}
}
def find_by_gid(gid: String): Future[Option[User]] = {
db.run(Users.filter(_.gid === gid).take(1).result.headOption)
}
private def argonHash(data: String) = argon2.hash(ArgonParameters.iterations, ArgonParameters.memory, ArgonParameters.parallelism, data.getBytes(UTF_8))
}
object UsersDAO {
sealed trait RegistrationError
case object UsernameTaken extends RegistrationError
case class UnexpectedError(cause: Throwable) extends RegistrationError
}
+22 -14
View File
@@ -1,12 +1,26 @@
package models
import play.api.libs.json.{JsValue, Json, Writes}
import play.api.libs.json.{JsString, JsValue, Json, OWrites, Writes}
import java.time.Instant
case class User(id: Option[Int], username: String, gid: String, passwordDigest: Option[String])
case class User(id: Option[Long], username: String, gid: String, passwordDigest: Option[String])
object User {
given writes: Writes[User] = (o: User) => Json.obj(
"gid" -> o.gid,
"username" -> o.username
)
}
case class AuthToken(id: Option[Int], userId: Int, token: String, createdAt: Instant, expiresAt: Instant)
case class AuthToken(id: Option[Long], userId: Long, token: String, createdAt: Instant, expiresAt: Instant)
object AuthToken {
given writes: OWrites[AuthToken] = (a: AuthToken) => Json.obj(
"token" -> a.token,
"userId" -> a.userId,
"createdAt" -> a.createdAt,
"expiresAt" -> a.expiresAt
)
}
enum AuctionStatus(val value: Short) {
case Created extends AuctionStatus(0)
@@ -16,21 +30,15 @@ enum AuctionStatus(val value: Short) {
object AuctionStatus {
lazy val byValue: Map[Short, AuctionStatus] = Map.from(AuctionStatus.values.map{it => (it.value, it)})
implicit val writes: Writes[AuctionStatus] = (o: AuctionStatus) => JsString(o.toString)
}
case class Auction(id: Option[Int], name: String, status: AuctionStatus, gid: String, authorId: Int,
case class Auction(id: Option[Long], name: String, status: AuctionStatus, gid: String, authorId: Long,
startingPrice: BigDecimal, currentBid: Option[BigDecimal],
currentBidderId: Option[Int], currentBidAt: Option[Instant],
currentBidderId: Option[Long], currentBidAt: Option[Instant],
createdAt: Instant)
object Auction {
implicit val writes: Writes[Auction] = (a: Auction) => Json.obj(
"id" -> a.id,
"name" -> a.name,
"gid" -> a.gid,
"starting_price" -> a.startingPrice,
"current_bid" -> a.currentBid,
"current_bidder_id" -> a.currentBidderId,
"current_bid_at" -> a.currentBidAt
)
implicit val writes: OWrites[Auction] = Json.writes[Auction]
}
+17 -2
View File
@@ -8,8 +8,10 @@ import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import play.api.Logging
import play.api.libs.concurrent.ActorModule
import play.api.libs.json._
import java.nio.charset.StandardCharsets
import scala.util.matching.Regex
object MessageProcessorActor extends ActorModule with Logging {
sealed trait Command
@@ -18,14 +20,16 @@ object MessageProcessorActor extends ActorModule with Logging {
override type Message = Command
private final val newBidPattern: Regex = """notify/new_bid/(\w+)""".r
@Singleton
class Reg @Inject()(private val mqttMessageProcessorActor: ActorRef[Command])
@Provides
def create(client: Mqtt5AsyncClient): Behavior[Command] = Behaviors.setup { context =>
def create(client: Mqtt5AsyncClient, newBidService: services.auctions.NewBid): Behavior[Command] = Behaviors.setup { context =>
client.connect().whenComplete { (_action, _throwable) =>
client.subscribeWith()
.topicFilter("test/+")
.topicFilter("notify/new_bid/+")
.callback{ (publish) =>
context.self ! MqttMessageReceived(publish)
}
@@ -37,6 +41,17 @@ object MessageProcessorActor extends ActorModule with Logging {
val topic = publish.getTopic
val payload = String(publish.getPayloadAsBytes, StandardCharsets.UTF_8)
logger.info(s"Received message $topic $payload")
topic.toString match {
case newBidPattern(auctionGid) =>
Json.parse(payload).validate[services.auctions.NewBid.Request] match {
case JsSuccess(request, _) => newBidService.perform(auctionGid, request)
case JsError(errors) =>
logger.warn(errors.toString())
}
println(s"New bid $auctionGid")
}
Behaviors.same
case Stop => Behaviors.stopped{ () =>
client.disconnect()
+8 -8
View File
@@ -8,7 +8,7 @@ import slick.model.ForeignKeyAction.{Cascade, Restrict}
import java.time.Instant
class UsersTable(tag: Tag) extends Table[User](tag, "users") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def gid = column[String]("gid", O.Unique)
def username = column[String]("username", O.Unique)
def passwordDigest = column[Option[String]]("password_digest")
@@ -20,8 +20,8 @@ lazy val Users = TableQuery[UsersTable]
class AuthTokensTable(tag: Tag) extends Table[AuthToken](tag, "auth_tokens") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Int]("user_id")
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Long]("user_id")
def token = column[String]("token")
def createdAt = column[Instant]("created_at")
def expiresAt = column[Instant]("expires_at")
@@ -40,14 +40,14 @@ implicit val auctionStatusColumnType: BaseColumnType[AuctionStatus] =
)
class AuctionsTable(tag: Tag) extends Table[Auction](tag, "auctions") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def status = column[AuctionStatus]("status")
def gid = column[String]("gid", O.Unique)
def authorId = column[Int]("author_id")
def startingPrice = column[BigDecimal]("starting_price")
def currentBid = column[Option[BigDecimal]]("current_bid")
def currentBidderId = column[Option[Int]]("current_bidder_id")
def authorId = column[Long]("author_id")
def startingPrice = column[BigDecimal]("starting_price", O.SqlType("DECIMAL(20, 3)"))
def currentBid = column[Option[BigDecimal]]("current_bid", O.SqlType("DECIMAL(20, 3)"))
def currentBidderId = column[Option[Long]]("current_bidder_id")
def currentBidAt = column[Option[Instant]]("current_bid_at")
def createdAt = column[Instant]("created_at")
+39
View File
@@ -0,0 +1,39 @@
package services.auctions
import dao.{AuctionsDAO, UsersDAO}
import models.Auction
import play.api.libs.functional.syntax.toFunctionalBuilderOps
import play.api.libs.json.{JsPath, JsResult, Reads}
import java.time.Instant
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
object NewBid {
case class Request(bid: BigDecimal, bidderGid: String, timestamp: Instant)
given Reads[Request] = (
(JsPath \ "bid").read[BigDecimal] and
(JsPath \ "bidder_gid").read[String] and
(JsPath \ "timestamp").read[Instant]
)(Request.apply)
}
class NewBid @Inject()(auctionsDao: AuctionsDAO, usersDAO: UsersDAO)(using ec: ExecutionContext) {
import NewBid.Request
def perform(auctionGid: String, request: Request): Future[Option[Auction]] = {
val dataFuture = for {
auction <- auctionsDao.find_by_gid(auctionGid)
bidder <- usersDAO.find_by_gid(request.bidderGid)
} yield (auction, bidder)
dataFuture.flatMap {
case (Some(auction), Some(bidder)) =>
auctionsDao.newBid(auction, bidder, bid = request.bid, timestamp = request.timestamp)
case (_, _) =>
// One of them (or both) aren't found
Future.successful(None)
}
}
}
+7
View File
@@ -1,4 +1,6 @@
name: 'neon-vertigo-api'
include:
- ../shared/compose.shared.yml
services:
db:
image: "postgres:18"
@@ -6,3 +8,8 @@ services:
- db.env
ports:
- "5432:5432"
volumes:
- "db:/var/lib/postgresql"
volumes:
db:
+5
View File
@@ -2,9 +2,14 @@
play.http.filters=controllers.filters.Filters
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.csrf.header.bypassHeaders {
X-Requested-With = "*"
}
play.filters.cors {
allowedOrigins = ["http://localhost:4321"]
exposedHeaders = ["ETag"]
}
play.modules.enabled += "mqttClient.Module"
+6
View File
@@ -9,7 +9,13 @@ GET / controllers.HomeController.index()
POST /login controllers.authentication.SessionsController.login()
DELETE /logout controllers.authentication.SessionsController.logout()
GET /user_data controllers.HomeController.userData()
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
GET /auctions controllers.AuctionsController.index()
GET /auctions/:id controllers.AuctionsController.show(id: Long)
POST /auctions controllers.AuctionsController.create()
-> /-devel controllers.devel.DevRouter