diff --git a/README.md b/README.md index acd3799..9060b41 100644 --- a/README.md +++ b/README.md @@ -2,44 +2,27 @@ Бэкенд для логистической платформы. Управление заказами на перевозку, водителями, транспортом и складами. При создании заказа строится реальный маршрут через OSRM, считается стоимость перевозки, трекинг водителя в реальном времени через WebSocket. +Репозиторий: [github.com/anxi0uz/logiflow](https://github.com/anxi0uz/logiflow.git) + ## Стек -- **Go 1.25** — Chi v5, oapi-codegen, pgx/v5, go-redis +- **Go 1.25** — Chi v5, oapi-codegen, pgx/v5, go-redis, errgroup - **PostgreSQL** — миграции через Goose - **Redis** — хранение JWT access/refresh токенов - **Nominatim** — геокодинг адресов (OpenStreetMap, без ключа) - **OSRM** — построение маршрутов и расчёт дистанции -- **Prometheus + Grafana** — мониторинг +- **Prometheus + Grafana** — мониторинг HTTP метрик - **Podman** — контейнеризация -## Запуск +## Быстрый старт -```bash -# поднять все сервисы -make up - -# пересобрать и поднять -make build-up - -# остановить -make down - -# для деплоя (pull + build + up) -make deploy -``` - -Перед запуском создать сеть: +Создать сеть: ```bash podman network create LogiflowNetwork ``` -Скопировать `.env` и заполнить секреты: +Создать `.env` и заполнить: ```bash -cp .env.example .env -``` - -Обязательные переменные: -``` LOGIFLOW_DATABASE_USER= LOGIFLOW_DATABASE_PASSWORD= LOGIFLOW_DATABASE_NAME= @@ -47,16 +30,44 @@ LOGIFLOW_REDIS_PASSWORD= LOGIFLOW_JWT_KEY= ``` +Поднять все сервисы: +```bash +make up # запуск +make build-up # пересборка + запуск +make down # остановка +make deploy # pull + пересборка + запуск (для деплоя) +``` + +## Сервисы + +| Сервис | Адрес | +|---|---| +| API | `http://localhost:3001` | +| Grafana | `http://localhost:3000` | +| Prometheus | `http://localhost:9090` | +| Метрики | `http://localhost:3001/metrics` | + ## Конфигурация Конфиг читается из `configs/config.toml`, переменные окружения с префиксом `LOGIFLOW_` перекрывают файл. ```toml +[server] +host = "0.0.0.0" +port = 3001 +readTimeout = "10s" +writeTimeout = "30s" +idleTimeout = "60s" + +[redis] +refreshTokenTTL = "168h" +accessTokenTTL = "24h" + [pricing] -baseFee = 5000.0 # базовая ставка, руб -perKm = 100.0 # руб/км -perKg = 15.0 # руб/кг -perM3 = 600.0 # руб/м³ +baseFee = 500.0 # базовая ставка, руб +perKm = 25.0 # руб/км +perKg = 3.0 # руб/кг +perM3 = 150.0 # руб/м³ ``` Цена заказа считается по формуле: @@ -68,12 +79,12 @@ total = baseFee + distance_km * perKm + weight_kg * perKg + volume_m3 * perM3 | Роль | Возможности | |---|---| -| `client` | Создаёт заказы, следит за своими заявками | -| `driver` | Меняет свой статус, видит назначенные маршруты | -| `manager` | Назначает водителей на заказы | -| `admin` | Создаёт профили водителей и менеджеров, видит всё | +| `client` | Создаёт и отменяет свои заказы, следит за статусом | +| `driver` | Меняет свой статус, видит назначенные заказы, двигает статус in_transit/delivered | +| `manager` | Назначает водителей на заказы, управляет статусами, смотрит отчёты | +| `admin` | Создаёт профили водителей, видит всё | -Клиенты регистрируются сами через `POST /auth/register`. Водителей и менеджеров создаёт только администратор. +Клиенты регистрируются через `POST /auth/register`. Роль назначается вручную в БД. Водителей создаёт `admin`, менеджеров — авторизованный пользователь. ## Авторизация @@ -83,6 +94,83 @@ JWT (HS256) + refresh токены. Access токен живёт 24 часа, re Authorization: ``` +## API + +### Auth +| Метод | Путь | Описание | +|---|---|---| +| POST | `/auth/register` | Регистрация клиента | +| POST | `/auth/login` | Вход, получение токенов | +| POST | `/auth/logout` | Выход, инвалидация токенов | +| POST | `/auth/refresh` | Обновление access токена | + +### Пользователь +| Метод | Путь | Описание | +|---|---|---| +| GET | `/me` | Профиль текущего пользователя | +| PATCH | `/me` | Обновить имя, аватар, пароль | +| DELETE | `/me` | Удалить аккаунт | +| GET | `/me/trips` | История поездок (driver) | + +### Заказы +| Метод | Путь | Описание | +|---|---|---| +| GET | `/orders` | Список заказов (по роли) | +| POST | `/orders` | Создать заказ | +| GET | `/orders/{id}` | Получить заказ | +| DELETE | `/orders/{id}` | Отменить заказ | +| PATCH | `/orders/{id}/status` | Обновить статус | +| GET | `/orders/{id}/route` | Маршрут заказа | +| GET | `/orders/{id}/route/ws` | WebSocket трекинг | + +### Водители +| Метод | Путь | Описание | +|---|---|---| +| GET | `/drivers` | Список водителей | +| POST | `/drivers` | Создать водителя (admin) | +| GET | `/drivers/{slug}` | Получить водителя | +| PUT | `/drivers/{slug}` | Обновить водителя | +| DELETE | `/drivers/{slug}` | Удалить водителя | +| PATCH | `/drivers/me/status` | Обновить свой статус (driver) | + +### Менеджеры +| Метод | Путь | Описание | +|---|---|---| +| GET | `/managers` | Список менеджеров | +| POST | `/managers` | Создать менеджера | +| GET | `/managers/{slug}` | Получить менеджера | +| DELETE | `/managers/{slug}` | Удалить менеджера | + +### Склады +| Метод | Путь | Описание | +|---|---|---| +| GET | `/warehouses` | Список складов | +| POST | `/warehouses` | Создать склад | +| GET | `/warehouses/{slug}` | Получить склад | +| PUT | `/warehouses/{slug}` | Обновить склад | +| DELETE | `/warehouses/{slug}` | Удалить склад | + +### Транспорт +| Метод | Путь | Описание | +|---|---|---| +| GET | `/vehicles` | Список ТС | +| POST | `/vehicles` | Добавить ТС | +| GET | `/vehicles/{slug}` | Получить ТС | +| PUT | `/vehicles/{slug}` | Обновить ТС | +| DELETE | `/vehicles/{slug}` | Удалить ТС | + +### Отчёты +| Метод | Путь | Описание | +|---|---|---| +| GET | `/reports/orders` | Отчёт по заказам (manager) | +| GET | `/reports/dashboard` | Дашборд (manager) | + +### Уведомления +| Метод | Путь | Описание | +|---|---|---| +| GET | `/notifications` | Список уведомлений | +| PATCH | `/notifications/{id}/read` | Отметить прочитанным | + ## Флоу заказа ``` @@ -110,4 +198,21 @@ orders (created_by_id → users, driver_id → drivers, manager_id → managers) notifications (user_id → users) ``` -Сервер поднимается на `localhost:3001`, Grafana на `localhost:3000`, Prometheus на `localhost:9090`. +## Мониторинг + +Prometheus собирает метрики с `/metrics`. Grafana доступна на `localhost:3000` (admin/admin). + +Доступные метрики: +- `logiflow_http_requests_total` — кол-во запросов по методу, пути, статусу +- `logiflow_http_duration_seconds` — latency запросов + +Конфиг Prometheus: `configs/prometheus.yml` +Datasource Grafana: `configs/datasources/` + +## Тесты + +Юнит тесты хендлеров через `httptest` без реальной БД: + +```bash +go test ./tests/... +``` diff --git a/configs/datasources/prometheus.yaml b/configs/datasources/prometheus.yaml new file mode 100644 index 0000000..cf8e42a --- /dev/null +++ b/configs/datasources/prometheus.yaml @@ -0,0 +1,7 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + url: http://logiflow-prometheus:9090 + isDefault: true + access: proxy diff --git a/configs/prometheus.yml b/configs/prometheus.yml index 433139b..247fb09 100644 --- a/configs/prometheus.yml +++ b/configs/prometheus.yml @@ -6,6 +6,6 @@ scrape_configs: static_configs: - targets: ["localhost:9090"] - - job_name: "handbooks" + - job_name: "logiflow" static_configs: - targets: ["app:3001"] diff --git a/go.mod b/go.mod index 325da92..a085957 100644 --- a/go.mod +++ b/go.mod @@ -27,9 +27,14 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/huandu/go-clone v1.7.3 // indirect @@ -37,16 +42,40 @@ require ( 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/josharian/intern v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mailru/easyjson v0.7.7 // 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml v1.9.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/mod v0.32.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 7aa4e93..943a813 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= @@ -8,33 +10,63 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= 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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 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/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= 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-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 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/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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= @@ -44,6 +76,7 @@ github.com/huandu/go-sqlbuilder v1.39.1 h1:uUaj41yLNTQBe7ojNF6Im1RPbHCN4zCjMRyST github.com/huandu/go-sqlbuilder v1.39.1/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -54,6 +87,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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= 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= @@ -67,6 +102,11 @@ 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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= @@ -75,16 +115,48 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.6.0 h1:4i+F2cvwBFZeplxCssNdLy3MhNzUD87mI3HnayHZkAU= +github.com/oapi-codegen/oapi-codegen/v2 v2.6.0/go.mod h1:eWHeJSohQJIINJZzzQriVynfGsnlQVh0UkN2UYYcw4Q= 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/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 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/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= 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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 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= @@ -93,15 +165,26 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/samber/slog-chi v1.19.0 h1:fl4qH5Hhk7feHtyp4CxJUt7U1TqjPrZ1uueDW9D+Cps= github.com/samber/slog-chi v1.19.0/go.mod h1:a1iIuofF2gS1ii8aXIQhC6TEguLOhOvSM958fY5hToU= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 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/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= +github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= 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= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= @@ -112,18 +195,84 @@ 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= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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= diff --git a/internal/handler/manager.go b/internal/handler/manager.go index 5e7f8b5..ef1c3fa 100644 --- a/internal/handler/manager.go +++ b/internal/handler/manager.go @@ -70,6 +70,7 @@ func (s *Server) CreateManager(w http.ResponseWriter, r *http.Request) { if err := storage.Create(ctx, "users", userModel, tx); err != nil { slog.ErrorContext(ctx, "Error while creating user", slog.String("error", err.Error())) s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + tx.Rollback(ctx) return } @@ -83,6 +84,7 @@ func (s *Server) CreateManager(w http.ResponseWriter, r *http.Request) { if err := storage.Create(ctx, "managers", managerModel, tx); err != nil { slog.ErrorContext(ctx, "Error while creating manager", slog.String("error", err.Error())) s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + tx.Rollback(ctx) return } diff --git a/internal/handler/metrics.go b/internal/handler/metrics.go new file mode 100644 index 0000000..375076d --- /dev/null +++ b/internal/handler/metrics.go @@ -0,0 +1,51 @@ +package handler + +import ( + "net/http" + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + httpRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "logiflow_http_requests_total", + Help: "Количество HTTP запросов", + }, []string{"method", "path", "status"}) + + httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "logiflow_http_duration_seconds", + Help: "Время обработки HTTP запросов", + Buckets: prometheus.DefBuckets, + }, []string{"method", "path"}) +) + +func (s *Server) MiddlewareMetrics(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + wrapped := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(wrapped, r) + + httpRequests.WithLabelValues( + r.Method, + r.URL.Path, + strconv.Itoa(wrapped.status), + ).Inc() + + httpDuration.WithLabelValues(r.Method, r.URL.Path). + Observe(time.Since(start).Seconds()) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} diff --git a/internal/handler/order.go b/internal/handler/order.go index c9ac7a1..4e2e701 100644 --- a/internal/handler/order.go +++ b/internal/handler/order.go @@ -2,10 +2,13 @@ package handler import ( "encoding/json" + "errors" "log/slog" "net/http" "github.com/anxi0uz/logiflow/internal/api" + "github.com/anxi0uz/logiflow/internal/services" + storage "github.com/anxi0uz/logiflow/pkg" openapi_types "github.com/oapi-codegen/runtime/types" ) @@ -17,10 +20,13 @@ func (s *Server) ListOrders(w http.ResponseWriter, r *http.Request, params api.L s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) return } - switch claims.Role { - case "client": - break + orders, err := s.OrderSerice.ListOrders(ctx, claims.ID, claims.Role, params) + if err != nil { + slog.ErrorContext(ctx, "Error while getting list of orders", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return } + s.JSON(w, r, http.StatusOK, orders, RespSuccess) } func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) { @@ -51,13 +57,107 @@ func (s *Server) CreateOrder(w http.ResponseWriter, r *http.Request) { }, "order") } -func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} +func (s *Server) GetOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } -func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} + order, err := s.OrderSerice.GetOrder(ctx, id, claims.ID, claims.Role) + if err != nil { + if errors.Is(err, services.ErrForbidden) { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + if errors.Is(err, storage.ErrNotFound) { + s.JSON(w, r, http.StatusNotFound, MsgNotFound, RespNotFound) + return + } + slog.ErrorContext(ctx, "Error while getting order", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, order, RespSuccess) +} -func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} +func (s *Server) CancelOrder(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + if err := s.OrderSerice.CancelOrder(ctx, id, claims.ID, claims.Role); err != nil { + if errors.Is(err, services.ErrCannotCancel) { + slog.ErrorContext(ctx, "Cant cancel order with that id", slog.String("id", id.String())) + s.JSON(w, r, http.StatusConflict, "order cant be cancelled in current status", RespError) + return + } + if errors.Is(err, services.ErrForbidden) { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + slog.ErrorContext(ctx, "error while cancelling order with that id", slog.Any("id", id), slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, "Cancelled", RespSuccess) +} + +func (s *Server) UpdateOrderStatus(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + var req api.OrderStatusUpdate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError) + return + } + order, err := s.OrderSerice.UpdateOrderStatus(ctx, id, claims.ID, claims.Role, req) + if err != nil { + switch { + case errors.Is(err, services.ErrForbidden): + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + case errors.Is(err, services.ErrCannotCancel): + s.JSON(w, r, http.StatusConflict, "cannot cancel order in current status", RespError) + default: + slog.ErrorContext(ctx, "...", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + } + return + } + s.JSON(w, r, http.StatusOK, order, RespSuccess) +} func (s *Server) GetOrdersReport(w http.ResponseWriter, r *http.Request, params api.GetOrdersReportParams) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + orders, err := s.OrderSerice.GetOrdersReport(ctx, claims.Role, params) + if err != nil { + if errors.Is(err, services.ErrForbidden) { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + slog.ErrorContext(ctx, "Error while getting orders report", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, orders, RespSuccess) } func (s *Server) GetDashboard(w http.ResponseWriter, r *http.Request) {} diff --git a/internal/handler/route.go b/internal/handler/route.go index ff2bbe2..de260b3 100644 --- a/internal/handler/route.go +++ b/internal/handler/route.go @@ -1,11 +1,26 @@ package handler import ( + "log/slog" "net/http" + "github.com/anxi0uz/logiflow/internal/models" + storage "github.com/anxi0uz/logiflow/pkg" + "github.com/huandu/go-sqlbuilder" openapi_types "github.com/oapi-codegen/runtime/types" ) -func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} +func (s *Server) GetRoute(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) { + ctx := r.Context() + route, err := storage.GetOne[models.Route](ctx, s.DB, "routes", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("order_id", id)) + }) + if err != nil { + slog.ErrorContext(ctx, "Error while getting route", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, route, RespSuccess) +} func (s *Server) RouteWebSocket(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) {} diff --git a/internal/handler/server_impl.go b/internal/handler/server_impl.go index e29ae10..0c6f066 100644 --- a/internal/handler/server_impl.go +++ b/internal/handler/server_impl.go @@ -17,6 +17,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/redis/go-redis/v9" "github.com/rs/xid" slogchi "github.com/samber/slog-chi" @@ -53,7 +54,7 @@ type Server struct { ctx context.Context Redis *redis.Client JwtKey []byte - OrderSerice services.OrderService + OrderSerice services.OrderServicer } func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Server { @@ -63,7 +64,7 @@ func NewServer(db *pgxpool.Pool, redis *redis.Client, cfg *config.Config) *Serve ctx: context.Background(), Config: cfg, JwtKey: []byte(cfg.JwtOpt.Key), - OrderSerice: *services.NewOrderService(db, *cfg), + OrderSerice: services.NewOrderService(db, *cfg), } } @@ -79,7 +80,7 @@ func (s *Server) Run() error { })) r.Use(s.MiddlewareRequestID) - + r.Use(s.MiddlewareMetrics) r.Use(slogchi.NewWithConfig(slog.Default(), slogchi.Config{ DefaultLevel: slog.LevelInfo, ClientErrorLevel: slog.LevelWarn, // 400–499 → Warn @@ -93,6 +94,7 @@ func (s *Server) Run() error { r.Use(s.AuthMiddleware) h := api.HandlerFromMux(s, r) + r.Handle("/metrics", promhttp.Handler()) srv := &http.Server{ Handler: h, @@ -121,7 +123,7 @@ func (s *Server) Run() error { func (s *Server) AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" { + if r.URL.Path == "/auth/register" || r.URL.Path == "/auth/login" || r.URL.Path == "/metrics" { next.ServeHTTP(w, r) return } diff --git a/internal/handler/user.go b/internal/handler/user.go index 597a65f..ad74732 100644 --- a/internal/handler/user.go +++ b/internal/handler/user.go @@ -263,9 +263,99 @@ func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) { } s.JSON(w, r, http.StatusOK, user, "ok") } -func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {} +func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } -func (s *Server) GetMyTrips(w http.ResponseWriter, r *http.Request) {} + var req api.UserUpdate + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.JSON(w, r, http.StatusBadRequest, MsgInvalidBody, RespError) + return + } + + user, err := storage.GetOne[models.User](ctx, s.DB, "users", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("id", claims.ID)) + }) + if err != nil { + slog.ErrorContext(ctx, "user not found", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + if req.FullName != nil { + user.FullName = *req.FullName + } + if req.AvatarUrl != nil { + user.AvatarURL = *req.AvatarUrl + } + if req.Password != nil { + if req.CurrentPassword == nil { + s.JSON(w, r, http.StatusBadRequest, "currentPassword is required", RespError) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(*req.CurrentPassword)); err != nil { + s.JSON(w, r, http.StatusBadRequest, "wrong current password", RespError) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) + if err != nil { + slog.ErrorContext(ctx, "bcrypt failed", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + user.PasswordHash = string(hash) + } + user.UpdatedAt = time.Now() + + if err := storage.Update(ctx, "users", *user, s.DB, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.Equal("id", claims.ID)) + }); err != nil { + slog.ErrorContext(ctx, "update user failed", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + s.JSON(w, r, http.StatusOK, user, RespSuccess) +} + +func (s *Server) GetMyTrips(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + claims, ok := ctx.Value("user").(*Claims) + if !ok { + slog.ErrorContext(ctx, "Error while casting claims") + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + if claims.Role != "driver" { + s.JSON(w, r, http.StatusForbidden, MsgForbidden, RespError) + return + } + + driver, err := storage.GetOne[models.Driver](ctx, s.DB, "drivers", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("user_id", claims.ID)) + }) + if err != nil { + slog.ErrorContext(ctx, "error while getting driver", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + + orders, err := storage.GetAll[models.Order](ctx, "orders", s.DB, func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("driver_id", driver.ID)) + }) + if err != nil { + slog.ErrorContext(ctx, "Error while getting orders", slog.String("error", err.Error())) + s.JSON(w, r, http.StatusInternalServerError, MsgInternalError, RespError) + return + } + s.JSON(w, r, http.StatusOK, orders, RespSuccess) + +} func (s *Server) issueTokens(w http.ResponseWriter, r *http.Request, user *models.User) { access, err := s.generateAccessToken(user, s.Config.RedisAccessTokenDur()) diff --git a/internal/services/order.go b/internal/services/order.go index 66d5006..3be5053 100644 --- a/internal/services/order.go +++ b/internal/services/order.go @@ -24,8 +24,17 @@ type OrderService struct { db *pgxpool.Pool config config.Config } +type OrderServicer interface { + CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*CreateOrderResult, error) + ListOrders(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error) + GetOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) (*models.Order, error) + CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error + UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) + GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) +} var ErrForbidden = errors.New("forbidden") +var ErrCannotCancel = errors.New("cant cancel") type CreateOrderResult struct { Order models.Order @@ -169,7 +178,7 @@ func (s *OrderService) CreateOrder(ctx context.Context, req api.OrderCreate, use func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error) { var driverID *uuid.UUID if role == "driver" { - driver, err := storage.GetOne[models.Driver](ctx, s.db, "driver", func(sb *sqlbuilder.SelectBuilder) { + driver, err := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) { sb.Where(sb.EQ("user_id", userID)) }) if err != nil { @@ -177,6 +186,7 @@ func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role st } driverID = &driver.ID } + orders, err := storage.GetAll[models.Order](ctx, "orders", s.db, func(sb *sqlbuilder.SelectBuilder) { switch role { case "client": @@ -192,6 +202,7 @@ func (s *OrderService) ListOrders(ctx context.Context, userID uuid.UUID, role st } } }) + if err != nil { return nil, fmt.Errorf("list orders: %w", err) } @@ -201,14 +212,111 @@ func (s *OrderService) GetOrder(ctx context.Context, id uuid.UUID, userID uuid.U order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) { sb.Where(sb.EQ("id", id)) }) + if err != nil { return nil, err } + if role == "client" && (order.CreatedByID == nil || *order.CreatedByID != userID) { return nil, ErrForbidden } + return order, nil } -func (s *OrderService) CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error -func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) -func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) +func (s *OrderService) CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error { + order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("id", id)) + }) + if err != nil { + return err + } + + if role == "client" && (order.CreatedByID == nil || *order.CreatedByID != userID) { + return ErrForbidden + } + + if order.Status != "pending" { + return ErrCannotCancel + } + + order.Status = "cancelled" + if err := storage.Update(ctx, "orders", order, s.db, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.EQ("id", id)) + }); err != nil { + return err + } + return nil +} +func (s *OrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) { + if role == "client" { + return nil, ErrForbidden + } + + order, err := storage.GetOne[models.Order](ctx, s.db, "orders", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("id", id)) + }) + if err != nil { + return nil, err + } + if role == "driver" { + driver, err := storage.GetOne[models.Driver](ctx, s.db, "drivers", func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.EQ("user_id", userID)) + }) + if err != nil { + return nil, fmt.Errorf("get driver: %w", err) + } + if order.DriverID == nil || *order.DriverID != driver.ID { + return nil, ErrForbidden + } + if req.Status != api.OrderStatusUpdateStatusInTransit && req.Status != api.OrderStatusUpdateStatusDelivered { + return nil, ErrForbidden + } + } + if !req.Status.Valid() { + return nil, fmt.Errorf("invalid status: %s", req.Status) + } + now := time.Now() + order.Status = string(req.Status) + if req.Status == api.OrderStatusUpdateStatusAssigned { + if req.DriverId == nil { + return nil, fmt.Errorf("driverId required when assigned") + } + order.DriverID = req.DriverId + order.AssignedAt = &now + } + if req.Status == api.OrderStatusUpdateStatusDelivered { + order.DeliveredAt = &now + } + if err := storage.Update(ctx, "orders", *order, s.db, func(sb *sqlbuilder.UpdateBuilder) { + sb.Where(sb.EQ("id", id)) + }); err != nil { + return nil, fmt.Errorf("update order: %w", err) + } + return order, nil +} +func (s *OrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) { + if role != "manager" { + return nil, ErrForbidden + } + orders, err := storage.GetAll[models.Order](ctx, "orders", s.db, func(sb *sqlbuilder.SelectBuilder) { + if params.Status != nil { + sb.Where(sb.EQ("status", string(*params.Status))) + } + if params.DriverId != nil { + sb.Where(sb.EQ("driver_id", *params.DriverId)) + } + if params.WarehouseId != nil { + sb.Where(sb.EQ("origin_warehouse_id", *params.WarehouseId)) + } + if params.From != nil { + sb.Where(sb.GE("created_at", params.From.Time)) + } + if params.To != nil { + sb.Where(sb.LE("created_at", params.To.Time)) + } + }) + if err != nil { + return nil, fmt.Errorf("get orders report: %w", err) + } + return orders, nil +} diff --git a/pkg/storage.go b/pkg/storage.go index 3e45d47..114e1b2 100644 --- a/pkg/storage.go +++ b/pkg/storage.go @@ -19,7 +19,7 @@ type Querier interface { var ErrNotFound = errors.New("not found") func GetAll[T any](ctx context.Context, table string, db Querier, opts ...func(*sqlbuilder.SelectBuilder)) ([]T, error) { - sb := sqlbuilder.NewStruct(new(T)).SelectFrom(table) + sb := sqlbuilder.NewStruct(new(T)).For(sqlbuilder.PostgreSQL).SelectFrom(table) sb.From(table) diff --git a/tests/order_handler_test.go b/tests/order_handler_test.go new file mode 100644 index 0000000..95f350b --- /dev/null +++ b/tests/order_handler_test.go @@ -0,0 +1,431 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/anxi0uz/logiflow/internal/api" + "github.com/anxi0uz/logiflow/internal/handler" + "github.com/anxi0uz/logiflow/internal/models" + "github.com/anxi0uz/logiflow/internal/services" + "github.com/google/uuid" +) + +// --- Mock --- + +type mockOrderService struct { + createOrder func(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*services.CreateOrderResult, error) + listOrders func(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error) + getOrder func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) (*models.Order, error) + cancelOrder func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error + updateOrderStatus func(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) + getOrdersReport func(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) +} + +func (m *mockOrderService) CreateOrder(ctx context.Context, req api.OrderCreate, userID uuid.UUID) (*services.CreateOrderResult, error) { + return m.createOrder(ctx, req, userID) +} +func (m *mockOrderService) ListOrders(ctx context.Context, userID uuid.UUID, role string, params api.ListOrdersParams) ([]models.Order, error) { + return m.listOrders(ctx, userID, role, params) +} +func (m *mockOrderService) GetOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) (*models.Order, error) { + return m.getOrder(ctx, id, userID, role) +} +func (m *mockOrderService) CancelOrder(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string) error { + return m.cancelOrder(ctx, id, userID, role) +} +func (m *mockOrderService) UpdateOrderStatus(ctx context.Context, id uuid.UUID, userID uuid.UUID, role string, req api.OrderStatusUpdate) (*models.Order, error) { + return m.updateOrderStatus(ctx, id, userID, role, req) +} +func (m *mockOrderService) GetOrdersReport(ctx context.Context, role string, params api.GetOrdersReportParams) ([]models.Order, error) { + return m.getOrdersReport(ctx, role, params) +} + +// --- Helpers --- + +func newTestServer(svc services.OrderServicer) *handler.Server { + return &handler.Server{ + OrderSerice: svc, + } +} + +func withClaims(r *http.Request, id uuid.UUID, role string) *http.Request { + claims := &handler.Claims{ID: id, Role: role} + ctx := context.WithValue(r.Context(), "user", claims) + return r.WithContext(ctx) +} + +func jsonBody(t *testing.T, v any) *bytes.Buffer { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + return bytes.NewBuffer(b) +} + +// --- CreateOrder --- + +func TestCreateOrder_Success(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + createOrder: func(_ context.Context, _ api.OrderCreate, _ uuid.UUID) (*services.CreateOrderResult, error) { + return &services.CreateOrderResult{ + Order: models.Order{ID: orderID, Status: "pending"}, + Route: models.Route{ID: uuid.New(), OrderID: orderID}, + }, nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodPost, "/orders", jsonBody(t, api.OrderCreate{DestinationAddress: "Moscow"})) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CreateOrder(w, r) + + if w.Code != http.StatusCreated { + t.Errorf("expected 201, got %d", w.Code) + } +} + +func TestCreateOrder_InvalidBody(t *testing.T) { + s := newTestServer(&mockOrderService{}) + r := httptest.NewRequest(http.MethodPost, "/orders", bytes.NewBufferString("not json")) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CreateOrder(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestCreateOrder_ServiceError(t *testing.T) { + svc := &mockOrderService{ + createOrder: func(_ context.Context, _ api.OrderCreate, _ uuid.UUID) (*services.CreateOrderResult, error) { + return nil, errors.New("geocode failed") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodPost, "/orders", jsonBody(t, api.OrderCreate{DestinationAddress: "Moscow"})) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CreateOrder(w, r) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- ListOrders --- + +func TestListOrders_Success(t *testing.T) { + svc := &mockOrderService{ + listOrders: func(_ context.Context, _ uuid.UUID, _ string, _ api.ListOrdersParams) ([]models.Order, error) { + return []models.Order{{ID: uuid.New(), Status: "pending"}}, nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/orders", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.ListOrders(w, r, api.ListOrdersParams{}) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestListOrders_ServiceError(t *testing.T) { + svc := &mockOrderService{ + listOrders: func(_ context.Context, _ uuid.UUID, _ string, _ api.ListOrdersParams) ([]models.Order, error) { + return nil, errors.New("db error") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/orders", nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.ListOrders(w, r, api.ListOrdersParams{}) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- GetOrder --- + +func TestGetOrder_Success(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + getOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) (*models.Order, error) { + return &models.Order{ID: orderID, Status: "pending"}, nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/orders/"+orderID.String(), nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.GetOrder(w, r, orderID) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetOrder_Forbidden(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + getOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) (*models.Order, error) { + return nil, services.ErrForbidden + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/orders/"+orderID.String(), nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.GetOrder(w, r, orderID) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestGetOrder_ServiceError(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + getOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) (*models.Order, error) { + return nil, errors.New("db error") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/orders/"+orderID.String(), nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.GetOrder(w, r, orderID) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- CancelOrder --- + +func TestCancelOrder_Success(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + cancelOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) error { + return nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodDelete, "/orders/"+orderID.String()+"/cancel", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CancelOrder(w, r, orderID) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestCancelOrder_CannotCancel(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + cancelOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) error { + return services.ErrCannotCancel + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodDelete, "/orders/"+orderID.String()+"/cancel", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CancelOrder(w, r, orderID) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409, got %d", w.Code) + } +} + +func TestCancelOrder_Forbidden(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + cancelOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) error { + return services.ErrForbidden + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodDelete, "/orders/"+orderID.String()+"/cancel", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CancelOrder(w, r, orderID) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestCancelOrder_ServiceError(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + cancelOrder: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string) error { + return errors.New("db error") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodDelete, "/orders/"+orderID.String()+"/cancel", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.CancelOrder(w, r, orderID) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- UpdateOrderStatus --- + +func TestUpdateOrderStatus_Success(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + updateOrderStatus: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string, _ api.OrderStatusUpdate) (*models.Order, error) { + return &models.Order{ID: orderID, Status: "assigned"}, nil + }, + } + s := newTestServer(svc) + body := jsonBody(t, api.OrderStatusUpdate{Status: api.OrderStatusUpdateStatusAssigned}) + r := httptest.NewRequest(http.MethodPatch, "/orders/"+orderID.String()+"/status", body) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.UpdateOrderStatus(w, r, orderID) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestUpdateOrderStatus_Forbidden(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + updateOrderStatus: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string, _ api.OrderStatusUpdate) (*models.Order, error) { + return nil, services.ErrForbidden + }, + } + s := newTestServer(svc) + body := jsonBody(t, api.OrderStatusUpdate{Status: api.OrderStatusUpdateStatusAssigned}) + r := httptest.NewRequest(http.MethodPatch, "/orders/"+orderID.String()+"/status", body) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.UpdateOrderStatus(w, r, orderID) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestUpdateOrderStatus_InvalidBody(t *testing.T) { + s := newTestServer(&mockOrderService{}) + r := httptest.NewRequest(http.MethodPatch, "/orders/"+uuid.New().String()+"/status", bytes.NewBufferString("bad json")) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.UpdateOrderStatus(w, r, uuid.New()) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestUpdateOrderStatus_ServiceError(t *testing.T) { + orderID := uuid.New() + svc := &mockOrderService{ + updateOrderStatus: func(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ string, _ api.OrderStatusUpdate) (*models.Order, error) { + return nil, errors.New("db error") + }, + } + s := newTestServer(svc) + body := jsonBody(t, api.OrderStatusUpdate{Status: api.OrderStatusUpdateStatusInTransit}) + r := httptest.NewRequest(http.MethodPatch, "/orders/"+orderID.String()+"/status", body) + r = withClaims(r, uuid.New(), "driver") + w := httptest.NewRecorder() + + s.UpdateOrderStatus(w, r, orderID) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- GetOrdersReport --- + +func TestGetOrdersReport_Success(t *testing.T) { + svc := &mockOrderService{ + getOrdersReport: func(_ context.Context, _ string, _ api.GetOrdersReportParams) ([]models.Order, error) { + return []models.Order{{ID: uuid.New(), Status: "delivered"}}, nil + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/orders", nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.GetOrdersReport(w, r, api.GetOrdersReportParams{}) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestGetOrdersReport_Forbidden(t *testing.T) { + svc := &mockOrderService{ + getOrdersReport: func(_ context.Context, _ string, _ api.GetOrdersReportParams) ([]models.Order, error) { + return nil, services.ErrForbidden + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/orders", nil) + r = withClaims(r, uuid.New(), "client") + w := httptest.NewRecorder() + + s.GetOrdersReport(w, r, api.GetOrdersReportParams{}) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestGetOrdersReport_ServiceError(t *testing.T) { + svc := &mockOrderService{ + getOrdersReport: func(_ context.Context, _ string, _ api.GetOrdersReportParams) ([]models.Order, error) { + return nil, errors.New("db error") + }, + } + s := newTestServer(svc) + r := httptest.NewRequest(http.MethodGet, "/reports/orders", nil) + r = withClaims(r, uuid.New(), "manager") + w := httptest.NewRecorder() + + s.GetOrdersReport(w, r, api.GetOrdersReportParams{}) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +}