diff --git a/.env b/.env index e69de29..48d0dc9 100644 --- a/.env +++ b/.env @@ -0,0 +1,15 @@ +LOGIFLOW_DATABASE_HOST=postgres +LOGIFLOW_DATABASE_PORT=5432 +LOGIFLOW_DATABASE_USER=postgres +LOGIFLOW_DATABASE_PASSWORD=pass1234 +LOGIFLOW_DATABASE_NAME=logiflow +LOGIFLOW_DATABASE_SSLMODE=disable + +LOGIFLOW_REDIS_ADDR=redis:6379 +LOGIFLOW_REDIS_PASSWORD=redis123 +LOGIFLOW_JWT_KEY=98c5772ae16aaa4fd0013eb338252a93b198fb40e9337506334b3aeb21abbe4cd9289cdd + +GOOSE_DRIVER=postgres +GOOSE_DBSTRING=postgres://${LOGIFLOW_DATABASE_USER}:${LOGIFLOW_DATABASE_PASSWORD}@localhost:${LOGIFLOW_DATABASE_PORT}/${LOGIFLOW_DATABASE_NAME}?sslmode=disable +GOOSE_MIGRATION_DIR=./migrations +GOOSE_TABLE=goose_migrations \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..a97b08c --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/anxi0uz/logiflow/internal/config" + "github.com/golang-cz/devslog" +) + +func NewDevLogger() { + opts := &devslog.Options{ + MaxSlicePrintSize: 4, + SortKeys: true, + TimeFormat: "15:04:05.000", + NewLineAfterLog: true, + DebugColor: devslog.Cyan, + StringerFormatter: true, + } + + handler := devslog.NewHandler(os.Stdout, opts) + logger := slog.New(handler) + + slog.SetDefault(logger) +} + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + NewDevLogger() + + cfg, err := config.NewConfig(ctx, "configs/config.toml") + if err != nil { + slog.ErrorContext(ctx, "Cant load configs", slog.String("Error", err.Error())) + } + + slog.SetLogLoggerLevel(cfg.Logiflow.LogLevel) +} diff --git a/go.mod b/go.mod index 1a1d379..28adfba 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/anxi0uz/logiflow go 1.25.7 require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-cz/devslog v0.0.15 github.com/huandu/go-sqlbuilder v1.39.1 github.com/jackc/pgx/v5 v5.8.0 github.com/joho/godotenv v1.5.1 @@ -10,6 +12,8 @@ require ( github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.2 + github.com/oapi-codegen/runtime v1.2.0 + github.com/pressly/goose/v3 v3.27.0 github.com/redis/go-redis/v9 v9.18.0 ) @@ -18,17 +22,21 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pelletier/go-toml v1.9.5 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 171491a..131a1cd 100644 --- a/go.sum +++ b/go.sum @@ -9,10 +9,18 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-cz/devslog v0.0.15 h1:ejoBLTCwJHWGbAmDf2fyTJJQO3AkzcPjw8SC9LaOQMI= +github.com/golang-cz/devslog v0.0.15/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= @@ -32,8 +40,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= @@ -44,16 +52,30 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -64,14 +86,26 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/internal/api/api.swagger.yaml b/internal/api/api.swagger.yaml index 24b3496..10d2b6c 100644 --- a/internal/api/api.swagger.yaml +++ b/internal/api/api.swagger.yaml @@ -1,5 +1,345 @@ openapi: 3.0.3 info: - title: logiflow - description: API для информационной системы для логистической компании - version: 1.0.0 + title: Logiflow API + description: API для логистической информационной системы + version: 0.1.0 + contact: + name: anxi0uz + +servers: + - url: http://localhost:3001 + description: Local development + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ApiResponse: + type: object + properties: + status: + type: integer + nullable: false + data: + type: object + nullable: true + success: + type: boolean + nullable: false + requestID: + type: string + nullable: false + + ErrorResponse: + type: object + properties: + status: + type: integer + message: + type: string + requestID: + type: string + + User: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + format: email + slug: + type: string + fullName: + type: string + nullable: true + avatarUrl: + type: string + nullable: true + passwordHash: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + nullable: true + lastLoginAt: + type: string + format: date-time + nullable: true + + UserResponse: + type: object + properties: + id: + type: string + format: uuid + email: + type: string + format: email + slug: + type: string + fullName: + type: string + nullable: true + avatarUrl: + type: string + nullable: true + createdAt: + type: string + format: date-time + + RegisterRequest: + type: object + required: [email, password, fullName] + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + fullName: + type: string + + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + + TokenRefreshRequest: + type: object + required: [refreshToken] + properties: + refreshToken: + type: string + description: Refresh токен, полученный при логине + + TokenResponse: + type: object + required: [accessToken, refreshToken] + properties: + accessToken: + type: string + description: Access токен (JWT) + refreshToken: + type: string + description: Refresh токен (opaque token, rotation) + expiresIn: + type: integer + description: Время жизни access токена в секундах + + UserUpdate: + type: object + properties: + fullName: + type: string + minLength: 2 + maxLength: 150 + nullable: true + avatarUrl: + type: string + format: uri + nullable: true + password: + type: string + minLength: 8 + description: Новый пароль (если меняется) + currentPassword: + type: string + minLength: 8 + description: Текущий пароль (обязателен при смене пароля) + + UserDeleteRequest: + type: object + required: [password] + properties: + password: + type: string + description: Текущий пароль для подтверждения удаления + +paths: + /auth/register: + post: + operationId: authRegister + summary: Регистрация нового пользователя + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRequest" + responses: + "201": + description: Пользователь успешно создан + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + "400": + description: Некорректные данные + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Email уже занят + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /auth/login: + post: + operationId: authLogin + summary: Авторизация пользователя + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Успешный вход + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + "401": + description: Неверный email или пароль + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /auth/refresh: + post: + operationId: authRefresh + summary: Обновление access-токена через refresh-токен + tags: [Auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TokenRefreshRequest" + responses: + "200": + description: Токены успешно обновлены + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + "401": + description: Недействительный или истёкший refresh-токен + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "400": + description: Некорректный запрос + + /auth/logout: + post: + operationId: authLogout + summary: Выход из системы + tags: [Auth] + security: + - BearerAuth: [] + responses: + "204": + description: Успешный выход + "401": + description: Не авторизован + + /me: + get: + operationId: getMe + summary: Получить текущего пользователя + tags: [Me] + security: + - BearerAuth: [] + responses: + "200": + description: Данные пользователя + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + "401": + description: Не авторизован + + patch: + operationId: updateMe + summary: Обновить данные текущего пользователя + tags: [Me] + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserUpdate" + responses: + "200": + description: Пользователь обновлён + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + "400": + description: Некорректные данные + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Не авторизован + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + delete: + operationId: deleteMe + summary: Удалить аккаунт + tags: [Me] + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserDeleteRequest" + responses: + "204": + description: Аккаунт удалён + "400": + description: Неверный пароль + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Не авторизован + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" diff --git a/internal/api/gen.go b/internal/api/gen.go new file mode 100644 index 0000000..6f99a22 --- /dev/null +++ b/internal/api/gen.go @@ -0,0 +1,424 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.6.0 DO NOT EDIT. +package api + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BearerAuthScopes = "BearerAuth.Scopes" +) + +// ApiResponse defines model for ApiResponse. +type ApiResponse struct { + Data *map[string]interface{} `json:"data,omitempty"` + RequestID *string `json:"requestID,omitempty"` + Status *int `json:"status,omitempty"` + Success *bool `json:"success,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + Message *string `json:"message,omitempty"` + RequestID *string `json:"requestID,omitempty"` + Status *int `json:"status,omitempty"` +} + +// LoginRequest defines model for LoginRequest. +type LoginRequest struct { + Email openapi_types.Email `json:"email"` + Password string `json:"password"` +} + +// RegisterRequest defines model for RegisterRequest. +type RegisterRequest struct { + Email openapi_types.Email `json:"email"` + FullName string `json:"fullName"` + Password string `json:"password"` +} + +// TokenRefreshRequest defines model for TokenRefreshRequest. +type TokenRefreshRequest struct { + // RefreshToken Refresh токен, полученный при логине + RefreshToken string `json:"refreshToken"` +} + +// UserDeleteRequest defines model for UserDeleteRequest. +type UserDeleteRequest struct { + // Password Текущий пароль для подтверждения удаления + Password string `json:"password"` +} + +// UserUpdate defines model for UserUpdate. +type UserUpdate struct { + AvatarUrl *string `json:"avatarUrl,omitempty"` + + // CurrentPassword Текущий пароль (обязателен при смене пароля) + CurrentPassword *string `json:"currentPassword,omitempty"` + FullName *string `json:"fullName,omitempty"` + + // Password Новый пароль (если меняется) + Password *string `json:"password,omitempty"` +} + +// AuthLoginJSONRequestBody defines body for AuthLogin for application/json ContentType. +type AuthLoginJSONRequestBody = LoginRequest + +// AuthRefreshJSONRequestBody defines body for AuthRefresh for application/json ContentType. +type AuthRefreshJSONRequestBody = TokenRefreshRequest + +// AuthRegisterJSONRequestBody defines body for AuthRegister for application/json ContentType. +type AuthRegisterJSONRequestBody = RegisterRequest + +// DeleteMeJSONRequestBody defines body for DeleteMe for application/json ContentType. +type DeleteMeJSONRequestBody = UserDeleteRequest + +// UpdateMeJSONRequestBody defines body for UpdateMe for application/json ContentType. +type UpdateMeJSONRequestBody = UserUpdate + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Авторизация пользователя + // (POST /auth/login) + AuthLogin(w http.ResponseWriter, r *http.Request) + // Выход из системы + // (POST /auth/logout) + AuthLogout(w http.ResponseWriter, r *http.Request) + // Обновление access-токена через refresh-токен + // (POST /auth/refresh) + AuthRefresh(w http.ResponseWriter, r *http.Request) + // Регистрация нового пользователя + // (POST /auth/register) + AuthRegister(w http.ResponseWriter, r *http.Request) + // Удалить аккаунт + // (DELETE /me) + DeleteMe(w http.ResponseWriter, r *http.Request) + // Получить текущего пользователя + // (GET /me) + GetMe(w http.ResponseWriter, r *http.Request) + // Обновить данные текущего пользователя + // (PATCH /me) + UpdateMe(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Авторизация пользователя +// (POST /auth/login) +func (_ Unimplemented) AuthLogin(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Выход из системы +// (POST /auth/logout) +func (_ Unimplemented) AuthLogout(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Обновление access-токена через refresh-токен +// (POST /auth/refresh) +func (_ Unimplemented) AuthRefresh(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Регистрация нового пользователя +// (POST /auth/register) +func (_ Unimplemented) AuthRegister(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Удалить аккаунт +// (DELETE /me) +func (_ Unimplemented) DeleteMe(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Получить текущего пользователя +// (GET /me) +func (_ Unimplemented) GetMe(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Обновить данные текущего пользователя +// (PATCH /me) +func (_ Unimplemented) UpdateMe(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// AuthLogin operation middleware +func (siw *ServerInterfaceWrapper) AuthLogin(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthLogin(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthLogout operation middleware +func (siw *ServerInterfaceWrapper) AuthLogout(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthLogout(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthRefresh operation middleware +func (siw *ServerInterfaceWrapper) AuthRefresh(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthRefresh(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// AuthRegister operation middleware +func (siw *ServerInterfaceWrapper) AuthRegister(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.AuthRegister(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteMe operation middleware +func (siw *ServerInterfaceWrapper) DeleteMe(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMe operation middleware +func (siw *ServerInterfaceWrapper) GetMe(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateMe operation middleware +func (siw *ServerInterfaceWrapper) UpdateMe(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateMe(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/login", wrapper.AuthLogin) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/logout", wrapper.AuthLogout) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/refresh", wrapper.AuthRefresh) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/auth/register", wrapper.AuthRegister) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/me", wrapper.DeleteMe) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/me", wrapper.GetMe) + }) + r.Group(func(r chi.Router) { + r.Patch(options.BaseURL+"/me", wrapper.UpdateMe) + }) + + return r +} diff --git a/internal/config/config.go b/internal/config/config.go index a8fe62e..ea9bcd0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -66,8 +66,8 @@ func NewConfig(ctx context.Context, configPath string) (*Config, error) { k := koanf.New(".") - if err := k.Load(env.Provider("HANDBOOKS_", ".", func(s string) string { - return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, "HANDBOOKS_")), "_", ".") + if err := k.Load(env.Provider("LOGIFLOW_", ".", func(s string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(s, "LOGIFLOW_")), "_", ".") }), nil); err != nil { return nil, fmt.Errorf("ошибка загрузки ENV: %w", err) } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 5b17535..5ebebcc 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -2,9 +2,18 @@ package database import ( "context" + "database/sql" + "fmt" "log/slog" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pressly/goose/v3" +) + +const ( + pgDriverName = "pgx" + migrationsDir = "./migrations" + gooseDriverName = "postgres" ) func NewConnectionPool(ctx context.Context, connectionString string) (*pgxpool.Pool, error) { @@ -21,3 +30,29 @@ func NewConnectionPool(ctx context.Context, connectionString string) (*pgxpool.P return pool, nil } + +func RunMigrations(ctx context.Context, dbURL string) error { + db, err := sql.Open(pgDriverName, dbURL) + if err != nil { + return fmt.Errorf("не удалось открыть соединение для миграций: %w", err) + } + defer db.Close() + + goose.SetDialect(gooseDriverName) + + statusErr := goose.Status(db, migrationsDir) + if statusErr != nil { + slog.WarnContext(ctx, "Не удалось получить статус миграций", "error", statusErr) + } + + current, _ := goose.GetDBVersion(db) + slog.InfoContext(ctx, "Текущая версия БД", "version", current) + + slog.DebugContext(ctx, "Запуск миграций Goose...") + if err := goose.Up(db, migrationsDir); err != nil { + return fmt.Errorf("ошибка применения миграций: %w", err) + } + + slog.InfoContext(ctx, "Миграции успешно применены или уже актуальны") + return nil +} diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go new file mode 100644 index 0000000..e69de29 diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..49ebbf5 --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Slug string `db:"slug" json:"slug"` + PasswordHash string `db:"password_hash" json:"-"` + FullName *string `db:"full_name" json:"full_name,omitempty"` + AvatarURL *string `db:"avatar_url" json:"avatar_url,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at,omitempty"` + LastLoginAt *time.Time `db:"last_login_at" json:"last_login_at,omitempty"` +} diff --git a/migrations/20260303130358_init.sql b/migrations/20260303130358_init.sql index c902c87..5f14584 100644 --- a/migrations/20260303130358_init.sql +++ b/migrations/20260303130358_init.sql @@ -1,9 +1,9 @@ -- +goose Up -- +goose StatementBegin -CREATE EXTENSION IF NOT EXISTS pg_tgrm; +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -DROP EXTENSION IF EXISTS pg_tgrm; +DROP EXTENSION IF EXISTS pg_trgm; -- +goose StatementEnd diff --git a/migrations/20260303130413_create_user.sql b/migrations/20260303130413_create_user.sql index 39fb695..9b08c46 100644 --- a/migrations/20260303130413_create_user.sql +++ b/migrations/20260303130413_create_user.sql @@ -7,7 +7,6 @@ CREATE TABLE IF NOT EXISTS users ( password_hash VARCHAR(255) NOT NULL, full_name VARCHAR(150), avatar_url VARCHAR(512), - role VARCHAR(50) DEFAULT 'student', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ, last_login_at TIMESTAMPTZ diff --git a/migrations/20260303130440_create_vehicles.sql b/migrations/20260303130440_create_vehicles.sql index b9c449e..ce652b3 100644 --- a/migrations/20260303130440_create_vehicles.sql +++ b/migrations/20260303130440_create_vehicles.sql @@ -1,9 +1,22 @@ -- +goose Up -- +goose StatementBegin -SELECT 'up SQL query'; +CREATE TABLE IF NOT EXISTS vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + plate_number VARCHAR(20) UNIQUE NOT NULL, + brand VARCHAR(50), + model VARCHAR(50), + year INTEGER, + capacity_kg NUMERIC(10,2), + capacity_m3 NUMERIC(10,2), + status VARCHAR(20) DEFAULT 'available', + slug VARCHAR(120) UNIQUE NOT NULL +); + + +CREATE INDEX idx_vehicles_slug ON vehicles(slug); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -SELECT 'down SQL query'; +DROP TABLE IF EXISTS vehicles; -- +goose StatementEnd diff --git a/migrations/20260303130452_create_drivers.sql b/migrations/20260303130452_create_drivers.sql index b9c449e..24b6855 100644 --- a/migrations/20260303130452_create_drivers.sql +++ b/migrations/20260303130452_create_drivers.sql @@ -1,9 +1,18 @@ -- +goose Up -- +goose StatementBegin -SELECT 'up SQL query'; +CREATE TABLE IF NOT EXISTS drivers( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, + license_number VARCHAR (50) NOT NULL, + license_expiry DATE NOT NULL, + rating NUMERIC(3,2) DEFAULT 5.00, + slug VARCHAR(120) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'available' +); -- +goose StatementEnd -- +goose Down -- +goose StatementBegin -SELECT 'down SQL query'; +DROP TABLE IF EXISTS drivers; -- +goose StatementEnd diff --git a/migrations/20260303130455_create_roles.sql b/migrations/20260303130455_create_roles.sql deleted file mode 100644 index b9c449e..0000000 --- a/migrations/20260303130455_create_roles.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- +goose StatementBegin -SELECT 'up SQL query'; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -SELECT 'down SQL query'; --- +goose StatementEnd diff --git a/migrations/20260304163413_create_roles.sql b/migrations/20260304163413_create_roles.sql new file mode 100644 index 0000000..d8133fe --- /dev/null +++ b/migrations/20260304163413_create_roles.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS roles( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(20), + code VARCHAR(20) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS roles; +-- +goose StatementEnd diff --git a/migrations/20260304163423_create_users_roles.sql b/migrations/20260304163423_create_users_roles.sql new file mode 100644 index 0000000..08eab03 --- /dev/null +++ b/migrations/20260304163423_create_users_roles.sql @@ -0,0 +1,13 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS user_roles( + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role_id UUID REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS user_roles; +-- +goose StatementEnd diff --git a/pkg/storage.go b/pkg/storage.go index 5de367d..fda27e7 100644 --- a/pkg/storage.go +++ b/pkg/storage.go @@ -1,4 +1,4 @@ -package models +package storage import ( "context"