From 14996ef47882aac25f16ba44abccc9991d58233a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Otr=C4=99bski?= Date: Tue, 13 Oct 2020 00:18:24 +0200 Subject: [PATCH] Store feedback in PostgreSQL (#10) Store feedback in PostgresSql --- src/main/resources/application.conf | 15 ++++ .../scala/quizz/db/DatabaseInitializer.scala | 34 ++++++++ src/main/scala/quizz/db/Feedback.scala | 12 +++ src/main/scala/quizz/db/package.scala | 24 +++++ .../scala/quizz/feedback/FeedbackSender.scala | 79 +++++++++++++---- src/main/scala/quizz/web/WebApp.scala | 87 ++++++++++++------- src/test/scala/mindmup/ParserTest.scala | 2 +- 7 files changed, 205 insertions(+), 48 deletions(-) create mode 100644 src/main/scala/quizz/db/DatabaseInitializer.scala create mode 100644 src/main/scala/quizz/db/Feedback.scala create mode 100644 src/main/scala/quizz/db/package.scala diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf index 04b0a9b..3ef029d 100644 --- a/src/main/resources/application.conf +++ b/src/main/resources/application.conf @@ -12,4 +12,19 @@ feedback { token = "" token = ${?SLACK_TOKEN} } +} + +database { + use = true + user = ${?USE_DB} + host = "localhost" + host = ${?DB_HOST} + port = 5432 + port = ${?DB_PORT} + dbname = "quizz" + dbname = ${?DB_NAME} + user = "postgres" + user = ${?DB_USERNAME} + password = "password" + password = ${?DB_PASSWORD} } \ No newline at end of file diff --git a/src/main/scala/quizz/db/DatabaseInitializer.scala b/src/main/scala/quizz/db/DatabaseInitializer.scala new file mode 100644 index 0000000..c641cad --- /dev/null +++ b/src/main/scala/quizz/db/DatabaseInitializer.scala @@ -0,0 +1,34 @@ +package quizz.db + +import cats.effect.IO + +object DatabaseInitializer { + + def initDatabase(cfg: DatabaseConfig): IO[Int] = { + import doobie._ + import doobie.implicits._ + import doobie.util.ExecutionContexts + import cats.effect._ + implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContexts.synchronous) + + val xa = Transactor.fromDriverManager[IO]( + "org.postgresql.Driver", // driver classname + s"jdbc:postgresql://${cfg.host}:${cfg.port}/${cfg.database}", // connect URL (driver-specific) + cfg.user, // user + cfg.password // password + ) + val create = + sql""" CREATE TABLE IF NOT EXISTS feedback + ( + id SERIAL PRIMARY KEY, + timestamp timestamp NOT NULL, + quizzId varchar(300) NOT NULL, + path varchar(2000) NOT NULL, + comment varchar(5000) NOT NULL, + rate INT NOT NULL + ); + """.update.run + create.transact(xa) + } + +} diff --git a/src/main/scala/quizz/db/Feedback.scala b/src/main/scala/quizz/db/Feedback.scala new file mode 100644 index 0000000..81da15f --- /dev/null +++ b/src/main/scala/quizz/db/Feedback.scala @@ -0,0 +1,12 @@ +package quizz.db + +import java.util.Date + +case class Feedback( + id: Int, + timestamp: Date, + quizzId: String, + path: String, + comment: String, + rate: Int +) diff --git a/src/main/scala/quizz/db/package.scala b/src/main/scala/quizz/db/package.scala new file mode 100644 index 0000000..86402f8 --- /dev/null +++ b/src/main/scala/quizz/db/package.scala @@ -0,0 +1,24 @@ +package quizz + +import com.typesafe.config.Config + +package object db { + + case class DatabaseConfig( + host: String, + port: Int, + database: String, + user: String, + password: String + ) + + def databaseConfig(config: Config): DatabaseConfig = + DatabaseConfig( + host = config.getString("database.host"), + port = config.getInt("database.port"), + database = config.getString("database.dbname"), + user = config.getString("database.user"), + password = config.getString("database.password") + ) + +} diff --git a/src/main/scala/quizz/feedback/FeedbackSender.scala b/src/main/scala/quizz/feedback/FeedbackSender.scala index 926f971..763a80b 100644 --- a/src/main/scala/quizz/feedback/FeedbackSender.scala +++ b/src/main/scala/quizz/feedback/FeedbackSender.scala @@ -1,40 +1,48 @@ package quizz.feedback -import cats.effect.Sync +import cats.effect.{ Clock, IO, Sync } import cats.syntax.option._ import com.typesafe.scalalogging.LazyLogging -import quizz.web.WebApp.Api.{ Feedback, QuizzState } +import doobie.quill.DoobieContext +import io.getquill.Literal +import quizz.db.{ DatabaseConfig, Feedback } +import quizz.web.WebApp.Api.{ FeedbackSend, QuizzState } import scala.language.higherKinds trait FeedbackSender[F[_]] { - def send(feedback: Feedback, quizzState: QuizzState): F[Unit] + def send(feedback: FeedbackSend, quizzState: QuizzState): F[Unit] } class LogFeedbackSender[F[_]: Sync] extends FeedbackSender[F] with LazyLogging { - override def send(feedback: Feedback, quizzState: QuizzState): F[Unit] = + override def send(feedback: FeedbackSend, quizzState: QuizzState): F[Unit] = Sync[F].delay(logger.info(s"Have feedback: $feedback for $quizzState")) } object SlackFeedbackSender { + case class SlackMessage(blocks: List[Block]) + case class Block(text: Text, `type`: String = "section", block_id: Option[String] = None) + case class Text(text: String, `type`: String = "mrkdwn") - private def feedbackIcon(feedback: Feedback): String = + private def feedbackIcon(feedback: FeedbackSend): String = feedback.rate match { case a if a > 0 => ":+1:" case a if a < 0 => ":-1:" case a if a == 0 => ":point_right:" } - def convertFeedback(feedback: Feedback, quizzState: QuizzState): SlackMessage = { + def convertFeedback(feedback: FeedbackSend, quizzState: QuizzState): SlackMessage = { val history: List[Block] = quizzState.history.foldRight(List.empty[Block]) { (historyStep, list) => val answers = historyStep.answers .map(answer => - s" - ${answer.selected.map(if (_) ":ballot_box_with_check:" else ":black_square_button:").getOrElse(":black_square_button:")} ${answer.text}" + s" - ${answer.selected + .map(if (_) ":ballot_box_with_check:" else ":black_square_button:") + .getOrElse(":black_square_button:")} ${answer.text}" ) .mkString("\n") Block( @@ -73,18 +81,13 @@ object SlackFeedbackSender { class SlackFeedbackSender[F[_]: Sync](token: String) extends FeedbackSender[F] { - override def send(feedback: Feedback, quizzState: QuizzState): F[Unit] = + override def send(feedback: FeedbackSend, quizzState: QuizzState): F[Unit] = Sync[F].delay { import sttp.client.circe._ val url: String = s"https://hooks.slack.com/services/$token" import sttp.client._ val uri = uri"$url" - val rate = feedback.rate match { - case a if a > 0 => ":+1:" - case a if a < 0 => ":-1:" - case a if a == 0 => ":point_right:" - } val message = SlackFeedbackSender.convertFeedback(feedback, quizzState) import io.circe.generic.auto._ @@ -95,7 +98,53 @@ class SlackFeedbackSender[F[_]: Sync](token: String) extends FeedbackSender[F] { .body(message) implicit val backend: SttpBackend[Identity, Nothing, NothingT] = HttpURLConnectionBackend() - val response: Identity[Response[Either[String, String]]] = myRequest.send() - response + myRequest.send() } } + +class FeedbackDBSender(dbConfig: DatabaseConfig)(implicit clock: Clock[IO]) + extends FeedbackSender[IO] { + + import cats.effect._ + import doobie._ + + val trivial = LogHandler(e => Console.println("*** " + e)) + val dc = new DoobieContext.Postgres(Literal) // Literal naming scheme + + import dc._ + + private implicit val cs: ContextShift[IO] = + IO.contextShift(scala.concurrent.ExecutionContext.global) + + private val xa = Transactor.fromDriverManager[IO]( + driver = "org.postgresql.Driver", + url = s"jdbc:postgresql://${dbConfig.host}:${dbConfig.port}/${dbConfig.database}", + user = dbConfig.user, + pass = dbConfig.password + ) + + override def send(feedback: FeedbackSend, quizzState: QuizzState): IO[Unit] = { + import java.util.Date + + import doobie.implicits._ + for { + now <- clock.realTime(scala.concurrent.duration.MILLISECONDS) + fb = Feedback( + id = 0, + timestamp = new Date(now), + quizzId = feedback.quizzId, + path = feedback.path, + comment = feedback.comment, + rate = feedback.rate + ) + _ <- addFeedbackToDb(fb).transact(xa) + } yield () + } + + protected def addFeedbackToDb(feedback: Feedback): doobie.ConnectionIO[Index] = { + val q1 = quote { + query[Feedback].insert(lift(feedback)).returningGenerated(_.id) + } + run(q1) + } +} diff --git a/src/main/scala/quizz/web/WebApp.scala b/src/main/scala/quizz/web/WebApp.scala index fb66530..d3bfcba 100644 --- a/src/main/scala/quizz/web/WebApp.scala +++ b/src/main/scala/quizz/web/WebApp.scala @@ -19,21 +19,22 @@ package quizz.web import java.io.File import cats.effect.concurrent.Ref -import cats.effect.{ ExitCode, IO, IOApp } +import cats.effect.{Clock, ExitCode, IO, IOApp} import cats.implicits._ -import scala.concurrent.{ ExecutionContextExecutor, Future } -import com.typesafe.config.{ Config, ConfigFactory } +import scala.concurrent.{ExecutionContextExecutor, Future} +import com.typesafe.config.{Config, ConfigFactory} import com.typesafe.scalalogging.LazyLogging import io.circe import io.circe.generic.auto._ -import quizz.data.{ ExamplesData, Loader } -import quizz.feedback.{ LogFeedbackSender, SlackFeedbackSender } +import quizz.data.{ExamplesData, Loader} +import quizz.db.DatabaseInitializer +import quizz.feedback.{FeedbackDBSender, FeedbackSender, LogFeedbackSender, SlackFeedbackSender} import quizz.model.Quizz -import quizz.web.WebApp.Api.{ AddQuizzResponse, FeedbackResponse, Quizzes } +import quizz.web.WebApp.Api.{AddQuizzResponse, FeedbackResponse, Quizzes} import tapir.json.circe._ import tapir.server.akkahttp._ -import tapir.{ path, _ } +import tapir.{path, _} object WebApp extends IOApp with LazyLogging { @@ -66,7 +67,7 @@ object WebApp extends IOApp with LazyLogging { case class Quizzes(quizzes: List[QuizzInfo]) - case class Feedback(quizzId: String, path: String, rate: Int, comment: String) + case class FeedbackSend(quizzId: String, path: String, rate: Int, comment: String) case class FeedbackResponse(status: String) @@ -78,16 +79,16 @@ object WebApp extends IOApp with LazyLogging { } - val stateCodec = jsonBody[Api.QuizzState] - val codecQuizInfo = jsonBody[Api.Quizzes] - val codecFeedback = jsonBody[Api.Feedback] - val codecFeedbackResponse = jsonBody[Api.FeedbackResponse] + private val stateCodec = jsonBody[Api.QuizzState] + private val codecQuizInfo = jsonBody[Api.Quizzes] + private val codecFeedback = jsonBody[Api.FeedbackSend] + private val codecFeedbackResponse = jsonBody[Api.FeedbackResponse] private val config: Config = ConfigFactory.load() private val dirWithQuizzes: String = config.getString("quizz.loader.dir") logger.info(s"""Loading from "$dirWithQuizzes" """) - val routeEndpoint = endpoint.get + private val routeEndpoint = endpoint.get .in( ("api" / "quiz" / path[String]("id") / "path" / path[String]("quizPath")) .mapTo(Api.QuizzQuery) @@ -95,16 +96,16 @@ object WebApp extends IOApp with LazyLogging { .errorOut(stringBody) .out(jsonBody[Api.QuizzState]) - val routeEndpointStart = endpoint.get + private val routeEndpointStart = endpoint.get .in(("api" / "quiz" / path[String]("id") / "path").mapTo(Api.QuizzId)) .errorOut(stringBody) .out(jsonBody[Api.QuizzState]) - val listQuizzes = endpoint.get + private val listQuizzes = endpoint.get .in("api" / "quiz") .out(jsonBody[Api.Quizzes]) - val addQuizz = endpoint.put + private val addQuizz = endpoint.put .in("api" / "quizz" / path[String](name = "id").description("Id of quizz to add/replace")) .in(stringBody("UTF-8")) .mapIn(idAndContent => Api.AddQuizz(idAndContent._1, idAndContent._2))(a => @@ -112,12 +113,12 @@ object WebApp extends IOApp with LazyLogging { ) .out(jsonBody[Api.AddQuizzResponse]) - val feedback = endpoint.post + private val feedback = endpoint.post .in("api" / "feedback") - .in(jsonBody[Api.Feedback].description("Feedback from user")) + .in(jsonBody[Api.FeedbackSend].description("Feedback from user")) .out(jsonBody[Api.FeedbackResponse]) - val validateEndpoint = endpoint.post + private val validateEndpoint = endpoint.post .in("api" / "quizz" / "validate" / "mindmup") .in(stringBody("UTF-8")) .out(jsonBody[Api.ValidationResult]) @@ -165,7 +166,7 @@ object WebApp extends IOApp with LazyLogging { def addQuizzProvider( quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - )(request: Api.AddQuizz) = { + )(request: Api.AddQuizz): Future[Either[Nothing, AddQuizzResponse]] = { import mindmup._ val newQuizzOrError: Either[circe.Error, Quizz] = Parser.parseInput(request.mindmupSource).map(_.toQuizz.copy(id = request.id)) @@ -181,11 +182,13 @@ object WebApp extends IOApp with LazyLogging { } def feedbackProvider( - quizzes: Ref[IO, Either[String, Map[String, Quizz]]] - )(feedback: Api.Feedback): Future[Either[Unit, FeedbackResponse]] = { - val log = new LogFeedbackSender[IO] - val useSlack = config.getBoolean("feedback.slack.use") - val request = Api.QuizzQuery(feedback.quizzId, feedback.path) + quizzes: Ref[IO, Either[String, Map[String, Quizz]]], + feedbackSenders: List[FeedbackSender[IO]] + )(feedback: Api.FeedbackSend): Future[Either[Unit, FeedbackResponse]] = { + // val log = new LogFeedbackSender[IO] + // val useSlack = config.getBoolean("feedback.slack.use") + // val useDb = config.getBoolean("database.use") + val request = Api.QuizzQuery(feedback.quizzId, feedback.path) val quizzState: IO[Either[String, Api.QuizzState]] = for { q <- quizzes.get result = q.flatMap(quizzes => Logic.calculateStateOnPath(request, quizzes)) @@ -193,11 +196,9 @@ object WebApp extends IOApp with LazyLogging { val p = quizzState.flatMap { case Right(quizzState) => - if (useSlack) { - val slack = new SlackFeedbackSender[IO](config.getString("feedback.slack.token")) - log.send(feedback, quizzState).flatMap(_ => slack.send(feedback, quizzState)) - } else - log.send(feedback, quizzState) + feedbackSenders + .traverse(_.send(feedback, quizzState)) + case Left(error) => IO.raiseError(new Exception(s"Can't process feedback: $error")) } @@ -214,8 +215,23 @@ object WebApp extends IOApp with LazyLogging { } override def run(args: List[String]): IO[ExitCode] = { - import akka.http.scaladsl.server.Directives._ + val logSender = new LogFeedbackSender[IO].some + val feedbackSlack = + if (config.getBoolean("feedback.slack.use")) + Some(new SlackFeedbackSender[IO](config.getString("feedback.slack.token"))) + else none[FeedbackSender[IO]] + + val databaseFeedbackSender: Option[FeedbackDBSender] = { + if (config.getBoolean("database.use")) { + val a: Clock[IO] = implicitly[Clock[IO]] + new FeedbackDBSender(quizz.db.databaseConfig(config))(a).some + } else + None + } + val feedbackSenders = List(logSender, feedbackSlack, databaseFeedbackSender).flatten + + import akka.http.scaladsl.server.Directives._ val port = 8080 def bindingFuture( @@ -225,7 +241,7 @@ object WebApp extends IOApp with LazyLogging { val route = routeEndpoint.toRoute(routeWithPathProvider(quizzes)) val routeStart = routeEndpointStart.toRoute(routeWithoutPathProvider(quizzes)) val routeList = listQuizzes.toRoute(quizListProvider(quizzes)) - val routeFeedback = feedback.toRoute(feedbackProvider(quizzes)) + val routeFeedback = feedback.toRoute(feedbackProvider(quizzes, feedbackSenders)) val add = addQuizz.toRoute(addQuizzProvider(quizzes)) val validateRoute = validateEndpoint.toRoute(validateProvider) implicit val system: ActorSystem = ActorSystem("my-system") @@ -247,8 +263,15 @@ object WebApp extends IOApp with LazyLogging { Right(ExamplesData.quizzes) } + val initDb = + if (config.getBoolean("database.use")) + DatabaseInitializer.initDatabase(quizz.db.databaseConfig(config)) + else + IO.unit + logger.info(s"Server is online on port $port") for { + _ <- initDb quizzesRef <- Ref.of[IO, Either[String, Map[String, Quizz]]]("Not yet loaded".asLeft[Map[String, Quizz]]) quizzesOrError <- loadQuizzes() diff --git a/src/test/scala/mindmup/ParserTest.scala b/src/test/scala/mindmup/ParserTest.scala index 3a11801..3af6c0b 100644 --- a/src/test/scala/mindmup/ParserTest.scala +++ b/src/test/scala/mindmup/ParserTest.scala @@ -3,7 +3,7 @@ package mindmup import cats.syntax.either._ import cats.syntax.option._ import io.circe -import mindmup.V3IdString.{Idea, Mindmap} +import mindmup.V3IdString.{ Idea, Mindmap } import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers