18 Commits

Author SHA1 Message Date
feea01a47e Release 0.12.2 2022-01-20 00:53:32 +03:00
fcacd9f15c user friednly is required table 2022-01-19 15:09:45 +03:00
b2256784dd move header buttons into one row 2022-01-19 13:42:43 +03:00
fee87ddbc8 more typed actors 2022-01-19 12:19:55 +03:00
dc882b74bf move modals to form validation 2022-01-19 03:04:59 +03:00
7a6cd84ce3 Release 0.12.1 2022-01-17 22:35:50 +03:00
33b750123d disable covreport coz it breaks the dist 2022-01-17 22:34:26 +03:00
d049238dcf Release 0.12.0 2022-01-17 22:28:13 +03:00
5d72852420 add status endpoint 2022-01-17 22:26:48 +03:00
78a00e2cab sbt improvelemnts 2022-01-17 12:17:39 +03:00
786c3d7d48 Release 0.11.1 2022-01-17 05:21:11 +03:00
8a1d99b319 change sorting order 2022-01-17 05:19:56 +03:00
ac0e0ac899 Release 0.11.0 2022-01-17 05:13:16 +03:00
e88c9d51b0 update description 2022-01-17 05:12:11 +03:00
ced781bba2 migrate to anorm
I'm tired of ORM and would like to write clear sql requests. The
following wrappers were checked:
* doobie - cats api which is useless in this project
* scalike - can't work with sqlite at all
* anorm - awful api
* something also

Anorm fits more than any other my criteria so I migrated to it with
native hikaricp usage
2022-01-17 05:10:01 +03:00
012cdd2d8b log exceptions for database requests 2022-01-16 15:15:48 +03:00
c5b0832d29 Release 0.10.1 2022-01-15 23:22:50 +03:00
b36240765a change job requirements 2022-01-15 23:21:42 +03:00
62 changed files with 838 additions and 572 deletions

1
.gitignore vendored
View File

@ -75,6 +75,7 @@ lib_managed/
src_managed/ src_managed/
project/boot/ project/boot/
project/plugins/project/ project/plugins/project/
.bsp/
# Scala-IDE specific # Scala-IDE specific
.scala_dependencies .scala_dependencies

View File

@ -15,10 +15,10 @@ compile: clean
format: format:
sbt scalafmt sbt scalafmt
dist: tests version dist: tests
sbt dist sbt dist
push: dist push: version dist
git add version.sbt git add version.sbt
git commit -m "Release $(VERSION)" git commit -m "Release $(VERSION)"
git tag "$(VERSION)" git tag "$(VERSION)"

View File

@ -22,10 +22,10 @@ from the extracted archive root.
## Web service ## Web service
REST API documentation is available at `http://0.0.0.0:8000/swagger`. HTML representation is available at `http://0.0.0.0:8000`. REST API documentation is available at `http://0.0.0.0:8000/api-docs`. HTML representation is available at `http://0.0.0.0:8000`.
*Note*: host and port depend on configuration settings. *Note*: host and port depend on configuration settings.
## Public service ## Public service
There is also public service which is available at http://ffxivbis.arcanis.me. There is also public service which is available at https://ffxivbis.arcanis.me.

View File

@ -1,3 +1,5 @@
organization := "me.arcanis"
name := "ffxivbis" name := "ffxivbis"
scalaVersion := "2.13.6" scalaVersion := "2.13.6"
@ -5,12 +7,3 @@ scalaVersion := "2.13.6"
scalacOptions ++= Seq("-deprecation", "-feature") scalacOptions ++= Seq("-deprecation", "-feature")
enablePlugins(JavaAppPackaging) enablePlugins(JavaAppPackaging)
assemblyMergeStrategy in assembly := {
case PathList("META-INF", xs @ _*) => MergeStrategy.discard
case "application.conf" => MergeStrategy.concat
case "module-info.class" => MergeStrategy.first
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}

View File

@ -1,9 +1,9 @@
val AkkaVersion = "2.6.17" val AkkaVersion = "2.6.18"
val AkkaHttpVersion = "10.2.7" val AkkaHttpVersion = "10.2.7"
val ScalaTestVersion = "3.2.10" val ScalaTestVersion = "3.2.10"
val SlickVersion = "3.3.3" val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.9" libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.10"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.4"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
@ -15,16 +15,15 @@ libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6" libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "com.typesafe.slick" %% "slick" % SlickVersion libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.6.10"
libraryDependencies += "com.typesafe.slick" %% "slick-hikaricp" % SlickVersion libraryDependencies += "com.zaxxer" % "HikariCP" % "5.0.1" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "8.2.2" libraryDependencies += "org.flywaydb" % "flyway-core" % "8.4.1"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3" libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.36.0.3"
libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1" libraryDependencies += "org.postgresql" % "postgresql" % "42.3.1"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4" libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre" libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"
// testing // testing
libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test" libraryDependencies += "org.scalactic" %% "scalactic" % ScalaTestVersion % "test"
libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test" libraryDependencies += "org.scalatest" %% "scalatest" % ScalaTestVersion % "test"

View File

@ -1 +1 @@
sbt.version = 1.3.3 sbt.version = 1.5.8

View File

@ -1,4 +1,4 @@
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.3")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4") addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.4")
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.10.0-RC1") addDependencyTreePlugin

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -0,0 +1,2 @@
drop index bis_piece_type_player_id_idx;
create unique index bis_piece_type_player_id_idx on bis(player_id, piece, piece_type);

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -84,23 +86,24 @@
<div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="update-bis-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<form class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="updateBis()">
<div class="modal-header"> <div class="modal-header form-group row">
<div class="btn-group" role="group" aria-label="Update bis"> <div class="btn-group" role="group" aria-label="Update bis">
<input id="add-piece-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off" checked> <input id="add-piece-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off" checked>
<label class="btn btn-outline-primary" for="add-piece-btn">add piece</label> <label class="btn btn-outline-primary" for="add-piece-btn">add piece</label>
<input id="update-bis-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off"> <input id="update-bis-btn" name="update-bis" type="radio" class="btn-check" autocomplete="off">
<label class="btn btn-outline-primary" for="update-bis-btn">update bis</label> <label class="btn btn-outline-primary" for="update-bis-btn">update bis</label>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label> <label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player"></select> <select id="player" name="player" class="form-control" title="player" required></select>
</div> </div>
</div> </div>
<div id="piece-row" class="form-group row"> <div id="piece-row" class="form-group row">
@ -118,14 +121,14 @@
<div id="bis-link-row" class="form-group row" style="display: none"> <div id="bis-link-row" class="form-group row" style="display: none">
<label class="col-sm-4 col-form-label" for="bis-link">link</label> <label class="col-sm-4 col-form-label" for="bis-link">link</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="bis-link" name="link" class="form-control" placeholder="link to bis" onkeyup="disableSubmitBisButton()"> <input id="bis-link" name="link" class="form-control" placeholder="link to bis">
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-add-bis-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPiece()" disabled>add</button> <button id="submit-add-bis-btn" type="submit" class="btn btn-primary">add</button>
<button id="submit-update-bis-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="setBis()" style="display: none" disabled>set</button> <button id="submit-set-bis-btn" type="submit" class="btn btn-primary" style="display: none">set</button>
</div> </div>
</div> </div>
</form> </form>
@ -169,7 +172,7 @@
const updateButton = $("#update-btn"); const updateButton = $("#update-btn");
const submitAddBisButton = $("#submit-add-bis-btn"); const submitAddBisButton = $("#submit-add-bis-btn");
const submitUpdateBisButton = $("#submit-update-bis-btn"); const submitSetBisButton = $("#submit-set-bis-btn");
const updateBisDialog = $("#update-bis-dialog"); const updateBisDialog = $("#update-bis-dialog");
const addPieceButton = $("#add-piece-btn"); const addPieceButton = $("#add-piece-btn");
@ -206,12 +209,8 @@
success: function (_) { reload(); }, success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
} updateBisDialog.modal("hide");
return true; // action expects boolean result
function disableSubmitBisButton() {
const nonEmpty = (playerInput.val() !== null); // well lol
submitUpdateBisButton.attr("disabled", !(nonEmpty && linkInput.val()));
submitAddBisButton.attr("disabled", !(nonEmpty));
} }
function hideControls() { function hideControls() {
@ -220,20 +219,24 @@
} }
function hideLinkPart() { function hideLinkPart() {
disableSubmitBisButton();
bisLinkRow.hide(); bisLinkRow.hide();
submitUpdateBisButton.hide(); linkInput.prop("required", false);
submitSetBisButton.hide();
pieceRow.show(); pieceRow.show();
pieceTypeRow.show(); pieceTypeRow.show();
pieceInput.prop("required", true);
pieceTypeInput.prop("required", true);
submitAddBisButton.show(); submitAddBisButton.show();
} }
function hidePiecePart() { function hidePiecePart() {
disableSubmitBisButton();
bisLinkRow.show(); bisLinkRow.show();
submitUpdateBisButton.show(); linkInput.prop("required", true);
submitSetBisButton.show();
pieceRow.hide(); pieceRow.hide();
pieceTypeRow.hide(); pieceTypeRow.hide();
pieceInput.prop("required", false);
pieceTypeInput.prop("required", false);
submitAddBisButton.hide(); submitAddBisButton.hide();
} }
@ -267,7 +270,6 @@
return option; return option;
}); });
playerInput.empty().append(options); playerInput.empty().append(options);
disableSubmitBisButton();
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
@ -325,6 +327,18 @@
success: function (_) { reload(); }, success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
updateBisDialog.modal("hide");
return true; // action expects boolean result
}
function updateBis() {
if (updateBisButton.is(":checked")) {
return setBis();
}
if (addPieceButton.is(":checked")) {
return addPiece();
}
return false; // should not happen
} }
$(function () { $(function () {
@ -342,6 +356,7 @@
table.bootstrapTable({}); table.bootstrapTable({});
reload(); reload();
reset();
}); });
</script> </script>

View File

@ -6,11 +6,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="timestamp"
data-sort-order="desc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -86,35 +88,44 @@
<div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-loot-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog modal-lg" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addLoot()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add looted piece</h4> <h4 class="modal-title">add looted piece</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="player">player</label> <label class="col-sm-4 col-form-label" for="player">player</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="player" name="player" class="form-control" title="player"></select> <select id="player" name="player" class="form-control" title="player" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece">piece</label> <label class="col-sm-4 col-form-label" for="piece">piece</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="piece" name="piece" class="form-control" title="piece"></select> <select id="piece" name="piece" class="form-control" title="piece" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="piece-type">piece type</label> <label class="col-sm-4 col-form-label" for="piece-type">piece type</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="piece-type" name="pieceType" class="form-control" title="pieceType"></select> <select id="piece-type" name="pieceType" class="form-control" title="pieceType" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">job</label> <label class="col-sm-4 col-form-label" for="job">job</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job"></select> <select id="job" name="job" class="form-control" title="job" required></select>
</div>
</div>
<div class="form-group row">
<div class="col-sm-4"></div>
<div class="col-sm-8">
<div class="form-check">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
</div> </div>
</div> </div>
@ -130,18 +141,14 @@
</tr> </tr>
</thead> </thead>
</table> </table>
</form> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="form-check form-switch">
<input id="free-loot" name="freeLoot" type="checkbox" class="form-check-input">
<label class="form-check-label" for="free-loot">as free loot</label>
</div>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button> <button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addLoot()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div> </div>
</div> </form>
</div> </div>
</div> </div>
@ -182,7 +189,6 @@
const addButton = $("#add-btn"); const addButton = $("#add-btn");
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const submitLootButton = $("#submit-btn");
const addLootDialog = $("#add-loot-dialog"); const addLootDialog = $("#add-loot-dialog");
const freeLootInput = $("#free-loot"); const freeLootInput = $("#free-loot");
@ -214,6 +220,8 @@
success: function (_) { reload(); }, success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
addLootDialog.modal("hide");
return true; // action expects boolean result
} }
function hideControls() { function hideControls() {
@ -253,7 +261,6 @@
return option; return option;
}); });
playerInput.empty().append(options); playerInput.empty().append(options);
submitLootButton.attr("disabled", options.length === 0);
}, },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
@ -303,7 +310,7 @@
return { return {
nick: stat.nick, nick: stat.nick,
job: stat.job, job: stat.job,
isRequired: stat.isRequired, isRequired: stat.isRequired ? "yes" : "no",
lootCount: stat.lootCount, lootCount: stat.lootCount,
lootCountBiS: stat.lootCountBiS, lootCountBiS: stat.lootCountBiS,
lootCountTotal: stat.lootCountTotal, lootCountTotal: stat.lootCountTotal,

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -64,6 +64,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="nick"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -82,23 +84,23 @@
<div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-player-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addPlayer()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add new player</h4> <h4 class="modal-title">add new player</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="nick">player name</label> <label class="col-sm-4 col-form-label" for="nick">player name</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="nick" name="nick" class="form-control" placeholder="nick" onkeyup="disableAddPlayerForm()"> <input id="nick" name="nick" class="form-control" placeholder="nick" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="job">player job</label> <label class="col-sm-4 col-form-label" for="job">player job</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="job" name="job" class="form-control" title="job"></select> <select id="job" name="job" class="form-control" title="job" required></select>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
@ -113,13 +115,13 @@
<input id="priority" name="priority" type="number" class="form-control" value="0"> <input id="priority" name="priority" type="number" class="form-control" value="0">
</div> </div>
</div> </div>
</form> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-player-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addPlayer()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div> </div>
</div> </form>
</div> </div>
</div> </div>
@ -160,7 +162,6 @@
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const addPlayerDialog = $("#add-player-dialog"); const addPlayerDialog = $("#add-player-dialog");
const submitPlayerButton = $("#submit-player-btn");
const jobInput = $("#job"); const jobInput = $("#job");
const linkInput = $("#link"); const linkInput = $("#link");
@ -185,6 +186,8 @@
success: function (_) { reload(); }, success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
addPlayerDialog.modal("hide");
return true; // action expects boolean result
} }
function bisLinkFormatter(link, row) { function bisLinkFormatter(link, row) {
@ -195,10 +198,6 @@
} }
} }
function disableAddPlayerForm() {
submitPlayerButton.attr("disabled", !nickInput.val());
}
function hideControls() { function hideControls() {
addButton.attr("hidden", isReadOnly); addButton.attr("hidden", isReadOnly);
removeButton.attr("hidden", isReadOnly); removeButton.attr("hidden", isReadOnly);

View File

@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" type="text/css">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
</head> </head>
<body> <body>

View File

@ -6,17 +6,17 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="/static/favicon.ico" rel="shortcut icon"> <link rel="shortcut icon" href="/static/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" type="text/css">
<link href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.19.1/dist/bootstrap-table.min.css" type="text/css">
<link href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="https://unpkg.com/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" type="text/css">
<link href="/static/styles.css" rel="stylesheet" type="text/css"> <link rel="stylesheet" href="/static/styles.css" type="text/css">
</head> </head>
<body> <body>
@ -68,6 +68,8 @@
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-single-select="true" data-single-select="true"
data-sortable="true" data-sortable="true"
data-sort-name="username"
data-sort-order="asc"
data-sort-reset="true" data-sort-reset="true"
data-toolbar="#toolbar"> data-toolbar="#toolbar">
<thead class="table-primary"> <thead class="table-primary">
@ -82,38 +84,38 @@
<div id="add-user-dialog" tabindex="-1" role="dialog" class="modal fade"> <div id="add-user-dialog" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <form class="modal-content" action="javascript:" onsubmit="addUser()">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title">add new user</h4> <h4 class="modal-title">add new user</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<form class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="username">login</label> <label class="col-sm-4 col-form-label" for="username">login</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="username" name="username" class="form-control" placeholder="username" onkeyup="disableAddUserForm()"> <input id="username" name="username" class="form-control" placeholder="username" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="password">password</label> <label class="col-sm-4 col-form-label" for="password">password</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input id="password" name="password" type="password" class="form-control" placeholder="password" onkeyup="disableAddUserForm()"> <input id="password" name="password" type="password" class="form-control" placeholder="password" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label class="col-sm-4 col-form-label" for="permission">permission</label> <label class="col-sm-4 col-form-label" for="permission">permission</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select id="permission" name="permission" class="form-control" title="permission"></select> <select id="permission" name="permission" class="form-control" title="permission" required></select>
</div> </div>
</div> </div>
</form> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button id="submit-btn" type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="addUser()" disabled>add</button> <button type="submit" class="btn btn-primary">add</button>
</div> </div>
</div> </form>
</div> </div>
</div> </div>
@ -154,7 +156,6 @@
const removeButton = $("#remove-btn"); const removeButton = $("#remove-btn");
const addUserDialog = $("#add-user-dialog"); const addUserDialog = $("#add-user-dialog");
const submitUserButton = $("#submit-btn");
const usernameInput = $("#username"); const usernameInput = $("#username");
const passwordInput = $("#password"); const passwordInput = $("#password");
@ -174,10 +175,8 @@
success: function (_) { reload(); }, success: function (_) { reload(); },
error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); }, error: function (jqXHR, _, errorThrown) { requestAlert(jqXHR, errorThrown); },
}); });
} addUserDialog.modal("hide");
return true; // action expects boolean result
function disableAddUserForm() {
submitUserButton.attr("disabled", !(usernameInput.val() && passwordInput.val()));
} }
function hideControls() { function hideControls() {

View File

@ -3,15 +3,14 @@
<include resource="logback-application.xml" /> <include resource="logback-application.xml" />
<include resource="logback-http.xml" /> <include resource="logback-http.xml" />
<root level="debug"> <root level="DEBUG">
<appender-ref ref="application" /> <appender-ref ref="application" />
</root> </root>
<logger name="me.arcanis.ffxivbis" level="DEBUG" />
<logger name="http" level="DEBUG" additivity="false"> <logger name="http" level="DEBUG" additivity="false">
<appender-ref ref="http" /> <appender-ref ref="http" />
</logger> </logger>
<logger name="slick" level="INFO" />
<logger name="org.flywaydb.core.internal" level="INFO" /> <logger name="org.flywaydb.core.internal" level="INFO" />
<logger name="com.zaxxer.hikari.pool" level="INFO" />
</configuration> </configuration>

View File

@ -15,26 +15,17 @@ me.arcanis.ffxivbis {
mode = "sqlite" mode = "sqlite"
sqlite { sqlite {
profile = "slick.jdbc.SQLiteProfile$" driverClassName = "org.sqlite.JDBC"
db { jdbcUrl = "jdbc:sqlite:ffxivbis.db"
url = "jdbc:sqlite:ffxivbis.db" #username = "user"
#user = "user" #password = "password"
#password = "password"
}
numThreads = 10
} }
postgresql { postgresql {
profile = "slick.jdbc.PostgresProfile$" driverClassName = "org.postgresql.Driver"
db { jdbcUrl = "jdbc:postgresql://localhost/ffxivbis"
url = "jdbc:postgresql://localhost/ffxivbis" #username = "ffxivbis"
#user = "ffxivbis" #password = "ffxivbis"
#password = "ffxivbis"
connectionPool = disabled
keepAliveConnection = yes
}
numThreads = 10
} }
} }
@ -66,14 +57,14 @@ me.arcanis.ffxivbis {
# ttl of cached logins # ttl of cached logins
cache-timeout = 1m cache-timeout = 1m
} }
}
default-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 16
} }
throughput = 1
} default-dispatcher {
type = Dispatcher
executor = "thread-pool-executor"
thread-pool-executor {
fixed-pool-size = 16
}
throughput = 1
}
} }

View File

@ -37,7 +37,7 @@ function loadTypes(url, selector) {
} }
function setupFormClear(dialog, reset) { function setupFormClear(dialog, reset) {
dialog.on("shown.bs.modal", function () { dialog.on("hide.bs.modal", function () {
$(this).find("form").trigger("reset"); $(this).find("form").trigger("reset");
$(this).find("table").bootstrapTable("removeAll"); $(this).find("table").bootstrapTable("removeAll");
if (reset) { if (reset) {

View File

@ -1,4 +1,4 @@
REST json API description to interact with FFXIVBiS service. REST json API description to interact with FFXIV Best-in-slot service.
# Basic workflow # Basic workflow

View File

@ -17,8 +17,7 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.ExecutionContext import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._

View File

@ -13,8 +13,8 @@ import akka.http.scaladsl.server.Directive0
import akka.http.scaladsl.server.Directives.{extractClientIP, extractRequestContext, mapResponse, optionalHeaderValueByType} import akka.http.scaladsl.server.Directives.{extractClientIP, extractRequestContext, mapResponse, optionalHeaderValueByType}
import com.typesafe.scalalogging.Logger import com.typesafe.scalalogging.Logger
import java.time.{Instant, ZoneId}
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.{Instant, ZoneId}
import java.util.Locale import java.util.Locale
trait HttpLog { trait HttpLog {
@ -68,7 +68,7 @@ object HttpLog {
val httpLogDatetimeFormatter: DateTimeFormatter = val httpLogDatetimeFormatter: DateTimeFormatter =
DateTimeFormatter DateTimeFormatter
.ofPattern("dd/MMM/uuuu:HH:mm:ss xx ") .ofPattern("dd/MMM/uuuu:HH:mm:ss xx")
.withLocale(Locale.UK) .withLocale(Locale.UK)
.withZone(ZoneId.systemDefault()) .withZone(ZoneId.systemDefault())
} }

View File

@ -25,7 +25,7 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro
private val config = system.settings.config private val config = system.settings.config
implicit val scheduler: Scheduler = system.scheduler implicit val scheduler: Scheduler = system.scheduler
implicit val timeout: Timeout = config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") implicit val timeout: Timeout = config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
private val auth = AuthorizationProvider(config, storage, timeout, scheduler) private val auth = AuthorizationProvider(config, storage, timeout, scheduler)

View File

@ -22,6 +22,7 @@ class Swagger(config: Config) extends SwaggerHttpService {
classOf[api.v1.LootEndpoint], classOf[api.v1.LootEndpoint],
classOf[api.v1.PartyEndpoint], classOf[api.v1.PartyEndpoint],
classOf[api.v1.PlayerEndpoint], classOf[api.v1.PlayerEndpoint],
classOf[api.v1.StatusEndpoint],
classOf[api.v1.TypesEndpoint], classOf[api.v1.TypesEndpoint],
classOf[api.v1.UserEndpoint] classOf[api.v1.UserEndpoint]
) )
@ -35,7 +36,7 @@ class Swagger(config: Config) extends SwaggerHttpService {
override val host: String = override val host: String =
if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname") if (config.hasPath("me.arcanis.ffxivbis.web.hostname")) config.getString("me.arcanis.ffxivbis.web.hostname")
else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getString("me.arcanis.ffxivbis.web.port")}" else s"${config.getString("me.arcanis.ffxivbis.web.host")}:${config.getInt("me.arcanis.ffxivbis.web.port")}"
private val basicAuth = new SecurityScheme() private val basicAuth = new SecurityScheme()
.description("basic http auth") .description("basic http auth")

View File

@ -32,14 +32,15 @@ class RootApiV1Endpoint(
private val lootEndpoint = new LootEndpoint(storage, auth) private val lootEndpoint = new LootEndpoint(storage, auth)
private val partyEndpoint = new PartyEndpoint(storage, provider, auth) private val partyEndpoint = new PartyEndpoint(storage, provider, auth)
private val playerEndpoint = new PlayerEndpoint(storage, provider, auth) private val playerEndpoint = new PlayerEndpoint(storage, provider, auth)
private val statusEndpoint = new StatusEndpoint
private val typesEndpoint = new TypesEndpoint(config) private val typesEndpoint = new TypesEndpoint(config)
private val userEndpoint = new UserEndpoint(storage, auth) private val userEndpoint = new UserEndpoint(storage, auth)
def route: Route = def route: Route =
handleExceptions(exceptionHandler) { handleExceptions(exceptionHandler) {
handleRejections(rejectionHandler) { handleRejections(rejectionHandler) {
biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~ biSEndpoint.route ~ lootEndpoint.route ~ partyEndpoint.route ~ playerEndpoint.route ~
playerEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route statusEndpoint.route ~ typesEndpoint.route ~ userEndpoint.route
} }
} }
} }

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.{Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.ws.rs._
import me.arcanis.ffxivbis.http.api.v1.json._
@Path("/api/v1")
class StatusEndpoint extends JsonSupport {
def route: Route = getServerStatus
@GET
@Path("status")
@Produces(value = Array("application/json"))
@Operation(
summary = "server status",
description = "Returns the server status descriptor",
responses = Array(
new ApiResponse(
responseCode = "200",
description = "Service status descriptor",
content = Array(new Content(schema = new Schema(implementation = classOf[StatusModel])))
),
new ApiResponse(
responseCode = "500",
description = "Internal server error",
content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel])))
),
),
tags = Array("status"),
)
def getServerStatus: Route =
path("status") {
get {
complete {
StatusModel(
version = Option(getClass.getPackage.getImplementationVersion),
)
}
}
}
}

View File

@ -52,5 +52,6 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkModel] = jsonFormat2(PlayerBiSLinkModel.apply) implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkModel] = jsonFormat2(PlayerBiSLinkModel.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersModel] = implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersModel] =
jsonFormat9(PlayerIdWithCountersModel.apply) jsonFormat9(PlayerIdWithCountersModel.apply)
implicit val statusFormat: RootJsonFormat[StatusModel] = jsonFormat1(StatusModel.apply)
implicit val userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply) implicit val userFormat: RootJsonFormat[UserModel] = jsonFormat4(UserModel.apply)
} }

View File

@ -0,0 +1,13 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class StatusModel(@Schema(description = "server version") version: Option[String])

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
@ -25,8 +33,8 @@ trait LootHelper {
): Future[Unit] = ): Future[Unit] =
(action, maybeFree) match { (action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot) case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece) case (ApiAction.remove, Some(isFreeLoot)) => removePieceLoot(playerId, piece, isFreeLoot)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree") case _ => throw new IllegalArgumentException("Loot modification must always contain `isFreeLoot` field")
} }
def loot(partyId: String, playerId: Option[PlayerId])(implicit def loot(partyId: String, playerId: Option[PlayerId])(implicit
@ -35,8 +43,11 @@ trait LootHelper {
): Future[Seq[Player]] = ): Future[Seq[Player]] =
storage.ask(GetLoot(partyId, playerId, _)) storage.ask(GetLoot(partyId, playerId, _))
def removePieceLoot(playerId: PlayerId, piece: Piece)(implicit timeout: Timeout, scheduler: Scheduler): Future[Unit] = def removePieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)(implicit
storage.ask(RemovePieceFrom(playerId, piece, _)) timeout: Timeout,
scheduler: Scheduler
): Future[Unit] =
storage.ask(RemovePieceFrom(playerId, piece, isFreeLoot, _))
def suggestPiece(partyId: String, piece: Piece)(implicit def suggestPiece(partyId: String, piece: Piece)(implicit
executionContext: ExecutionContext, executionContext: ExecutionContext,

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -1,3 +1,11 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.http.helpers package me.arcanis.ffxivbis.http.helpers
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable

View File

@ -51,7 +51,8 @@ case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, rep
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage extends LootDatabaseMessage
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends LootDatabaseMessage { case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage {
override def partyId: String = playerId.partyId override def partyId: String = playerId.partyId
} }

View File

@ -13,4 +13,6 @@ import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) { case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no" def isFreeLootToString: String = if (isFreeLoot) "yes" else "no"
def isFreeLootToInt: Int = if (isFreeLoot) 1 else 0
} }

View File

@ -24,14 +24,14 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
with StrictLogging { with StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._ import me.arcanis.ffxivbis.utils.Implicits._
private val cacheTimeout: FiniteDuration = private val cacheTimeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.cache-timeout") context.system.settings.config.getFiniteDuration("me.arcanis.ffxivbis.settings.cache-timeout")
implicit private val executionContext: ExecutionContext = { implicit private val executionContext: ExecutionContext = {
val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher") val selector = DispatcherSelector.fromConfig("me.arcanis.ffxivbis.default-dispatcher")
context.system.dispatchers.lookup(selector) context.system.dispatchers.lookup(selector)
} }
implicit private val timeout: Timeout = implicit private val timeout: Timeout =
context.system.settings.config.getDuration("me.arcanis.ffxivbis.settings.request-timeout") context.system.settings.config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
implicit private val scheduler: Scheduler = context.system.scheduler implicit private val scheduler: Scheduler = context.system.scheduler
override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg) override def onMessage(msg: Message): Behavior[Message] = handle(Map.empty)(msg)

View File

@ -18,11 +18,14 @@ import me.arcanis.ffxivbis.service.database.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.DatabaseProfile import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
trait Database extends StrictLogging { trait Database extends StrictLogging {
implicit def executionContext: ExecutionContext implicit def executionContext: ExecutionContext
def config: Config def config: Config
def profile: DatabaseProfile def profile: DatabaseProfile
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] = def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
@ -35,9 +38,15 @@ trait Database extends StrictLogging {
for { for {
partyDescription <- profile.getPartyDescription(partyId) partyDescription <- profile.getPartyDescription(partyId)
players <- profile.getParty(partyId) players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty) bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future.successful(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty) loot <- if (withLoot) profile.getPieces(partyId) else Future.successful(Seq.empty)
} yield Party(partyDescription, config, players, bis, loot) } yield Party(partyDescription, config, players, bis, loot)
protected def run[T](fn: => Future[T])(onSuccess: T => Unit): Unit =
fn.onComplete {
case Success(value) => onSuccess(value)
case Failure(exception) => logger.error("exception during performing database request", exception)
}
} }
object Database { object Database {

View File

@ -6,9 +6,10 @@
* *
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.service.database
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.storage.DatabaseProfile
import org.flywaydb.core.Flyway import org.flywaydb.core.Flyway
import org.flywaydb.core.api.configuration.ClassicConfiguration import org.flywaydb.core.api.configuration.ClassicConfiguration
import org.flywaydb.core.api.output.MigrateResult import org.flywaydb.core.api.output.MigrateResult
@ -17,12 +18,14 @@ import scala.util.Try
class Migration(config: Config) { class Migration(config: Config) {
import me.arcanis.ffxivbis.utils.Implicits._
def performMigration(): Try[MigrateResult] = { def performMigration(): Try[MigrateResult] = {
val section = DatabaseProfile.getSection(config) val section = DatabaseProfile.getSection(config)
val url = section.getString("db.url") val url = section.getString("jdbcUrl")
val username = Try(section.getString("db.user")).toOption.filter(_.nonEmpty).orNull val username = section.getOptString("username").orNull
val password = Try(section.getString("db.password")).toOption.filter(_.nonEmpty).orNull val password = section.getOptString("password").orNull
val provider = url match { val provider = url match {
case s"jdbc:$p:$_" => p case s"jdbc:$p:$_" => p

View File

@ -8,29 +8,32 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
trait DatabaseBiSHandler { this: Database => trait DatabaseBiSHandler { this: Database =>
def bisHandler: DatabaseMessage.Handler = { def bisHandler(msg: BisDatabaseMessage): Behavior[DatabaseMessage] =
case AddPieceToBis(playerId, piece, client) => msg match {
profile.insertPieceBiS(playerId, piece).foreach(_ => client ! ()) case AddPieceToBis(playerId, piece, client) =>
Behaviors.same run(profile.insertPieceBiS(playerId, piece))(_ => client ! ())
Behaviors.same
case GetBiS(partyId, maybePlayerId, client) => case GetBiS(partyId, maybePlayerId, client) =>
getParty(partyId, withBiS = true, withLoot = false) run {
.map(filterParty(_, maybePlayerId)) getParty(partyId, withBiS = true, withLoot = false)
.foreach(client ! _) .map(filterParty(_, maybePlayerId))
Behaviors.same }(client ! _)
Behaviors.same
case RemovePieceFromBiS(playerId, piece, client) => case RemovePieceFromBiS(playerId, piece, client) =>
profile.deletePieceBiS(playerId, piece).foreach(_ => client ! ()) run(profile.deletePieceBiS(playerId, piece))(_ => client ! ())
Behaviors.same Behaviors.same
case RemovePiecesFromBiS(playerId, client) => case RemovePiecesFromBiS(playerId, client) =>
profile.deletePiecesBiS(playerId).foreach(_ => client ! ()) run(profile.deletePiecesBiS(playerId))(_ => client ! ())
Behaviors.same Behaviors.same
} }
} }

View File

@ -11,7 +11,7 @@ package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext} import akka.actor.typed.scaladsl.{AbstractBehavior, ActorContext}
import akka.actor.typed.{Behavior, DispatcherSelector} import akka.actor.typed.{Behavior, DispatcherSelector}
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.DatabaseMessage import me.arcanis.ffxivbis.messages.{BisDatabaseMessage, DatabaseMessage, LootDatabaseMessage, PartyDatabaseMessage, UserDatabaseMessage}
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
import me.arcanis.ffxivbis.storage.DatabaseProfile import me.arcanis.ffxivbis.storage.DatabaseProfile
@ -32,8 +32,12 @@ class DatabaseImpl(context: ActorContext[DatabaseMessage])
override val config: Config = context.system.settings.config override val config: Config = context.system.settings.config
override val profile: DatabaseProfile = new DatabaseProfile(executionContext, config) override val profile: DatabaseProfile = new DatabaseProfile(executionContext, config)
override def onMessage(msg: DatabaseMessage): Behavior[DatabaseMessage] = handle(msg) override def onMessage(msg: DatabaseMessage): Behavior[DatabaseMessage] =
msg match {
case msg: BisDatabaseMessage => bisHandler(msg)
case msg: LootDatabaseMessage => lootHandler(msg)
case msg: PartyDatabaseMessage => partyHandler(msg)
case msg: UserDatabaseMessage => userHandler(msg)
}
private def handle: DatabaseMessage.Handler =
bisHandler.orElse(lootHandler).orElse(partyHandler).orElse(userHandler)
} }

View File

@ -8,6 +8,7 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.Loot import me.arcanis.ffxivbis.models.Loot
@ -17,26 +18,29 @@ import java.time.Instant
trait DatabaseLootHandler { this: Database => trait DatabaseLootHandler { this: Database =>
def lootHandler: DatabaseMessage.Handler = { def lootHandler(msg: LootDatabaseMessage): Behavior[DatabaseMessage] =
case AddPieceTo(playerId, piece, isFreeLoot, client) => msg match {
val loot = Loot(-1, piece, Instant.now, isFreeLoot) case AddPieceTo(playerId, piece, isFreeLoot, client) =>
profile.insertPiece(playerId, loot).foreach(_ => client ! ()) val loot = Loot(-1, piece, Instant.now, isFreeLoot)
Behaviors.same run(profile.insertPiece(playerId, loot))(_ => client ! ())
Behaviors.same
case GetLoot(partyId, maybePlayerId, client) => case GetLoot(partyId, maybePlayerId, client) =>
getParty(partyId, withBiS = false, withLoot = true) run {
.map(filterParty(_, maybePlayerId)) getParty(partyId, withBiS = false, withLoot = true)
.foreach(client ! _) .map(filterParty(_, maybePlayerId))
Behaviors.same }(client ! _)
Behaviors.same
case RemovePieceFrom(playerId, piece, client) => case RemovePieceFrom(playerId, piece, isFreeLoot, client) =>
profile.deletePiece(playerId, piece).foreach(_ => client ! ()) run(profile.deletePiece(playerId, piece, isFreeLoot))(_ => client ! ())
Behaviors.same Behaviors.same
case SuggestLoot(partyId, piece, client) => case SuggestLoot(partyId, piece, client) =>
getParty(partyId, withBiS = true, withLoot = true) run {
.map(_.suggestLoot(piece)) getParty(partyId, withBiS = true, withLoot = true)
.foreach(client ! _) .map(_.suggestLoot(piece))
Behaviors.same }(client ! _)
} Behaviors.same
}
} }

View File

@ -8,6 +8,7 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.{BiS, Player} import me.arcanis.ffxivbis.models.{BiS, Player}
@ -17,49 +18,51 @@ import scala.concurrent.Future
trait DatabasePartyHandler { this: Database => trait DatabasePartyHandler { this: Database =>
def partyHandler: DatabaseMessage.Handler = { def partyHandler(msg: PartyDatabaseMessage): Behavior[DatabaseMessage] =
case AddPlayer(player, client) => msg match {
profile.insertPlayer(player).foreach(_ => client ! ()) case AddPlayer(player, client) =>
Behaviors.same run(profile.insertPlayer(player))(_ => client ! ())
Behaviors.same
case GetParty(partyId, client) => case GetParty(partyId, client) =>
getParty(partyId, withBiS = true, withLoot = true).foreach(client ! _) run(getParty(partyId, withBiS = true, withLoot = true))(client ! _)
Behaviors.same Behaviors.same
case GetPartyDescription(partyId, client) => case GetPartyDescription(partyId, client) =>
profile.getPartyDescription(partyId).foreach(client ! _) run(profile.getPartyDescription(partyId))(client ! _)
Behaviors.same Behaviors.same
case GetPlayer(playerId, client) => case GetPlayer(playerId, client) =>
val player = profile run {
.getPlayerFull(playerId) profile
.flatMap { maybePlayerData => .getPlayerFull(playerId)
Future.traverse(maybePlayerData.toSeq) { playerData => .flatMap { maybePlayerData =>
for { Future.traverse(maybePlayerData.toSeq) { playerData =>
bis <- profile.getPiecesBiS(playerId) for {
loot <- profile.getPieces(playerId) bis <- profile.getPiecesBiS(playerId)
} yield Player( loot <- profile.getPieces(playerId)
playerData.id, } yield Player(
playerId.partyId, playerData.id,
playerId.job, playerId.partyId,
playerId.nick, playerId.job,
BiS(bis.map(_.piece)), playerId.nick,
loot, BiS(bis.map(_.piece)),
playerData.link, loot,
playerData.priority playerData.link,
) playerData.priority
} )
} }
.map(_.headOption) }
player.foreach(client ! _) .map(_.headOption)
Behaviors.same }(client ! _)
Behaviors.same
case RemovePlayer(playerId, client) => case RemovePlayer(playerId, client) =>
profile.deletePlayer(playerId).foreach(_ => client ! ()) run(profile.deletePlayer(playerId))(_ => client ! ())
Behaviors.same Behaviors.same
case UpdateParty(description, client) => case UpdateParty(description, client) =>
profile.insertPartyDescription(description).foreach(_ => client ! ()) run(profile.insertPartyDescription(description))(_ => client ! ())
Behaviors.same Behaviors.same
} }
} }

View File

@ -8,32 +8,34 @@
*/ */
package me.arcanis.ffxivbis.service.database.impl package me.arcanis.ffxivbis.service.database.impl
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.Behaviors
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.Database
trait DatabaseUserHandler { this: Database => trait DatabaseUserHandler { this: Database =>
def userHandler: DatabaseMessage.Handler = { def userHandler(msg: UserDatabaseMessage): Behavior[DatabaseMessage] =
case AddUser(user, isHashedPassword, client) => msg match {
val toInsert = if (isHashedPassword) user else user.withHashedPassword case AddUser(user, isHashedPassword, client) =>
profile.insertUser(toInsert).foreach(_ => client ! ()) val toInsert = if (isHashedPassword) user else user.withHashedPassword
Behaviors.same run(profile.insertUser(toInsert))(_ => client ! ())
Behaviors.same
case DeleteUser(partyId, username, client) => case DeleteUser(partyId, username, client) =>
profile.deleteUser(partyId, username).foreach(_ => client ! ()) run(profile.deleteUser(partyId, username))(_ => client ! ())
Behaviors.same Behaviors.same
case Exists(partyId, client) => case Exists(partyId, client) =>
profile.exists(partyId).foreach(client ! _) run(profile.exists(partyId))(client ! _)
Behaviors.same Behaviors.same
case GetUser(partyId, username, client) => case GetUser(partyId, username, client) =>
profile.getUser(partyId, username).foreach(client ! _) run(profile.getUser(partyId, username))(client ! _)
Behaviors.same Behaviors.same
case GetUsers(partyId, client) => case GetUsers(partyId, client) =>
profile.getUsers(partyId).foreach(client ! _) run(profile.getUsers(partyId))(client ! _)
Behaviors.same Behaviors.same
} }
} }

View File

@ -8,66 +8,72 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType} import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.ForeignKeyQuery
import java.time.Instant import java.time.Instant
import scala.concurrent.Future import scala.concurrent.Future
trait BiSProfile { this: DatabaseProfile => trait BiSProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class BiSRep(playerId: Long, created: Long, piece: String, pieceType: String, job: String) { private val loot: RowParser[Loot] =
(long("player_id") ~ str("piece") ~ str("piece_type")
def toLoot: Loot = Loot( ~ str("job") ~ long("created"))
playerId, .map { case playerId ~ piece ~ pieceType ~ job ~ created =>
Piece(piece, PieceType.withName(pieceType), Job.withName(job)), Loot(
Instant.ofEpochMilli(created), playerId = playerId,
isFreeLoot = false piece = Piece(
) piece = piece,
} pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
object BiSRep { ),
timestamp = Instant.ofEpochMilli(created),
def fromPiece(playerId: Long, piece: Piece): BiSRep = isFreeLoot = false,
BiSRep(playerId, DatabaseProfile.now, piece.piece, piece.pieceType.toString, piece.job.toString) )
} }
class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") {
def playerId: Rep[Long] = column[Long]("player_id", O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece", O.PrimaryKey)
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def * =
(playerId, created, piece, pieceType, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
}
def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] = def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete) withConnection { implicit conn =>
SQL("""delete from bis
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}""".stripMargin)
.on("player_id" -> playerId, "piece" -> piece.piece, "piece_type" -> piece.pieceType.toString)
.executeUpdate()
}
def deletePiecesBiSById(playerId: Long): Future[Int] = def deletePiecesBiSById(playerId: Long): Future[Int] =
db.run(piecesBiS(Seq(playerId)).delete) withConnection { implicit conn =>
SQL("""delete from bis where player_id = {player_id}""")
.on("player_id" -> playerId)
.executeUpdate()
}
def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId)) def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId))
def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] = def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot)) withConnection { implicit conn =>
SQL("""select * from bis where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] = def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
getPiecesBiSById(playerId).flatMap { withConnection { implicit conn =>
case pieces if pieces.exists(loot => loot.piece.strictEqual(piece)) => Future.successful(0) SQL("""insert into bis
case _ => db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece))) | (player_id, piece, piece_type, job, created)
| values
| ({player_id}, {piece}, {piece_type}, {job}, {created})
| on conflict (player_id, piece, piece_type) do nothing""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> piece.piece,
"piece_type" -> piece.pieceType.toString,
"job" -> piece.job.toString,
"created" -> DatabaseProfile.now
)
.executeUpdate()
} }
private def pieceBiS(piece: BiSRep) =
piecesBiS(Seq(piece.playerId)).filter { stored =>
(stored.piece === piece.piece) && (stored.pieceType === piece.pieceType)
}
private def piecesBiS(playerIds: Seq[Long]) =
bisTable.filter(_.playerId.inSet(playerIds.toSet))
} }

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2019-2022 Evgeniy Alekseev.
*
* This file is part of ffxivbis
* (see https://github.com/arcan1s/ffxivbis).
*
* License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause
*/
package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config
import com.zaxxer.hikari.HikariConfig
import java.sql.Connection
import java.util.Properties
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.jdk.CollectionConverters._
import scala.util.control.NonFatal
import scala.util.{Failure, Success, Try}
trait DatabaseConnection {
def datasource: DataSource
def executionContext: ExecutionContext
def withConnection[T](fn: Connection => T): Future[T] = {
val promise = Promise[T]()
executionContext.execute { () =>
Try(datasource.getConnection) match {
case Success(conn) =>
try {
val result = fn(conn)
promise.trySuccess(result)
} catch {
case NonFatal(exception) => promise.tryFailure(exception)
} finally
conn.close()
case Failure(exception) => promise.tryFailure(exception)
}
}
promise.future
}
}
object DatabaseConnection {
def getDataSourceConfig(config: Config): HikariConfig = {
val properties = new Properties()
config.entrySet().asScala.map(_.getKey).foreach { key =>
properties.setProperty(key, config.getString(key))
}
new HikariConfig(properties)
}
}

View File

@ -9,32 +9,32 @@
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import com.zaxxer.hikari.HikariDataSource
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId} import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import java.time.Instant import java.time.Instant
import javax.sql.DataSource
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class DatabaseProfile(context: ExecutionContext, config: Config) class DatabaseProfile(override val executionContext: ExecutionContext, config: Config)
extends BiSProfile extends StrictLogging
with BiSProfile
with LootProfile with LootProfile
with PartyProfile with PartyProfile
with PlayersProfile with PlayersProfile
with UsersProfile { with UsersProfile {
implicit val executionContext: ExecutionContext = context override val datasource: DataSource =
try {
val dbConfig: DatabaseConfig[JdbcProfile] = val profile = DatabaseProfile.getSection(config)
DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config)) val dataSourceConfig = DatabaseConnection.getDataSourceConfig(profile)
import dbConfig.profile.api._ new HikariDataSource(dataSourceConfig)
val db = dbConfig.db } catch {
case exception: Exception =>
val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces] logger.error("exception during storage initialization", exception)
val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces] throw exception
val partiesTable: TableQuery[Parties] = TableQuery[Parties] }
val playersTable: TableQuery[Players] = TableQuery[Players]
val usersTable: TableQuery[Users] = TableQuery[Users]
// generic bis api // generic bis api
def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] = def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
@ -53,9 +53,8 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceBiSById(piece)) byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api // generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] = { def deletePiece(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean): Future[Int] = {
// we don't really care here about loot val loot = Loot(-1, piece, Instant.now, isFreeLoot)
val loot = Loot(-1, piece, Instant.now, isFreeLoot = false)
byPlayerId(playerId, deletePieceById(loot)) byPlayerId(playerId, deletePieceById(loot))
} }
@ -69,21 +68,23 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceById(loot)) byPlayerId(playerId, insertPieceById(loot))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] = private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).flatMap(callback) getPlayers(partyId).flatMap(callback)(executionContext)
private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] = private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] =
getPlayer(playerId).flatMap { getPlayer(playerId).flatMap {
case Some(id) => callback(id) case Some(id) => callback(id)
case None => Future.failed(new Error(s"Could not find player $playerId")) case None => Future.failed(DatabaseProfile.PlayerNotFound(playerId))
} }(executionContext)
} }
object DatabaseProfile { object DatabaseProfile {
def now: Long = Instant.now.toEpochMilli case class PlayerNotFound(playerId: PlayerId) extends Exception(s"Could not find player $playerId")
def getSection(config: Config): Config = { def getSection(config: Config): Config = {
val section = config.getString("me.arcanis.ffxivbis.database.mode") val section = config.getString("me.arcanis.ffxivbis.database.mode")
config.getConfig("me.arcanis.ffxivbis.database").getConfig(section) config.getConfig(s"me.arcanis.ffxivbis.database.$section")
} }
def now: Long = Instant.now.toEpochMilli
} }

View File

@ -8,81 +8,78 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType} import me.arcanis.ffxivbis.models.{Job, Loot, Piece, PieceType}
import slick.lifted.{ForeignKeyQuery, Index}
import java.time.Instant import java.time.Instant
import scala.concurrent.Future import scala.concurrent.Future
trait LootProfile { this: DatabaseProfile => trait LootProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class LootRep( private val loot: RowParser[Loot] =
lootId: Option[Long], (long("player_id") ~ str("piece") ~ str("piece_type")
playerId: Long, ~ str("job") ~ long("created") ~ int("is_free_loot"))
created: Long, .map { case playerId ~ piece ~ pieceType ~ job ~ created ~ isFreeLoot =>
piece: String, Loot(
pieceType: String, playerId = playerId,
job: String, piece = Piece(
isFreeLoot: Int piece = piece,
) { pieceType = PieceType.withName(pieceType),
job = Job.withName(job)
def toLoot: Loot = Loot( ),
playerId, timestamp = Instant.ofEpochMilli(created),
Piece(piece, PieceType.withName(pieceType), Job.withName(job)), isFreeLoot = isFreeLoot == 1,
Instant.ofEpochMilli(created), )
isFreeLoot == 1 }
)
}
object LootRep {
def fromLoot(playerId: Long, loot: Loot): LootRep =
LootRep(
None,
playerId,
loot.timestamp.toEpochMilli,
loot.piece.piece,
loot.piece.pieceType.toString,
loot.piece.job.toString,
if (loot.isFreeLoot) 1 else 0
)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey)
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def isFreeLoot: Rep[Int] = column[Int]("is_free_loot")
def * =
(lootId.?, playerId, created, piece, pieceType, job, isFreeLoot) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def lootOwnerIdx: Index =
index("loot_owner_idx", playerId, unique = false)
}
def deletePieceById(loot: Loot)(playerId: Long): Future[Int] = def deletePieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromLoot(playerId, loot)).map(_.lootId).max.result).flatMap { withConnection { implicit conn =>
case Some(id) => db.run(lootTable.filter(_.lootId === id).delete) SQL("""delete from loot
case _ => throw new IllegalArgumentException(s"Could not find piece $loot belong to $playerId") | where loot_id in
| (
| select loot_id from loot
| where player_id = {player_id}
| and piece = {piece}
| and piece_type = {piece_type}
| and job = {job}
| and is_free_loot = {is_free_loot}
| limit 1
| )""".stripMargin)
.on(
"player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
} }
def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId)) def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId))
def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] = def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot)) withConnection { implicit conn =>
SQL("""select * from loot where player_id in ({player_ids})""")
.on("player_ids" -> playerIds)
.executeQuery()
.as(loot.*)
}
def insertPieceById(loot: Loot)(playerId: Long): Future[Int] = def insertPieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromLoot(playerId, loot))) withConnection { implicit conn =>
SQL("""insert into loot
private def pieceLoot(piece: LootRep) = | (player_id, piece, piece_type, job, created, is_free_loot)
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece) | values
| ({player_id}, {piece}, {piece_type}, {job}, {created}, {is_free_loot})""".stripMargin)
private def piecesLoot(playerIds: Seq[Long]) = .on(
lootTable.filter(_.playerId.inSet(playerIds.toSet)) "player_id" -> playerId,
"piece" -> loot.piece.piece,
"piece_type" -> loot.piece.pieceType.toString,
"job" -> loot.piece.job.toString,
"created" -> DatabaseProfile.now,
"is_free_loot" -> loot.isFreeLootToInt
)
.executeUpdate()
}
} }

View File

@ -8,47 +8,41 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.PartyDescription import me.arcanis.ffxivbis.models.PartyDescription
import scala.concurrent.Future import scala.concurrent.Future
trait PartyProfile { this: DatabaseProfile => trait PartyProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class PartyRep(partyId: Option[Long], partyName: String, partyAlias: Option[String]) { private val description: RowParser[PartyDescription] =
(str("party_name") ~ str("party_alias").?)
def toDescription: PartyDescription = PartyDescription(partyName, partyAlias) .map { case partyName ~ partyAlias =>
} PartyDescription(
partyId = partyName,
object PartyRep { partyAlias = partyAlias,
)
def fromDescription(party: PartyDescription, id: Option[Long]): PartyRep = }
PartyRep(id, party.partyId, party.partyAlias)
}
class Parties(tag: Tag) extends Table[PartyRep](tag, "parties") {
def partyId: Rep[Long] = column[Long]("party_id", O.AutoInc, O.PrimaryKey)
def partyName: Rep[String] = column[String]("party_name")
def partyAlias: Rep[Option[String]] = column[Option[String]]("party_alias")
def * =
(partyId.?, partyName, partyAlias) <> ((PartyRep.apply _).tupled, PartyRep.unapply)
}
def getPartyDescription(partyId: String): Future[PartyDescription] = def getPartyDescription(partyId: String): Future[PartyDescription] =
db.run( withConnection { implicit conn =>
partyDescription(partyId).result.headOption.map(_.map(_.toDescription).getOrElse(PartyDescription.empty(partyId))) SQL("""select * from parties where party_name = {party_name}""")
) .on("party_name" -> partyId)
.executeQuery()
def getUniquePartyId(partyId: String): Future[Option[Long]] = .as(description.singleOpt)
db.run(partyDescription(partyId).map(_.partyId).result.headOption) .getOrElse(PartyDescription.empty(partyId))
def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
getUniquePartyId(partyDescription.partyId).flatMap {
case Some(id) => db.run(partiesTable.update(PartyRep.fromDescription(partyDescription, Some(id))))
case _ => db.run(partiesTable.insertOrUpdate(PartyRep.fromDescription(partyDescription, None)))
} }
private def partyDescription(partyId: String) = def insertPartyDescription(partyDescription: PartyDescription): Future[Int] =
partiesTable.filter(_.partyName === partyId) withConnection { implicit conn =>
SQL("""insert into parties
| (party_name, party_alias)
| values
| ({party_name}, {party_alias})
| on conflict (party_name) do update set
| party_alias = {party_alias}""".stripMargin)
.on("party_name" -> partyDescription.partyId, "party_alias" -> partyDescription.partyAlias)
.executeUpdate()
}
} }

View File

@ -8,76 +8,97 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId} import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId}
import scala.concurrent.Future import scala.concurrent.Future
trait PlayersProfile { this: DatabaseProfile => trait PlayersProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class PlayerRep( private val player: RowParser[Player] =
partyId: String, (long("player_id") ~ str("party_id") ~ str("job")
playerId: Option[Long], ~ str("nick") ~ str("bis_link").? ~ int("priority").?)
created: Long, .map { case playerId ~ partyId ~ job ~ nick ~ link ~ priority =>
nick: String, Player(
job: String, id = playerId,
link: Option[String], partyId = partyId,
priority: Int job = Job.withName(job),
) { nick = nick,
bis = BiS.empty,
loot = Seq.empty,
link = link,
priority = priority.getOrElse(0),
)
}
def toPlayer: Player = def deletePlayer(playerId: PlayerId): Future[Int] =
Player(playerId.getOrElse(-1), partyId, Job.withName(job), nick, BiS.empty, Seq.empty, link, priority) withConnection { implicit conn =>
} SQL("""delete from players
| where party_id = {party_id}
object PlayerRep { | and nick = {nick}
| and job = {job}""".stripMargin)
def fromPlayer(player: Player, id: Option[Long]): PlayerRep = .on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
PlayerRep(player.partyId, id, DatabaseProfile.now, player.nick, player.job.toString, player.link, player.priority) .executeUpdate()
}
class Players(tag: Tag) extends Table[PlayerRep](tag, "players") {
def partyId: Rep[String] = column[String]("party_id")
def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def nick: Rep[String] = column[String]("nick")
def job: Rep[String] = column[String]("job")
def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link")
def priority: Rep[Int] = column[Int]("priority", O.Default(1))
def * =
(partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply)
}
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]) {
case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer)
case (acc, _) => acc
})
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
db.run(player(playerId).map(_.playerId).result.headOption)
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
db.run(player(playerId).result.headOption.map(_.map(_.toPlayer)))
def getPlayers(partyId: String): Future[Seq[Long]] =
db.run(players(partyId).map(_.playerId).result)
def insertPlayer(playerObj: Player): Future[Int] =
getPlayer(playerObj.playerId).flatMap {
case Some(id) => db.run(playersTable.update(PlayerRep.fromPlayer(playerObj, Some(id))))
case _ => db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(playerObj, None)))
} }
private def player(playerId: PlayerId) = def getParty(partyId: String): Future[Map[Long, Player]] =
playersTable withConnection { implicit conn =>
.filter(_.partyId === playerId.partyId) SQL("""select * from players where party_id = {party_id}""")
.filter(_.job === playerId.job.toString) .on("party_id" -> partyId)
.filter(_.nick === playerId.nick) .executeQuery()
.as(player.*)
.map(p => p.id -> p)
.toMap
}
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
withConnection { implicit conn =>
SQL("""select player_id from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(scalar[Long].singleOpt)
}
def getPlayerFull(playerId: PlayerId): Future[Option[Player]] =
withConnection { implicit conn =>
SQL("""select * from players
| where party_id = {party_id}
| and nick = {nick}
| and job = {job}""".stripMargin)
.on("party_id" -> playerId.partyId, "nick" -> playerId.nick, "job" -> playerId.job.toString)
.executeQuery()
.as(player.singleOpt)
}
def getPlayers(partyId: String): Future[Seq[Long]] =
withConnection { implicit conn =>
SQL("""select player_id from players where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(scalar[Long].*)
}
def insertPlayer(player: Player): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into players
| (party_id, created, job, nick, bis_link, priority)
| values
| ({party_id}, {created}, {job}, {nick}, {link}, {priority})
| on conflict (party_id, nick, job) do update set
| bis_link = {link}, priority = {priority}""".stripMargin)
.on(
"party_id" -> player.partyId,
"created" -> DatabaseProfile.now,
"job" -> player.job.toString,
"nick" -> player.nick,
"link" -> player.link,
"priority" -> player.priority
)
.executeUpdate()
}
private def players(partyId: String) =
playersTable.filter(_.partyId === partyId)
} }

View File

@ -8,64 +8,67 @@
*/ */
package me.arcanis.ffxivbis.storage package me.arcanis.ffxivbis.storage
import anorm.SqlParser._
import anorm._
import me.arcanis.ffxivbis.models.{Permission, User} import me.arcanis.ffxivbis.models.{Permission, User}
import slick.lifted.{Index, PrimaryKey}
import scala.concurrent.Future import scala.concurrent.Future
trait UsersProfile { this: DatabaseProfile => trait UsersProfile extends DatabaseConnection {
import dbConfig.profile.api._
case class UserRep(partyId: String, userId: Option[Long], username: String, password: String, permission: String) { private val user: RowParser[User] =
(str("party_id") ~ str("username") ~ str("password") ~ str("permission"))
def toUser: User = User(partyId, username, password, Permission.withName(permission)) .map { case partyId ~ username ~ password ~ permission =>
} User(
partyId = partyId,
object UserRep { username = username,
password = password,
def fromUser(user: User, id: Option[Long]): UserRep = permission = Permission.withName(permission),
UserRep(user.partyId, id, user.username, user.password, user.permission.toString) )
} }
class Users(tag: Tag) extends Table[UserRep](tag, "users") {
def partyId: Rep[String] = column[String]("party_id")
def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey)
def username: Rep[String] = column[String]("username")
def password: Rep[String] = column[String]("password")
def permission: Rep[String] = column[String]("permission")
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)
}
def deleteUser(partyId: String, username: String): Future[Int] = def deleteUser(partyId: String, username: String): Future[Int] =
db.run( withConnection { implicit conn =>
user(partyId, Some(username)) SQL("""delete from users
.filter(_.permission =!= Permission.admin.toString) // we do not allow to remove admins | where party_id = {party_id}
.delete | and username = {username}
) | and permission <> {admin}""".stripMargin)
.on("party_id" -> partyId, "username" -> username, "admin" -> Permission.admin.toString)
def exists(partyId: String): Future[Boolean] = .executeUpdate()
db.run(user(partyId, None).exists.result)
def getUser(partyId: String, username: String): Future[Option[User]] =
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(userObj: User): Future[Int] =
db.run(user(userObj.partyId, Some(userObj.username)).map(_.userId).result.headOption).flatMap {
case Some(id) => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, Some(id))))
case _ => db.run(usersTable.insertOrUpdate(UserRep.fromUser(userObj, None)))
} }
private def user(partyId: String, username: Option[String]) = def exists(partyId: String): Future[Boolean] = getUsers(partyId).map(_.nonEmpty)(executionContext)
usersTable
.filter(_.partyId === partyId) def getUser(partyId: String, username: String): Future[Option[User]] =
.filterIf(username.isDefined)(_.username === username.orNull) withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id} and username = {username}""")
.on("party_id" -> partyId, "username" -> username)
.executeQuery()
.as(user.singleOpt)
}
def getUsers(partyId: String): Future[Seq[User]] =
withConnection { implicit conn =>
SQL("""select * from users where party_id = {party_id}""")
.on("party_id" -> partyId)
.executeQuery()
.as(user.*)
}
def insertUser(user: User): Future[Int] =
withConnection { implicit conn =>
SQL("""insert into users
| (party_id, username, password, permission)
| values
| ({party_id}, {username}, {password}, {permission})
| on conflict (party_id, username) do update set
| password = {password}, permission = {permission}""".stripMargin)
.on(
"party_id" -> user.partyId,
"username" -> user.username,
"password" -> user.password,
"permission" -> user.permission.toString
)
.executeUpdate()
}
} }

View File

@ -9,22 +9,23 @@
package me.arcanis.ffxivbis.utils package me.arcanis.ffxivbis.utils
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.config.Config
import java.time.Duration
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import scala.concurrent.duration.FiniteDuration import scala.concurrent.duration.FiniteDuration
import scala.language.implicitConversions import scala.language.implicitConversions
import scala.util.Try
object Implicits { object Implicits {
implicit def getBooleanFromOptionString(maybeYes: Option[String]): Boolean = maybeYes.map(_.toLowerCase) match { implicit class ConfigExtension(config: Config) {
case Some("yes" | "on") => true
case _ => false def getFiniteDuration(path: String): FiniteDuration =
FiniteDuration(config.getDuration(path, TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS)
def getOptString(path: String): Option[String] =
Try(config.getString(path)).toOption.filter(_.nonEmpty)
def getTimeout(path: String): Timeout = getFiniteDuration(path)
} }
implicit def getFiniteDuration(duration: Duration): FiniteDuration =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
implicit def getTimeout(duration: Duration): Timeout =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
} }

View File

@ -17,7 +17,7 @@ object Settings {
} }
def clearDatabase(config: Config): Unit = def clearDatabase(config: Config): Unit =
config.getString("me.arcanis.ffxivbis.database.sqlite.db.url").split(":") config.getString("me.arcanis.ffxivbis.database.sqlite.jdbcUrl").split(":")
.lastOption.foreach { databasePath => .lastOption.foreach { databasePath =>
val databaseFile = new File(databasePath) val databaseFile = new File(databasePath)
if (databaseFile.exists) if (databaseFile.exists)
@ -25,5 +25,5 @@ object Settings {
} }
def randomDatabasePath: String = File.createTempFile("ffxivdb-",".db").toPath.toString def randomDatabasePath: String = File.createTempFile("ffxivdb-",".db").toPath.toString
def withRandomDatabase: Config = def withRandomDatabase: Config =
config(Map("me.arcanis.ffxivbis.database.sqlite.db.url" -> s"jdbc:sqlite:$randomDatabasePath")) config(Map("me.arcanis.ffxivbis.database.sqlite.jdbcUrl" -> s"jdbc:sqlite:$randomDatabasePath"))
} }

View File

@ -12,8 +12,7 @@ import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.models.{BiS, Job} import me.arcanis.ffxivbis.models.{BiS, Job}
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers

View File

@ -10,8 +10,7 @@ import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser} import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -12,8 +12,7 @@ import me.arcanis.ffxivbis.messages.AddUser
import me.arcanis.ffxivbis.models.PartyDescription import me.arcanis.ffxivbis.models.PartyDescription
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -11,8 +11,7 @@ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser} import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -0,0 +1,29 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.testkit.ScalatestRouteTest
import com.typesafe.config.Config
import me.arcanis.ffxivbis.Settings
import me.arcanis.ffxivbis.http.api.v1.json._
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
import scala.language.postfixOps
class StatusEndpointTest extends AnyWordSpecLike
with Matchers with ScalatestRouteTest with JsonSupport {
override val testConfig: Config = Settings.withRandomDatabase
private val route = new StatusEndpoint().route
"api v1 status endpoint" must {
"return server status" in {
Get("/status") ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -8,8 +8,7 @@ import akka.testkit.TestKit
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.database.Database import me.arcanis.ffxivbis.service.database.{Database, Migration}
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -4,7 +4,6 @@ import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import me.arcanis.ffxivbis.messages.{AddPieceToBis, AddPlayer, GetBiS, RemovePieceFromBiS} import me.arcanis.ffxivbis.messages.{AddPieceToBis, AddPlayer, GetBiS, RemovePieceFromBiS}
import me.arcanis.ffxivbis.models._ import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike
@ -85,6 +84,6 @@ class DatabaseBiSHandlerTest extends ScalaTestWithActorTestKit(Settings.withRand
} }
private def partyBiSCompare[T](party: Seq[T], bis: Seq[Piece]): Boolean = private def partyBiSCompare(party: Seq[Player], bis: Seq[Piece]): Boolean =
Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) => acc ++ player.asInstanceOf[Player].bis.pieces }, bis) Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) => acc ++ player.bis.pieces }, bis)
} }

View File

@ -2,9 +2,9 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import akka.actor.typed.scaladsl.AskPattern.Askable import akka.actor.typed.scaladsl.AskPattern.Askable
import ch.qos.logback.core.util.FixedDelay
import me.arcanis.ffxivbis.messages.{AddPieceTo, AddPlayer, GetLoot, RemovePieceFrom} import me.arcanis.ffxivbis.messages.{AddPieceTo, AddPlayer, GetLoot, RemovePieceFrom}
import me.arcanis.ffxivbis.models._ import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike
@ -58,7 +58,7 @@ class DatabaseLootHandlerTest extends ScalaTestWithActorTestKit(Settings.withRan
"remove loot" in { "remove loot" in {
val updateProbe = testKit.createTestProbe[Unit]() val updateProbe = testKit.createTestProbe[Unit]()
database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, updateProbe.ref) database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, isFreeLoot = false, updateProbe.ref)
updateProbe.expectMessage(askTimeout, ()) updateProbe.expectMessage(askTimeout, ())
val newLoot = Fixtures.loot.filterNot(_ == Fixtures.lootBody) val newLoot = Fixtures.loot.filterNot(_ == Fixtures.lootBody)
@ -87,10 +87,24 @@ class DatabaseLootHandlerTest extends ScalaTestWithActorTestKit(Settings.withRan
partyLootCompare(party, Fixtures.loot ++ Fixtures.loot) shouldEqual true partyLootCompare(party, Fixtures.loot ++ Fixtures.loot) shouldEqual true
} }
"remove only one piece" in {
val updateProbe = testKit.createTestProbe[Unit]()
database ! RemovePieceFrom(Fixtures.playerEmpty.playerId, Fixtures.lootBody, isFreeLoot = false, updateProbe.ref)
updateProbe.expectMessage(askTimeout, ())
val probe = testKit.createTestProbe[Seq[Player]]()
database ! GetLoot(Fixtures.playerEmpty.partyId, None, probe.ref)
val party = probe.expectMessageType[Seq[Player]](askTimeout)
val player = party.filter(_.playerId == Fixtures.playerEmpty.playerId)
player should not be empty
player.flatMap(_.loot).map(_.piece) should contain (Fixtures.lootBody)
}
} }
private def partyLootCompare[T](party: Seq[T], loot: Seq[Piece]): Boolean = private def partyLootCompare(party: Seq[Player], loot: Seq[Piece]): Boolean =
Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) => Compare.seqEquals(party.foldLeft(Seq.empty[Piece]){ case (acc, player) =>
acc ++ player.asInstanceOf[Player].loot.map(_.piece) acc ++ player.loot.map(_.piece)
}, loot) }, loot)
} }

View File

@ -3,7 +3,6 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import me.arcanis.ffxivbis.messages.{AddPlayer, GetParty, GetPlayer, RemovePlayer} import me.arcanis.ffxivbis.messages.{AddPlayer, GetParty, GetPlayer, RemovePlayer}
import me.arcanis.ffxivbis.models._ import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -3,7 +3,6 @@ package me.arcanis.ffxivbis.service.database
import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit import akka.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
import me.arcanis.ffxivbis.messages.{AddUser, DeleteUser, GetUser, GetUsers} import me.arcanis.ffxivbis.messages.{AddUser, DeleteUser, GetUser, GetUsers}
import me.arcanis.ffxivbis.models.User import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.storage.Migration
import me.arcanis.ffxivbis.utils.Compare import me.arcanis.ffxivbis.utils.Compare
import me.arcanis.ffxivbis.{Fixtures, Settings} import me.arcanis.ffxivbis.{Fixtures, Settings}
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike

View File

@ -1 +1 @@
version := "0.10.0" version := "0.12.2"