diff --git a/build.sbt b/build.sbt index be229f4..a0dfba0 100644 --- a/build.sbt +++ b/build.sbt @@ -11,6 +11,9 @@ val openapiVersion = "0.11.10" val log4catsVersion = "2.6.0" val logbackVersion = "1.4.11" val vaultVersion = "3.6.0" +val doobieVersion = "1.0.0-RC5" +val postgresqlVersion = "42.7.4" + lazy val root = (project in file(".")) .settings( @@ -35,6 +38,12 @@ lazy val root = (project in file(".")) "org.typelevel" %% "log4cats-slf4j" % log4catsVersion, "ch.qos.logback" % "logback-classic" % logbackVersion, - "org.typelevel" %% "vault" % vaultVersion + "org.typelevel" %% "vault" % vaultVersion, + + // Database + "org.tpolecat" %% "doobie-core" % doobieVersion, + "org.tpolecat" %% "doobie-hikari" % doobieVersion, + "org.tpolecat" %% "doobie-postgres" % doobieVersion, + "org.postgresql" % "postgresql" % postgresqlVersion ) ) diff --git a/src/main/resources/db/migration/V1__Initial_schema.sql b/src/main/resources/db/migration/V1__Initial_schema.sql new file mode 100644 index 0000000..ea8c26d --- /dev/null +++ b/src/main/resources/db/migration/V1__Initial_schema.sql @@ -0,0 +1,139 @@ +BEGIN; + +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql AS +$$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$; + +COMMIT; + +-- Таблица привилегий (например: "Администратор", "Оператор склада", "Менеджер заказов") +CREATE TABLE user_privileges ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, -- internal code (e.g., 'admin', 'warehouse_operator') + name TEXT NOT NULL, -- display name + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица прав (перечень отдельных действий — CRUD по сущностям) +CREATE TABLE user_permissions ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, -- internal code (e.g., 'product.view', 'order.update') + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Связь привилегий и разрешений (многие ко многим) +CREATE TABLE user_privilege_permissions ( + privilege_id INTEGER REFERENCES user_privileges(id) ON DELETE CASCADE, + permission_id INTEGER REFERENCES user_permissions(id) ON DELETE CASCADE, + PRIMARY KEY (privilege_id, permission_id) +); + +-- Таблица юзеров +-- CREATE TABLE user_employees ( +-- id SERIAL PRIMARY KEY, +-- full_name TEXT, +-- login TEXT UNIQUE, +-- password_hash TEXT NOT NULL, +-- is_active BOOLEAN DEFAULT FALSE, +-- privilege_id INTEGER REFERENCES user_privileges(id), +-- created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +-- ); +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + full_name TEXT, + login TEXT UNIQUE, + password_hash TEXT NOT NULL, + + is_blocked BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE, + privilege_id INTEGER REFERENCES user_privileges(id), + + -- Баланс + --btc BIGINT DEFAULT 0, + --btc_balance NUMERIC(20, 8) DEFAULT 0.0, + + -- Конфиденциальность + is_searchable BOOLEAN DEFAULT TRUE, + allow_message_forwarding BOOLEAN DEFAULT TRUE, + allow_messages_from_non_contacts BOOLEAN DEFAULT TRUE, + show_profile_photo_to_non_contacts BOOLEAN DEFAULT TRUE, + last_seen_visibility SMALLINT DEFAULT 0 CHECK (last_seen_visibility IN (0, 1, 2)), + last_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + -- Биография + bio TEXT, + show_bio_to_non_contacts BOOLEAN DEFAULT TRUE, + + -- Сторисы + show_stories_to_non_contacts BOOLEAN DEFAULT TRUE, + + -- Разрешения на чаты + allow_server_chats BOOLEAN DEFAULT TRUE, + public_invite_permission SMALLINT DEFAULT 0 CHECK (public_invite_permission IN (0, 1, 2)), + group_invite_permission SMALLINT DEFAULT 0 CHECK (group_invite_permission IN (0, 1, 2)), + --chat_invite_permission SMALLINT DEFAULT 0 CHECK (chat_invite_permission IN (0, 1, 2)), + + -- Звонки + call_permission SMALLINT DEFAULT 0 CHECK (call_permission IN (0, 1, 2)), + + -- Автоудаление аккаунта + auto_delete_after_days INTEGER CHECK (auto_delete_after_days IS NULL OR auto_delete_after_days > 0), + + -- Автоудаление сообщений + force_auto_delete_messages_in_private BOOLEAN DEFAULT FALSE, + max_message_auto_delete_seconds INTEGER CHECK (max_message_auto_delete_seconds IS NULL OR max_message_auto_delete_seconds >= 0), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ======================= +-- Таблица сессий +-- ======================= +CREATE TABLE user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + + access_token TEXT NOT NULL, -- JWT + refresh_token TEXT NOT NULL, -- JWT + + ip_address TEXT, + user_agent TEXT, + + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_refresh_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ======================= +-- Функция автообновления last_refresh_at при смене токенов +-- ======================= +CREATE OR REPLACE FUNCTION update_last_refresh_at() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.access_token IS DISTINCT FROM OLD.access_token THEN + NEW.last_refresh_at := CURRENT_TIMESTAMP; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ======================= +-- Триггер на обновление +-- ======================= +CREATE TRIGGER trg_update_last_refresh_at +BEFORE UPDATE ON user_sessions +FOR EACH ROW +EXECUTE FUNCTION update_last_refresh_at(); diff --git a/src/main/scala/org/yobble/scala_monolith/Main.scala b/src/main/scala/org/yobble/scala_monolith/Main.scala index 1172274..ff12d6c 100644 --- a/src/main/scala/org/yobble/scala_monolith/Main.scala +++ b/src/main/scala/org/yobble/scala_monolith/Main.scala @@ -1,34 +1,41 @@ package org.yobble.scala_monolith -import cats.effect.{ExitCode, IO, IOApp} +import cats.effect.{ExitCode, IO, IOApp, Resource} import org.http4s.ember.server.EmberServerBuilder import org.http4s.server.Router import com.comcast.ip4s._ import org.yobble.scala_monolith.api.route.Routes +import org.yobble.scala_monolith.config.{Database, DatabaseConfig} import org.yobble.scala_monolith.middleware.GlobalErrorHandler import cats.implicits._ +import doobie.hikari.HikariTransactor object Main extends IOApp { override def run(args: List[String]): IO[ExitCode] = { - val httpApp = Router("/" -> Routes.all).orNotFound - val httpAppWithMiddleware = GlobalErrorHandler.withGlobalErrorHandler(httpApp) + val resources: Resource[IO, HikariTransactor[IO]] = for { + dbConfig <- Resource.eval(DatabaseConfig.load[IO]) + transactor <- Database.transactor[IO](dbConfig) + } yield transactor - val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080) + resources.use { transactor => + val httpApp = Router("/" -> Routes.all(transactor)).orNotFound + val httpAppWithMiddleware = GlobalErrorHandler.withGlobalErrorHandler(httpApp) - EmberServerBuilder - .default[IO] - .withHost(ipv4"0.0.0.0") - .withPort(Port.fromInt(port).get) - .withHttpApp(httpAppWithMiddleware) - .build - .use { server => - // Здесь вручную выводим "localhost" в лог, хотя слушаем "0.0.0.0" - for { - _ <- IO.println(s"Server running at http://localhost:$port") - _ <- IO.println(s"Swagger UI available at http://localhost:$port/docs") - _ <- IO.never - } yield () - } - .as(ExitCode.Success) + val port = sys.env.get("HTTP_PORT").flatMap(_.toIntOption).getOrElse(8080) + + EmberServerBuilder + .default[IO] + .withHost(ipv4"0.0.0.0") + .withPort(Port.fromInt(port).get) + .withHttpApp(httpAppWithMiddleware) + .build + .use { server => + for { + _ <- IO.println(s"Server running at http://localhost:$port") + _ <- IO.println(s"Swagger UI available at http://localhost:$port/docs") + _ <- IO.never + } yield () + } + }.as(ExitCode.Success) } -} \ No newline at end of file +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/dto/LoginRequest.scala b/src/main/scala/org/yobble/scala_monolith/api/dto/LoginRequest.scala new file mode 100644 index 0000000..c5fb9ef --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/dto/LoginRequest.scala @@ -0,0 +1,11 @@ +package org.yobble.scala_monolith.api.dto + +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} +import io.circe.{Decoder, Encoder} + +case class LoginRequest(login: String, password: String) + +object LoginRequest { + implicit val decoder: Decoder[LoginRequest] = deriveDecoder[LoginRequest] + implicit val encoder: Encoder[LoginRequest] = deriveEncoder[LoginRequest] +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/dto/LoginResponse.scala b/src/main/scala/org/yobble/scala_monolith/api/dto/LoginResponse.scala new file mode 100644 index 0000000..48d0dd4 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/dto/LoginResponse.scala @@ -0,0 +1,11 @@ +package org.yobble.scala_monolith.api.dto + +import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} +import io.circe.{Decoder, Encoder} + +case class LoginResponse(accessToken: String, refreshToken: String) + +object LoginResponse { + implicit val decoder: Decoder[LoginResponse] = deriveDecoder[LoginResponse] + implicit val encoder: Encoder[LoginResponse] = deriveEncoder[LoginResponse] +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala b/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala new file mode 100644 index 0000000..b5586c5 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/api/endpoint/auth/AuthEndpoints.scala @@ -0,0 +1,16 @@ +package org.yobble.scala_monolith.api.endpoint.auth + +import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse} +import sttp.tapir._ +import sttp.tapir.generic.auto._ +import sttp.tapir.json.circe._ + +object AuthEndpoints { + + val loginEndpoint: PublicEndpoint[LoginRequest, String, LoginResponse, Any] = + endpoint.post + .in("auth" / "login") + .in(jsonBody[LoginRequest]) + .out(jsonBody[LoginResponse]) + .errorOut(stringBody) +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala index 84cba4e..c3395a2 100644 --- a/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala +++ b/src/main/scala/org/yobble/scala_monolith/api/route/AllServerEndpoints.scala @@ -1,14 +1,29 @@ package org.yobble.scala_monolith.api.route import cats.effect.IO +import doobie.Transactor +import org.yobble.scala_monolith.api.endpoint.auth.AuthEndpoints import sttp.tapir.server.ServerEndpoint import org.yobble.scala_monolith.api.endpoint.PingEndpoint import org.yobble.scala_monolith.api.endpoint.ErrorsEndpoint import org.yobble.scala_monolith.api.response.BaseResponse +import org.yobble.scala_monolith.repository.UserRepositoryImpl +import org.yobble.scala_monolith.service.AuthService object AllServerEndpoints { - val all: List[ServerEndpoint[Any, IO]] = List( - PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))), - ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors"))) - ) -} \ No newline at end of file + def all(transactor: Transactor[IO]): List[ServerEndpoint[Any, IO]] = { + + val authService = new AuthService(new UserRepositoryImpl(transactor)) + + val authEndpoints = List( + AuthEndpoints.loginEndpoint.serverLogic(authService.login) + ) + + val otherEndpoints = List( + PingEndpoint.pingEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "pong"))), + ErrorsEndpoint.errorsEndpoint.serverLogicSuccess(_ => IO.pure(BaseResponse(message = "errors"))) + ) + + authEndpoints ++ otherEndpoints + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala b/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala index 250ac3f..5d5eab8 100644 --- a/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala +++ b/src/main/scala/org/yobble/scala_monolith/api/route/Routes.scala @@ -1,6 +1,7 @@ package org.yobble.scala_monolith.api.route import cats.effect.IO +import doobie.Transactor import org.http4s.HttpRoutes import sttp.tapir.server.http4s.Http4sServerInterpreter import sttp.tapir.swagger.bundle.SwaggerInterpreter @@ -8,17 +9,19 @@ import org.yobble.scala_monolith.middleware.RealIpAndUserAgentMiddleware import cats.syntax.semigroupk._ object Routes { - private val allServerEndpoints = AllServerEndpoints.all + def all(transactor: Transactor[IO]): HttpRoutes[IO] = { + val allServerEndpoints = AllServerEndpoints.all(transactor) - private val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints) + val httpRoutes = Http4sServerInterpreter[IO]().toRoutes(allServerEndpoints) - private val docsRoutes = Http4sServerInterpreter[IO]().toRoutes( - SwaggerInterpreter().fromServerEndpoints[IO]( - allServerEndpoints, - "scala_monolith API", - "1.0" + val docsRoutes = Http4sServerInterpreter[IO]().toRoutes( + SwaggerInterpreter().fromServerEndpoints[IO]( + allServerEndpoints, + "scala_monolith API", + "1.0" + ) ) - ) - val all: HttpRoutes[IO] = RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes) -} \ No newline at end of file + RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes) + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/config/Database.scala b/src/main/scala/org/yobble/scala_monolith/config/Database.scala new file mode 100644 index 0000000..771e284 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/config/Database.scala @@ -0,0 +1,22 @@ +package org.yobble.scala_monolith.config + +import cats.effect.{Async, Resource} +import doobie.hikari.HikariTransactor +import org.yobble.scala_monolith.config.DatabaseConfig +import scala.concurrent.ExecutionContext + +object Database { + + def transactor[F[_]: Async](config: DatabaseConfig): Resource[F, HikariTransactor[F]] = { + for { + ec <- Resource.eval(Async[F].executionContext) + transactor <- HikariTransactor.newHikariTransactor[F]( + driverClassName = "org.postgresql.Driver", + url = config.url, + user = config.user, + pass = config.password, + connectEC = ec + ) + } yield transactor + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/config/DatabaseConfig.scala b/src/main/scala/org/yobble/scala_monolith/config/DatabaseConfig.scala new file mode 100644 index 0000000..b0ece86 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/config/DatabaseConfig.scala @@ -0,0 +1,22 @@ +package org.yobble.scala_monolith.config + +import cats.effect.Sync + +case class DatabaseConfig( + url: String, + user: String, + password: String +) + +object DatabaseConfig { + def load[F[_]: Sync]: F[DatabaseConfig] = { + Sync[F].delay { + // TODO: Load from a proper config file (e.g., application.conf) + DatabaseConfig( + url = "jdbc:postgresql://localhost:5101/yobble_db", + user = "yobble_app_user", + password = "strong_password_here" + ) + } + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/models/User.scala b/src/main/scala/org/yobble/scala_monolith/models/User.scala new file mode 100644 index 0000000..46faafd --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/models/User.scala @@ -0,0 +1,11 @@ +package org.yobble.scala_monolith.models + +import doobie.Read +import doobie.util.meta.Meta +import java.util.UUID + +case class User(id: UUID, login: String, passwordHash: String) derives Read + +object User { + implicit val uuidMeta: Meta[UUID] = Meta[String].timap(UUID.fromString)(_.toString) +} diff --git a/src/main/scala/org/yobble/scala_monolith/repository/UserRepository.scala b/src/main/scala/org/yobble/scala_monolith/repository/UserRepository.scala new file mode 100644 index 0000000..28a8906 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/repository/UserRepository.scala @@ -0,0 +1,20 @@ +package org.yobble.scala_monolith.repository + +import cats.effect.IO +import doobie.implicits._ +import doobie.postgres.implicits._ +import doobie.Transactor +import org.yobble.scala_monolith.models.User + +trait UserRepository { + def findByLogin(login: String): IO[Option[User]] +} + +class UserRepositoryImpl(transactor: Transactor[IO]) extends UserRepository { + override def findByLogin(login: String): IO[Option[User]] = { + sql"SELECT id, login, password_hash as passwordHash FROM users WHERE login = $login" + .query[User] + .option + .transact(transactor) + } +} diff --git a/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala b/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala new file mode 100644 index 0000000..25cae55 --- /dev/null +++ b/src/main/scala/org/yobble/scala_monolith/service/AuthService.scala @@ -0,0 +1,19 @@ +package org.yobble.scala_monolith.service + +import cats.effect.IO +import org.yobble.scala_monolith.api.dto.{LoginRequest, LoginResponse} +import org.yobble.scala_monolith.repository.UserRepository + +class AuthService(userRepository: UserRepository) { + + def login(request: LoginRequest): IO[Either[String, LoginResponse]] = { + userRepository.findByLogin(request.login).map { + case Some(user) if user.passwordHash == request.password => + // TODO: Implement proper password hashing (e.g., with bcrypt) + // TODO: Implement real token generation + Right(LoginResponse(accessToken = "fake-access-token", refreshToken = "fake-refresh-token")) + case _ => + Left("Invalid login or password") + } + } +}