diff --git a/build.sbt b/build.sbt index b8d5ece..4a711d8 100644 --- a/build.sbt +++ b/build.sbt @@ -9,14 +9,15 @@ scalacOptions ++= Seq("-deprecation", "-feature") libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.3" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2" -libraryDependencies += "io.spray" %% "spray-json" % "1.3.5" - libraryDependencies += "com.typesafe.akka" %% "akka-http" % "10.1.10" libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % "10.1.10" libraryDependencies += "com.typesafe.akka" %% "akka-stream" % "2.5.23" libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.0.4" libraryDependencies += "javax.ws.rs" % "javax.ws.rs-api" % "2.1.1" +libraryDependencies += "io.spray" %% "spray-json" % "1.3.5" +libraryDependencies += "com.lihaoyi" %% "scalatags" % "0.7.0" + libraryDependencies += "com.typesafe.slick" %% "slick" % "3.3.2" libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2" libraryDependencies += "org.flywaydb" % "flyway-core" % "6.0.6" diff --git a/src/main/resources/html/bis.html b/src/main/resources/html/bis.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000..a24324d --- /dev/null +++ b/src/main/resources/static/styles.css @@ -0,0 +1,277 @@ +/* in-text images */ +figure.img { + float: right; + border: 0px solid #333; + padding: 0px; + margin: 5px 0px 5px 10px; +} +figure.img img { + max-width: 100%; + height: auto; +} +figure.img figcaption { + margin: 0px; + font-size: 90%; + font-style: italic; + text-align: center; +} + +h1 .octicon-link, h2 .octicon-link, h3 .octicon-link, h4 .octicon-link, h5 .octicon-link, h6 .octicon-link { + display: none; + color: #222222; + vertical-align: middle; +} + +h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, h5:hover .anchor, h6:hover .anchor{ + padding-left: 8px; + margin-left: -24px; + text-decoration: none; +} + +h1:hover .anchor .octicon-link, h2:hover .anchor .octicon-link, h3:hover .anchor .octicon-link, h4:hover .anchor .octicon-link, h5:hover .anchor .octicon-link, h6:hover .anchor .octicon-link { + display: inline-block; +} + +body { + padding: 50px; + font: 14px/1.5 "Liberation Sans", Helvetica, Arial, sans-serif; + color: #555555; + background: #eaeaea +} + +h1, h2, h3, h4, h5, h6 { + color: #222222; + margin: 0 0 20px; +} + +p, ul, ol, table, pre, dl { + margin: 0 0 20px; + text-align: justify; +} + +h1, h2, h3 { + line-height: 1.1; +} + +h1 { + font-size: 28px; +} + +h2 { + color: #393939; +} + +h3, h4, h5, h6 { + color: #494949; +} + +a { + color: #3399cc; + font-weight: 350; + text-decoration: none; +} + +a small { + font-size: 11px; + color: #777777; + margin-top: -0.6em; + display: block; +} + +.wrapper { + width: 80%; + margin: 0 auto; +} + +blockquote { + border-left: 1px solid #ffffff; + margin: 0; + padding: 0 0 0 20px; + font-style: italic; +} + +code, pre { + font-family: "Liberation Mono", Monaco, Bitstream Vera Sans Mono, Lucida Console, Terminal; + color: #222222; + font-size: 12px; +} + +pre { + padding: 8px 15px; + border-radius: 5px; + border: 1px solid #e5e5e5; + overflow-x: auto; + overflow-y: auto; +} + +input, select{ + box-sizing: border-box; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 5px 10px; + border-bottom: 1px solid #ffffff; +} + +td { + text-align: justify; +} + +dt { + color: #444444; + font-weight: 700; +} + +th { + text-align: left; + color: #444444; +} + +img { + max-width: 100%; +} + +header { + width: 20%; + float: left; + position: fixed; +} + +header ul { + list-style: none; + height: 40px; + padding: 0; + background: #eeeeee; + border-radius: 5px; + border: 1px solid #d2d2d2; + box-shadow: inset #fff 0 1px 0, inset rgba(0,0,0,0.03) 0 -1px 0; + width: 15%; +} + +header li { + width: 8%; + float: left; + border-right: 1px solid #d2d2d2; + height: 40px; +} + +header ul a { + line-height: 1; + font-size: 11px; + color: #999999; + display: block; + text-align: center; + padding-top: 6px; + height: 40px; +} + +strong { + color: #222222; + font-weight: 700; +} + +header ul li + li { + width: 8%; + border-left: 1px solid #ffffff; +} + +header ul li + li + li { + width: 8%; + border-right: none; +} + +header ul a strong { + font-size: 14px; + display: block; + color: #222222; +} + +section { + width: 70%; + float: right; + padding-bottom: 50px; +} + +small { + font-size: 11px; +} + +hr { + border: 0; + background: #ffffff; + height: 1px; + margin: 0 0 20px; +} + +footer { + width: 20%; + float: left; + position: fixed; + bottom: 50px; +} + +@media print, screen and (max-width: 960px) { + div.wrapper { + width: auto; + margin: 0; + } + header, section, footer { + float: none; + position: static; + width: auto; + } + header { + padding-right: 320px; + } + section { + border: 1px solid #e5e5e5; + border-width: 1px 0; + padding: 20px 0; + margin: 0 0 20px; + } + header a small { + display: inline; + } + header ul { + position: absolute; + right: 50px; + top: 52px; + } +} + +@media print, screen and (max-width: 720px) { + body { + word-wrap: break-word; + } + header { + padding: 0; + } + header ul, header p.view { + position: static; + } + pre, code { + word-wrap: normal; + } +} + +@media print, screen and (max-width: 480px) { + body { + padding: 15px; + } + header ul { + display: none; + } +} + +@media print { + body { + padding: 0.4in; + font-size: 12pt; + color: #444444; + } +} diff --git a/src/main/resources/static/table_export.js b/src/main/resources/static/table_export.js new file mode 100644 index 0000000..aee73c1 --- /dev/null +++ b/src/main/resources/static/table_export.js @@ -0,0 +1,31 @@ +function downloadCsv(csv, filename) { + var csvFile = new Blob([csv], {"type": "text/csv"}); + + var downloadLink = document.createElement("a"); + downloadLink.download = filename; + downloadLink.href = window.URL.createObjectURL(csvFile); + downloadLink.style.display = "none"; + + document.body.appendChild(downloadLink); + downloadLink.click(); +} + +function exportTableToCsv(filename) { + var table = document.getElementById("result"); + var rows = table.getElementsByTagName("tr"); + + var csv = []; + for (var i = 0; i < rows.length; i++) { + if (rows[i].style.display === "none") + continue + var cols = rows[i].querySelectorAll("td, th"); + + var row = []; + for (var j = 0; j < cols.length; j++) + row.push(cols[j].innerText); + + csv.push(row.join(",")); + } + + downloadCsv(csv.join("\n"), filename); +} diff --git a/src/main/resources/static/table_search.js b/src/main/resources/static/table_search.js new file mode 100644 index 0000000..a2eec3b --- /dev/null +++ b/src/main/resources/static/table_search.js @@ -0,0 +1,21 @@ +function searchTable() { + var input = document.getElementById("search"); + var filter = input.value.toLowerCase(); + var table = document.getElementById("result"); + var tr = table.getElementsByTagName("tr"); + + // from 1 coz of header + for (var i = 1; i < tr.length; i++) { + var td = tr[i].getElementsByClassName("include_search"); + var display = "none"; + for (var j = 0; j < td.length; j++) { + if (td[j].tagName.toLowerCase() === "td") { + if (td[j].innerHTML.toLowerCase().indexOf(filter) > -1) { + display = ""; + break; + } + } + } + tr[i].style.display = display; + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala index 6030b43..766f7e4 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/BiSHelper.scala @@ -11,8 +11,8 @@ import scala.concurrent.{ExecutionContext, Future} class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { def addPieceBiS(playerId: PlayerId, piece: Piece) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseBiSHandler.AddPieceToBis(playerId, piece) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseBiSHandler.AddPieceToBis(playerId, piece)).mapTo[Int] def bis(partyId: String, playerId: Option[PlayerId]) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = @@ -23,7 +23,7 @@ class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariy downloadBiS(link, playerId.job).map(_.pieces.map(addPieceBiS(playerId, _))) def removePieceBiS(playerId: PlayerId, piece: Piece) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece)).mapTo[Int] } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala index 5440010..26743f2 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/LootHelper.scala @@ -12,16 +12,16 @@ import scala.concurrent.{ExecutionContext, Future} class LootHelper(storage: ActorRef) { def addPieceLoot(playerId: PlayerId, piece: Piece) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseLootHandler.AddPieceTo(playerId, piece) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseLootHandler.AddPieceTo(playerId, piece)).mapTo[Int] def loot(partyId: String, playerId: Option[PlayerId]) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = (storage ? DatabaseLootHandler.GetLoot(partyId, playerId)).mapTo[Seq[Player]] def removePieceLoot(playerId: PlayerId, piece: Piece) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseLootHandler.RemovePieceFrom(playerId, piece) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseLootHandler.RemovePieceFrom(playerId, piece)).mapTo[Int] def suggestPiece(partyId: String, piece: Piece) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] = diff --git a/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala index 2034e1e..9a156bf 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/PlayerHelper.scala @@ -13,15 +13,16 @@ import scala.util.{Failure, Success} class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) { def addPlayer(player: Player) - (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = - Future { storage ! DatabasePartyHandler.AddPlayer(player) }.andThen { - case Success(_) if player.link.isDefined => - downloadBiS(player.link.get, player.job).map { bis => - bis.pieces.map(storage ! DatabaseBiSHandler.AddPieceToBis(player.playerId, _)) - }.map(_ => ()) - case Success(_) => Future.successful(()) - case Failure(exception) => Future.failed(exception) - } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabasePartyHandler.AddPlayer(player)).mapTo[Int].map { res => + player.link match { + case Some(link) => + downloadBiS(link, player.job).map { bis => + bis.pieces.map(storage ? DatabaseBiSHandler.AddPieceToBis(player.playerId, _)) + }.map(_ => res) + case None => Future.successful(res) + } + }.flatten def getPlayers(partyId: String, maybePlayerId: Option[PlayerId]) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] = @@ -32,6 +33,7 @@ class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(a (storage ? DatabasePartyHandler.GetParty(partyId)).mapTo[Party].map(_.players.values.toSeq) } - def removePlayer(playerId: PlayerId)(implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabasePartyHandler.RemovePlayer(playerId) } + def removePlayer(playerId: PlayerId) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabasePartyHandler.RemovePlayer(playerId)).mapTo[Int] } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala index 17049d9..de1fbd2 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala @@ -1,12 +1,12 @@ package me.arcanis.ffxivbis.http import akka.actor.{ActorRef, ActorSystem} -import akka.http.scaladsl.model._ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.util.Timeout import com.typesafe.scalalogging.StrictLogging -import me.arcanis.ffxivbis.http.api.v1.ApiV1Endpoint +import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint +import me.arcanis.ffxivbis.http.view.RootView class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) extends StrictLogging { @@ -17,7 +17,8 @@ class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) implicit val timeout: Timeout = config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") - private val apiV1Endpoint: ApiV1Endpoint = new ApiV1Endpoint(storage, ariyala) + private val rootApiV1Endpoint: RootApiV1Endpoint = new RootApiV1Endpoint(storage, ariyala) + private val rootView: RootView = new RootView(storage, ariyala) def route: Route = apiRoute ~ htmlRoute ~ Swagger.routes ~ swaggerUIRoute @@ -25,7 +26,7 @@ class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) ignoreTrailingSlash { pathPrefix("api") { pathPrefix(Segment) { - case "v1" => apiV1Endpoint.route + case "v1" => rootApiV1Endpoint.route case _ => reject } } @@ -33,9 +34,9 @@ class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef) private def htmlRoute: Route = ignoreTrailingSlash { - pathEndOrSingleSlash { - complete(StatusCodes.OK) - } + pathPrefix("static") { + getFromResourceDirectory("static") + } ~ rootView.route } private def swaggerUIRoute: Route = diff --git a/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala index 2509fc0..28926ae 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/UserHelper.scala @@ -11,8 +11,8 @@ import scala.concurrent.{ExecutionContext, Future} class UserHelper(storage: ActorRef) { def addUser(user: User, isHashedPassword: Boolean) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseUserHandler.InsertUser(user, isHashedPassword) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseUserHandler.InsertUser(user, isHashedPassword)).mapTo[Int] def user(partyId: String, username: String) (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] = @@ -23,6 +23,6 @@ class UserHelper(storage: ActorRef) { (storage ? DatabaseUserHandler.GetUsers(partyId)).mapTo[Seq[User]] def removeUser(partyId: String, username: String) - (implicit executionContext: ExecutionContext): Future[Unit] = - Future { storage ! DatabaseUserHandler.DeleteUser(partyId, username) } + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] = + (storage ? DatabaseUserHandler.DeleteUser(partyId, username)).mapTo[Int] } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala index a0f4193..b59c67a 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/BiSEndpoint.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.http.api.v1 import akka.actor.ActorRef -import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.util.Timeout @@ -19,7 +19,6 @@ import me.arcanis.ffxivbis.models.PlayerId @Path("api/v1") class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) extends BiSHelper(storage, ariyala) with Authorization with JsonSupport { - import spray.json.DefaultJsonProtocol._ def route: Route = createBiS ~ getBiS ~ modifyBiS @@ -49,7 +48,7 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti put { entity(as[PlayerBiSLinkResponse]) { bisLink => val playerId = bisLink.playerId.withPartyId(partyId) - complete(putBiS(playerId, bisLink.link).map(_ => StatusCodes.Created)) + complete(putBiS(playerId, bisLink.link).map(_ => (StatusCodes.Created, HttpEntity.Empty))) } } } @@ -122,7 +121,7 @@ class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit ti case ApiAction.add => addPieceBiS(playerId, action.piece.toPiece) case ApiAction.remove => removePieceBiS(playerId, action.piece.toPiece) } - result.map(_ => StatusCodes.Accepted) + result.map(_ => (StatusCodes.Accepted, HttpEntity.Empty)) } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala index 3ae6871..6b85b46 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/LootEndpoint.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.http.api.v1 import akka.actor.ActorRef -import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.util.Timeout @@ -19,7 +19,6 @@ import me.arcanis.ffxivbis.models.PlayerId @Path("api/v1") class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) extends LootHelper(storage) with Authorization with JsonSupport { - import spray.json.DefaultJsonProtocol._ def route: Route = getLoot ~ modifyLoot @@ -89,7 +88,7 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) case ApiAction.add => addPieceLoot(playerId, action.piece.toPiece) case ApiAction.remove => removePieceLoot(playerId, action.piece.toPiece) } - result.map(_ => StatusCodes.Accepted) + result.map(_ => (StatusCodes.Accepted, HttpEntity.Empty)) } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala index 693a7ad..fc474bb 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/PlayerEndpoint.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.http.api.v1 import akka.actor.ActorRef -import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.util.Timeout @@ -19,7 +19,6 @@ import me.arcanis.ffxivbis.models.PlayerId @Path("api/v1") class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) extends PlayerHelper(storage, ariyala) with Authorization with JsonSupport { - import spray.json.DefaultJsonProtocol._ def route: Route = getParty ~ modifyParty @@ -88,7 +87,7 @@ class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit case ApiAction.add => addPlayer(player) case ApiAction.remove => removePlayer(player.playerId) } - result.map(_ => StatusCodes.Accepted) + result.map(_ => (StatusCodes.Accepted, HttpEntity.Empty)) } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala similarity index 85% rename from src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala rename to src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala index ba03c61..77835dc 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/ApiV1Endpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/RootApiV1Endpoint.scala @@ -5,7 +5,7 @@ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.util.Timeout -class ApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { +class RootApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { private val biSEndpoint = new BiSEndpoint(storage, ariyala) private val lootEndpoint = new LootEndpoint(storage) diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala index a1d65dc..9e93d6e 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/UserEndpoint.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.http.api.v1 import akka.actor.ActorRef -import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.model.{HttpEntity, StatusCodes} import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server._ import akka.util.Timeout @@ -19,12 +19,11 @@ import me.arcanis.ffxivbis.models.Permission @Path("api/v1") class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) extends UserHelper(storage) with Authorization with JsonSupport { - import spray.json.DefaultJsonProtocol._ def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers @PUT - @Path("party/{partyId}") + @Path("party/{partyId}/create") @Consumes(value = Array("application/json")) @Operation(summary = "create new party", description = "Create new party with specified ID", parameters = Array( @@ -41,13 +40,13 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) tags = Array("party"), ) def createParty: Route = - path("party" / Segment) { partyId: String => + path("party" / Segment / "create") { partyId: String => extractExecutionContext { implicit executionContext => put { entity(as[UserResponse]) { user => val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin) complete { - addUser(admin, isHashedPassword = false).map(_ => StatusCodes.Created) + addUser(admin, isHashedPassword = false).map(_ => (StatusCodes.Created, HttpEntity.Empty)) } } } @@ -81,7 +80,7 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) entity(as[UserResponse]) { user => val withPartyId = user.toUser.copy(partyId = partyId) complete { - addUser(withPartyId, isHashedPassword = false).map(_ => StatusCodes.Created) + addUser(withPartyId, isHashedPassword = false).map(_ => (StatusCodes.Created, HttpEntity.Empty)) } } } @@ -111,7 +110,7 @@ class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout) authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ => delete { complete { - removeUser(partyId, username).map(_ => StatusCodes.Accepted) + removeUser(partyId, username).map(_ => (StatusCodes.Accepted, HttpEntity.Empty)) } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala index 5797168..529afe1 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v1/json/JsonSupport.scala @@ -4,8 +4,7 @@ import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport import me.arcanis.ffxivbis.models.Permission import spray.json._ -trait JsonSupport extends SprayJsonSupport { - import DefaultJsonProtocol._ +trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol { private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] = new RootJsonFormat[E#Value] { diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/BiSView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/BiSView.scala new file mode 100644 index 0000000..e7dd2b0 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/BiSView.scala @@ -0,0 +1,142 @@ +package me.arcanis.ffxivbis.http.view + +import akka.actor.ActorRef +import akka.http.scaladsl.model.StatusCodes +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server._ +import akka.util.Timeout +import me.arcanis.ffxivbis.http.{Authorization, BiSHelper} +import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.Try + +class BiSView(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) + extends BiSHelper(storage, ariyala) with Authorization { + + def route: Route = getBiS ~ modifyBiS + + def getBiS: Route = + path("party" / Segment / "bis") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + get { + complete { + bis(partyId, None).map { players => + BiSView.template(partyId, players, Piece.available, None) + }.map { text => + (StatusCodes.OK, RootView.toHtml(text)) + } + } + } + } + } + } + + def modifyBiS: Route = + path("party" / Segment / "bis") { partyId: String => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + post { + formFields("player".as[String], "piece".as[String].?, "is_tome".as[String].?, "link".as[String].?, "action".as[String]) { + (player, maybePiece, maybeIsTome, maybeLink, action) => + onComplete(modifyBiSCall(partyId, player, maybePiece, maybeIsTome, maybeLink, action)) { + case _ => redirect(s"/party/$partyId/bis", StatusCodes.Found) + } + } + } + } + } + } + + private def modifyBiSCall(partyId: String, player: String, + maybePiece: Option[String], maybeIsTome: Option[String], + maybeLink: Option[String], action: String) + (implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = { + def getPiece(playerId: PlayerId, piece: String) = + Try(Piece(piece, maybeIsTome.isDefined, playerId.job)).toOption + + PlayerId(partyId, player) match { + case Some(playerId) => (maybePiece, action, maybeLink) match { + case (Some(piece), "add", _) => getPiece(playerId, piece) match { + case Some(item) => addPieceBiS(playerId, item).map(_ => ()) + case _ => Future.failed(new Error(s"Could not construct piece from `$piece`")) + } + case (Some(piece), "remove", _) => getPiece(playerId, piece) match { + case Some(item) => removePieceBiS(playerId, item).map(_ => ()) + case _ => Future.failed(new Error(s"Could not construct piece from `$piece`")) + } + case (_, "create", Some(link)) => putBiS(playerId, link).map(_ => ()) + case _ => Future.failed(new Error(s"Could not perform $action")) + } + case _ => Future.failed(new Error(s"Could not construct player id from `$player`")) + } + } +} + +object BiSView { + import scalatags.Text.all._ + + def template(partyId: String, party: Seq[Player], pieces: Seq[String], error: Option[String]): String = { + "" + + html(lang:="en", + head( + title:="Best in slot", + link(rel:="stylesheet", `type`:="text/css", href:="/static/styles.css") + ), + + body( + h2("best in slot"), + + ErrorView.template(error), + SearchLineView.template, + + form(action:=s"/party/$partyId/bis", method:="post")( + select(name:="player", id:="player", title:="player") + (for (player <- party) yield option(player.playerId.toString)), + select(name:="piece", id:="piece", title:="piece") + (for (piece <- pieces) yield option(piece)), + input(name:="is_tome", id:="is_tome", title:="is tome", `type`:="checkbox"), + label(`for`:="is_tome")("is tome gear"), + input(name:="action", id:="action", `type`:="hidden", value:="add"), + input(name:="add", id:="add", `type`:="submit", value:="add") + ), + + form(action:="/bis", method:="post")( + select(name:="player", id:="player", title:="player") + (for (player <- party) yield option(player.playerId.toString)), + input(name:="link", id:="link", placeholder:="player bis link", title:="link", `type`:="text"), + input(name:="action", id:="action", `type`:="hidden", value:="create"), + input(name:="add", id:="add", `type`:="submit", value:="add") + ), + + table( + tr( + th("player"), + th("piece"), + th("is tome"), + th("") + //td(`class`:="include_search") + ), + for (player <- party; piece <- player.bis.pieces) yield tr( + td(`class`:="include_search")(player.playerId.toString), + td(`class`:="include_search")(piece.piece), + td(piece.isTomeToString), + td( + form(action:=s"/party/$partyId/bis", method:="post")( + input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString), + input(name:="piece", id:="piece", `type`:="hidden", value:=piece.piece), + input(name:="is_tome", id:="is_tome", `type`:="hidden", value:=piece.isTomeToString), + input(name:="action", id:="action", `type`:="hidden", value:="remove"), + input(name:="remove", id:="remove", `type`:="submit", value:="x") + ) + ) + ) + ), + + ExportToCSVView.template, + script(src:="/static/table_search.js", `type`:="text/javascript") + ) + ) + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/ErrorView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/ErrorView.scala new file mode 100644 index 0000000..f42dd8d --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/ErrorView.scala @@ -0,0 +1,11 @@ +package me.arcanis.ffxivbis.http.view + +import scalatags.Text +import scalatags.Text.all._ + +object ErrorView { + def template(error: Option[String]): Text.TypedTag[String] = error match { + case Some(text) => p(id:="error", s"Error occurs: $text") + case None => p("") + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/ExportToCSVView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/ExportToCSVView.scala new file mode 100644 index 0000000..3c9f123 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/ExportToCSVView.scala @@ -0,0 +1,12 @@ +package me.arcanis.ffxivbis.http.view + +import scalatags.Text +import scalatags.Text.all._ + +object ExportToCSVView { + def template: Text.TypedTag[String] = + div( + button(onclick:="exportTableToCsv('result.csv')")("Export to CSV"), + script(src:="/static/table_export.js", `type`:="text/javascript") + ) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/RootView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/RootView.scala new file mode 100644 index 0000000..12cbaa1 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/RootView.scala @@ -0,0 +1,20 @@ +package me.arcanis.ffxivbis.http.view + +import akka.actor.ActorRef +import akka.http.scaladsl.model.{ContentTypes, HttpEntity} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.util.Timeout + +class RootView(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) { + + private val biSView = new BiSView(storage, ariyala) + + def route: Route = + biSView.route +} + +object RootView { + def toHtml(template: String): HttpEntity.Strict = + HttpEntity(ContentTypes.`text/html(UTF-8)`, template) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/view/SearchLineView.scala b/src/main/scala/me/arcanis/ffxivbis/http/view/SearchLineView.scala new file mode 100644 index 0000000..492a938 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/view/SearchLineView.scala @@ -0,0 +1,14 @@ +package me.arcanis.ffxivbis.http.view + +import scalatags.Text +import scalatags.Text.all._ + +object SearchLineView { + def template: Text.TypedTag[String] = + div( + input( + `type`:="text", id:="search", onkeyup:="searchTable()", + placeholder:="search for data", title:="search" + ) + ) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala b/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala index e499058..7773b95 100644 --- a/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala +++ b/src/main/scala/me/arcanis/ffxivbis/models/Piece.scala @@ -5,6 +5,7 @@ trait Piece { def job: Job.Job def piece: String + def isTomeToString: String = if (isTome) "yes" else "no" def upgrade: Option[PieceUpgrade] = this match { case _ if !isTome => None case _: Waist => Some(AccessoryUpgrade) @@ -94,4 +95,8 @@ object Piece { case "weapon upgrade" => WeaponUpgrade case other => throw new Error(s"Unknown item type $other") } + + def available: Seq[String] = Seq("weapon", + "head", "body", "hands", "waist", "legs", "feet", + "ears", "neck", "wrist", "leftRing", "rightRing") } diff --git a/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala b/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala index 7155722..e6f1d5d 100644 --- a/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala +++ b/src/main/scala/me/arcanis/ffxivbis/models/PlayerId.scala @@ -1,5 +1,7 @@ package me.arcanis.ffxivbis.models +import scala.util.matching.Regex + trait PlayerIdBase { def job: Job.Job def nick: String @@ -15,4 +17,10 @@ object PlayerId { case (Some(nick), Some(job)) => Some(PlayerId(partyId, Job.fromString(job), nick)) case _ => None } + + private val prettyPlayerIdRegex: Regex = "^(.*) \\(([A-Z]{3})\\)$".r + def apply(partyId: String, player: String): Option[PlayerId] = player match { + case s"${prettyPlayerIdRegex(nick, job)}" => Some(PlayerId(partyId, Job.fromString(job), nick)) + case _ => None + } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala index fad8c69..093dcad 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseBiSHandler.scala @@ -9,7 +9,8 @@ trait DatabaseBiSHandler { this: Database => def bisHandler: Receive = { case AddPieceToBis(playerId, piece) => - profile.insertPieceBiS(playerId, piece) + val client = sender() + profile.insertPieceBiS(playerId, piece).pipeTo(client) case GetBiS(partyId, maybePlayerId) => val client = sender() @@ -18,7 +19,8 @@ trait DatabaseBiSHandler { this: Database => .pipeTo(client) case RemovePieceFromBiS(playerId, piece) => - profile.deletePieceBiS(playerId, piece) + val client = sender() + profile.deletePieceBiS(playerId, piece).pipeTo(client) } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala index d976e40..d8062a9 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseLootHandler.scala @@ -9,7 +9,8 @@ trait DatabaseLootHandler { this: Database => def lootHandler: Receive = { case AddPieceTo(playerId, piece) => - profile.insertPiece(playerId, piece) + val client = sender() + profile.insertPiece(playerId, piece).pipeTo(client) case GetLoot(partyId, maybePlayerId) => val client = sender() @@ -18,7 +19,8 @@ trait DatabaseLootHandler { this: Database => .pipeTo(client) case RemovePieceFrom(playerId, piece) => - profile.deletePiece(playerId, piece) + val client = sender() + profile.deletePiece(playerId, piece).pipeTo(client) case SuggestLoot(partyId, piece) => val client = sender() diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala index a78e958..4dcce8a 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabasePartyHandler.scala @@ -11,7 +11,8 @@ trait DatabasePartyHandler { this: Actor with StrictLogging with Database => def partyHandler: Receive = { case AddPlayer(player) => - profile.insertPlayer(player) + val client = sender() + profile.insertPlayer(player).pipeTo(client) case GetParty(partyId) => val client = sender() @@ -27,7 +28,8 @@ trait DatabasePartyHandler { this: Actor with StrictLogging with Database => player.pipeTo(client) case RemovePlayer(playerId) => - profile.deletePlayer(playerId) + val client = sender() + profile.deletePlayer(playerId).pipeTo(client) } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala index 29fe75f..22e1f21 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/impl/DatabaseUserHandler.scala @@ -9,7 +9,8 @@ trait DatabaseUserHandler { this: Database => def userHandler: Receive = { case DeleteUser(partyId, username) => - profile.deleteUser(partyId, username) + val client = sender() + profile.deleteUser(partyId, username).pipeTo(client) case GetUser(partyId, username) => val client = sender() @@ -20,8 +21,9 @@ trait DatabaseUserHandler { this: Database => profile.getUsers(partyId).pipeTo(client) case InsertUser(user, isHashedPassword) => + val client = sender() val toInsert = if (isHashedPassword) user else user.copy(password = user.hash) - profile.insertUser(toInsert) + profile.insertUser(toInsert).pipeTo(client) } } diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala index c81248a..c0e71c7 100644 --- a/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala +++ b/src/main/scala/me/arcanis/ffxivbis/storage/BiSProfile.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.storage import me.arcanis.ffxivbis.models.{Job, Loot, Piece} -import slick.lifted.{ForeignKeyQuery, Index} +import slick.lifted.{ForeignKeyQuery, Index, PrimaryKey} import scala.concurrent.Future @@ -18,9 +18,9 @@ trait BiSProfile { this: DatabaseProfile => } class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") { - def playerId: Rep[Long] = column[Long]("player_id") + def playerId: Rep[Long] = column[Long]("player_id", O.PrimaryKey) def created: Rep[Long] = column[Long]("created") - def piece: Rep[String] = column[String]("piece") + def piece: Rep[String] = column[String]("piece", O.PrimaryKey) def isTome: Rep[Int] = column[Int]("is_tome") def job: Rep[String] = column[String]("job") @@ -29,8 +29,6 @@ trait BiSProfile { this: DatabaseProfile => def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] = foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade) - def bisPiecePlayerIdIdx: Index = - index("bis_piece_player_id_idx", (playerId, piece), unique = true) } def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] = diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala index e41c893..187b74c 100644 --- a/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala +++ b/src/main/scala/me/arcanis/ffxivbis/storage/PlayersProfile.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.storage import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId} -import slick.lifted.Index +import slick.lifted.{Index, PrimaryKey} import scala.concurrent.Future @@ -14,8 +14,8 @@ trait PlayersProfile { this: DatabaseProfile => Player(partyId, Job.fromString(job), nick, BiS(Seq.empty), List.empty, link, priority) } object PlayerRep { - def fromPlayer(player: Player): PlayerRep = - PlayerRep(player.partyId, None, DatabaseProfile.now, player.nick, + def fromPlayer(player: Player, id: Option[Long]): PlayerRep = + PlayerRep(player.partyId, id, DatabaseProfile.now, player.nick, player.job.toString, player.link, player.priority) } @@ -30,11 +30,9 @@ trait PlayersProfile { this: DatabaseProfile => def * = (partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply) - - def playersNickJobIdx: Index = - index("players_nick_job_idx", (partyId, nick, job), unique = true) } + def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete) def getParty(partyId: String): Future[Map[Long, Player]] = db.run(players(partyId).result).map(_.foldLeft(Map.empty[Long, Player]) { @@ -45,8 +43,11 @@ trait PlayersProfile { this: DatabaseProfile => db.run(player(playerId).map(_.playerId).result.headOption) def getPlayers(partyId: String): Future[Seq[Long]] = db.run(players(partyId).map(_.playerId).result) - def insertPlayer(player: Player): Future[Int] = - db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(player))) + def insertPlayer(playerObj: Player): Future[Int] = + getPlayer(playerObj.playerId).map { + case Some(id) => db.run(playersTable.update(PlayerRep.fromPlayer(playerObj, Some(id)))) + case _ => db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(playerObj, None))) + }.flatten private def player(playerId: PlayerId) = playersTable diff --git a/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala index b720149..89ba512 100644 --- a/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala +++ b/src/main/scala/me/arcanis/ffxivbis/storage/UsersProfile.scala @@ -1,7 +1,7 @@ package me.arcanis.ffxivbis.storage import me.arcanis.ffxivbis.models.{Permission, User} -import slick.lifted.Index +import slick.lifted.{Index, PrimaryKey} import scala.concurrent.Future @@ -13,7 +13,7 @@ trait UsersProfile { this: DatabaseProfile => def toUser: User = User(partyId, username, password, Permission.withName(permission)) } object UserRep { - def fromUser(user: User): UserRep = + def fromUser(user: User, id: Option[Long]): UserRep = UserRep(user.partyId, None, user.username, user.password, user.permission.toString) } @@ -27,6 +27,7 @@ trait UsersProfile { this: DatabaseProfile => def * = (partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply) + def pk: PrimaryKey = primaryKey("users_username_idx", (partyId, username)) def usersUsernameIdx: Index = index("users_username_idx", (partyId, username), unique = true) } @@ -37,9 +38,11 @@ trait UsersProfile { this: DatabaseProfile => db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser)) def getUsers(partyId: String): Future[Seq[User]] = db.run(user(partyId, None).result).map(_.map(_.toUser)) - def insertUser(user: User): Future[Int] = { - db.run(usersTable.insertOrUpdate(UserRep.fromUser(user))) - } + def insertUser(userObj: User): Future[Int] = + db.run(user(userObj.partyId, Some(userObj.username)).result.headOption).map { + case Some(user) => db.run(usersTable.update(UserRep.fromUser(userObj, user.userId))) + case _ => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, None))) + }.flatten private def user(partyId: String, username: Option[String]) = usersTable