This commit is contained in:
cheykrym 2025-08-05 08:26:04 +03:00
parent 2afe7e619d
commit 7762e1de89
13 changed files with 341 additions and 36 deletions

View File

@ -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
)
)

View File

@ -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();

View File

@ -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)
}
}

View File

@ -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]
}

View File

@ -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]
}

View File

@ -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)
}

View File

@ -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")))
)
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
}
}

View File

@ -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)
RealIpAndUserAgentMiddleware(docsRoutes <+> httpRoutes)
}
}

View File

@ -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
}
}

View File

@ -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"
)
}
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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")
}
}
}