rename long name to short name
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
#CERTBOT NGINX VARIABLES
|
||||
|
||||
EMAIL=valitovgaziz@yandex.ru
|
||||
DOMAINS_yalarba=yalarba.ru,www.yalarba.ru
|
||||
DOMAINS_valitovgaziz=valitovgaziz.ru,www.valitovgaziz.ru
|
||||
DOMAINS_easysite102=easysite102.ru,www.easysite102.ru
|
||||
DOMAINS_begushiybashkir=xn--80abahjtcfl5d0a8di.xn--p1ai,www.xn--80abahjtcfl5d0a8di.xn--p1ai
|
||||
DOMAINS_begushiybashkir_latin=begushiybashkir.ru,www.begushiybashkir.ru
|
||||
ALL_DOMAINS=yalarba.ru,www.yalarba.ru,valitovgaziz.ru,www.valitovgaziz.ru,easysite102.ru,www.easysite102.ru,begushiybashkir.ru,www.begushiybashkir.ru,xn--80abahjtcfl5d0a8di.xn--p1ai,www.xn--80abahjtcfl5d0a8di.xn--p1ai
|
||||
|
||||
# keycloak
|
||||
KEYCLOAK_ADMIN_PASSWORD=your_secure_password
|
||||
KEYCLOAK_DB_PASSWORD=your_secure_db_password
|
||||
@@ -0,0 +1,73 @@
|
||||
all: git stop_bb build_bb run_bb npm_clean rebuild_bbvue api_bb_logs
|
||||
|
||||
api_bb: git stop_bb build_bb run_bb api_bb_logs
|
||||
|
||||
git:
|
||||
git pull
|
||||
|
||||
stop_bb:
|
||||
docker compose down api_bb
|
||||
|
||||
build_bb:
|
||||
docker compose build api_bb --no-cache
|
||||
|
||||
run_bb:
|
||||
docker compose up api_bb -d
|
||||
|
||||
bb_db:
|
||||
docker exec -it serv_nginx-db-1 sh -c "psql -U postgres -d bb_db
|
||||
|
||||
api_bb_logs:
|
||||
docker logs api_bb -f
|
||||
|
||||
restart:
|
||||
docker compose down && docker compose up -d
|
||||
|
||||
npm_clean:
|
||||
npm cache clean --force
|
||||
|
||||
rebuild_bbvue:
|
||||
cd bbvue && npm run build
|
||||
|
||||
vue_bb: git npm_clean rebuild_bbvue api_bb_logs
|
||||
|
||||
stop_nginx:
|
||||
docker compose down nginx
|
||||
|
||||
build_nginx:
|
||||
docker compose build nginx --no-cache
|
||||
|
||||
start_nginx:
|
||||
docker compose up nginx -d
|
||||
|
||||
logs_nginx:
|
||||
docker logs nginx -f
|
||||
|
||||
nginx: git stop_nginx build_nginx start_nginx logs_nginx
|
||||
|
||||
stop:
|
||||
docker compose down
|
||||
|
||||
build:
|
||||
docker compose build --no-cache
|
||||
|
||||
start:
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
re_all: stop git build start
|
||||
|
||||
stop_kk:
|
||||
docker compose down keycloak
|
||||
|
||||
build_kk:
|
||||
docker compose build keycloak --no-cache
|
||||
|
||||
start_kk:
|
||||
docker compose up keycloak -d
|
||||
|
||||
logs_kk:
|
||||
docker logs keycloak -f
|
||||
|
||||
re_kk: git stop_kk start_kk
|
||||
|
||||
keycloak: git stop_kk build_kk start_kk logs_kk
|
||||
@@ -0,0 +1,24 @@
|
||||
PORT=8080
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=bb_db
|
||||
DB_SSLMODE=disable
|
||||
|
||||
|
||||
# .env
|
||||
LOG_LEVEL=debug
|
||||
ENVIRONMENT=development
|
||||
|
||||
# app
|
||||
REST_API_VERSION=1.0.0
|
||||
VITE_API_BASE_URL=https://begushiybashkir.ru
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=valitovgaziz
|
||||
SMTP_PASSWORD=omqywxnamignyeql
|
||||
FROM_EMAIL=valitovgaziz@gmail.com
|
||||
FRONTEND_URL=https://begushiybashkir.ru
|
||||
@@ -0,0 +1,18 @@
|
||||
# Используем официальный образ Go
|
||||
FROM golang:1.25.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем go.mod и go.sum
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Компилируем БЕЗ CGO
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["./bin/main"]
|
||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
// main.go с graceful shutdown
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"api_bb/internal/app"
|
||||
"api_bb/internal/config"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Загрузка конфигурации
|
||||
cfg := config.Load()
|
||||
|
||||
// Инициализация логгера
|
||||
if err := logger.Init(
|
||||
os.Getenv("LOG_LEVEL"),
|
||||
os.Getenv("ENVIRONMENT"),
|
||||
); err != nil {
|
||||
log.Printf("Failed to initialize logger: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
// Логируем начало работы
|
||||
logger.LogApplicationStart(os.Getenv("REST_API_VERSION"), os.Getenv("ENVIRONMENT"), "")
|
||||
|
||||
// Создание и инициализация приложения
|
||||
application := app.NewApp(cfg)
|
||||
if err := application.Initialize(); err != nil {
|
||||
logger.Get().Fatal("failed to initialize application", zap.Error(err))
|
||||
}
|
||||
|
||||
// Канал для graceful shutdown
|
||||
done := make(chan bool, 1)
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Запуск сервера в горутине
|
||||
go func() {
|
||||
if err := application.Start(); err != nil {
|
||||
logger.Get().Fatal("failed to start server", zap.Error(err))
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Ожидание сигнала shutdown
|
||||
<-quit
|
||||
logger.Get().Info("shutdown signal received")
|
||||
|
||||
// Graceful shutdown приложения
|
||||
if err := application.Shutdown(); err != nil {
|
||||
logger.Get().Fatal("could not gracefully shutdown the application", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.LogApplicationShutdown("graceful shutdown")
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
module api_bb
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/wneessen/go-mail v0.7.2
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
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/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
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/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=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/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.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/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8=
|
||||
github.com/wneessen/go-mail v0.7.2/go.mod h1:+TkW6QP3EVkgTEqHtVmnAE/1MRhmzb8Y9/W3pweuS+k=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,26 @@
|
||||
module go-rest-api
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/go-chi/cors v1.2.1
|
||||
golang.org/x/crypto v0.31.0
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"api_bb/internal/config"
|
||||
"api_bb/internal/database"
|
||||
"api_bb/internal/routes"
|
||||
"api_bb/pkg/logger"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg *config.Config
|
||||
db *database.Database
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewApp(cfg *config.Config) *App {
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize инициализирует приложение (БД, миграции, роутинг)
|
||||
func (a *App) Initialize() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
// Инициализация базы данных
|
||||
dbConfig := &database.Config{
|
||||
URL: a.cfg.DatabaseURL,
|
||||
}
|
||||
a.db = database.NewDatabase(dbConfig)
|
||||
|
||||
// Подключение к БД
|
||||
if err := a.db.Connect(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Проверка соединения
|
||||
if err := a.db.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Выполнение миграций
|
||||
if err := a.db.Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Настройка роутера
|
||||
router := routes.SetupRouter(a.db.DB, a.cfg)
|
||||
|
||||
// Настройка HTTP сервера
|
||||
a.server = &http.Server{
|
||||
Addr: ":" + a.cfg.Port,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
zapLogger.Info("application initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start запускает HTTP сервер
|
||||
func (a *App) Start() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("starting HTTP server", zap.String("port", a.cfg.Port))
|
||||
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully останавливает приложение
|
||||
func (a *App) Shutdown() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("shutdown signal received")
|
||||
|
||||
// Graceful shutdown сервера
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
a.server.SetKeepAlivesEnabled(false)
|
||||
if err := a.server.Shutdown(ctx); err != nil {
|
||||
zapLogger.Error("could not gracefully shutdown the server", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Закрытие соединения с БД
|
||||
if err := a.db.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zapLogger.Info("application shutdown completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB возвращает экземпляр базы данных
|
||||
func (a *App) GetDB() *gorm.DB {
|
||||
return a.db.DB
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// config/config.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
StaticURL string `env:"STATIC_URL" envDefault:"http://localhost:8080"`
|
||||
JWTSecret string `env:"JWT_SECRET,required"`
|
||||
|
||||
// Email configuration
|
||||
SMTPHost string `env:"SMTP_HOST,required"`
|
||||
SMTPPort int `env:"SMTP_PORT,required"`
|
||||
SMTPUsername string `env:"SMTP_USERNAME,required"`
|
||||
SMTPPassword string `env:"SMTP_PASSWORD,required"`
|
||||
FromEmail string `env:"FROM_EMAIL,required"`
|
||||
FrontendURL string `env:"FRONTEND_URL,required"`
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
_ = godotenv.Load(".env")
|
||||
port := getEnv("PORT", "8080")
|
||||
jwtSecret := getEnv("JWT_SECRET", "your-secret-key")
|
||||
|
||||
// Формируем DSN для PostgreSQL из переменных окружения
|
||||
databaseURL := getPostgresDSN()
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: databaseURL,
|
||||
JWTSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func getPostgresDSN() string {
|
||||
host := getEnv("DB_HOST", "localhost")
|
||||
port := getEnv("DB_PORT", "5432")
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "postgres")
|
||||
dbname := getEnv("DB_NAME", "bb_db")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, user, password, dbname, sslmode)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"api_bb/pkg/logger"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
DB *gorm.DB
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
func NewDatabase(cfg *Config) *Database {
|
||||
return &Database{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect устанавливает соединение с базой данных
|
||||
func (d *Database) Connect() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
// Логирование попытки подключения к БД
|
||||
zapLogger.Info("attempting to connect to database",
|
||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(d.cfg.URL), &gorm.Config{})
|
||||
if err != nil {
|
||||
zapLogger.Error("failed to connect to database",
|
||||
zap.Error(err),
|
||||
zap.String("database_url", MaskPassword(d.cfg.URL)),
|
||||
)
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
d.DB = db
|
||||
|
||||
// Логирование успешного подключения к БД
|
||||
zapLogger.Info("successfully connected to database",
|
||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping проверяет соединение с базой данных
|
||||
func (d *Database) Ping() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
sqlDB, err := d.DB.DB()
|
||||
if err != nil {
|
||||
zapLogger.Error("failed to get database instance", zap.Error(err))
|
||||
return fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
zapLogger.Error("database ping failed", zap.Error(err))
|
||||
return fmt.Errorf("database ping failed: %w", err)
|
||||
}
|
||||
|
||||
zapLogger.Info("database ping successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с базой данных
|
||||
func (d *Database) Close() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
if d.DB == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
sqlDB, err := d.DB.DB()
|
||||
if err != nil {
|
||||
zapLogger.Error("failed to get database instance for closing", zap.Error(err))
|
||||
return fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
zapLogger.Info("closing database connection")
|
||||
if err := sqlDB.Close(); err != nil {
|
||||
zapLogger.Error("failed to close database connection", zap.Error(err))
|
||||
return fmt.Errorf("failed to close database connection: %w", err)
|
||||
}
|
||||
|
||||
zapLogger.Info("database connection closed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Вспомогательные функции для работы с DSN
|
||||
|
||||
// ExtractHostFromDSN извлекает хост из DSN строки
|
||||
func ExtractHostFromDSN(dsn string) string {
|
||||
// Простая реализация для PostgreSQL DSN
|
||||
parts := strings.Split(dsn, " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "host=") {
|
||||
return strings.TrimPrefix(part, "host=")
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ExtractDBNameFromDSN извлекает имя базы данных из DSN строки
|
||||
func ExtractDBNameFromDSN(dsn string) string {
|
||||
// Простая реализация для PostgreSQL DSN
|
||||
parts := strings.Split(dsn, " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "dbname=") {
|
||||
return strings.TrimPrefix(part, "dbname=")
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MaskPassword маскирует пароль в DSN строке для безопасного логирования
|
||||
func MaskPassword(dsn string) string {
|
||||
// Простая реализация - заменяет пароль на ***
|
||||
parts := strings.Split(dsn, " ")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, "password=") {
|
||||
parts[i] = "password=***"
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/pkg/logger"
|
||||
)
|
||||
|
||||
// Migrate выполняет автоматические миграции для всех моделей
|
||||
func (d *Database) Migrate() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("starting database migration")
|
||||
|
||||
// Список всех моделей для миграции
|
||||
models := []interface{}{
|
||||
&models.User{},
|
||||
&models.News{},
|
||||
&models.Comment{},
|
||||
&models.Review{},
|
||||
&models.UserStats{},
|
||||
&models.Workout{},
|
||||
&models.Achievement{},
|
||||
&models.Event{},
|
||||
&models.EventRegistration{},
|
||||
&models.PersonalBest{},
|
||||
&models.TrainingPlan{},
|
||||
&models.EmailVerification{},
|
||||
// Добавьте другие модели здесь
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
modelName := getModelName(model)
|
||||
zapLogger.Debug("migrating model", zap.String("model", modelName))
|
||||
|
||||
if err := d.DB.AutoMigrate(model); err != nil {
|
||||
zapLogger.Error("failed to migrate model",
|
||||
zap.String("model", modelName),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Info("database migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateModels выполняет миграции для конкретных моделей
|
||||
func (d *Database) MigrateModels(models ...interface{}) error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("starting migration for specific models",
|
||||
zap.Int("model_count", len(models)),
|
||||
)
|
||||
|
||||
for _, model := range models {
|
||||
modelName := getModelName(model)
|
||||
zapLogger.Debug("migrating model", zap.String("model", modelName))
|
||||
|
||||
if err := d.DB.AutoMigrate(model); err != nil {
|
||||
zapLogger.Error("failed to migrate model",
|
||||
zap.String("model", modelName),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Info("models migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getModelName возвращает имя модели для логирования
|
||||
func getModelName(model interface{}) string {
|
||||
switch model.(type) {
|
||||
case *models.User:
|
||||
return "User"
|
||||
case *models.News:
|
||||
return "News"
|
||||
case *models.Comment:
|
||||
return "Comment"
|
||||
case *models.Review:
|
||||
return "Reviews"
|
||||
case *models.UserStats:
|
||||
return "Статистика Пользователя"
|
||||
case *models.Workout:
|
||||
return "Тренировки пользователя"
|
||||
case *models.Achievement:
|
||||
return "Достижения пользователя"
|
||||
case *models.Event:
|
||||
return "Событие"
|
||||
case *models.EventRegistration:
|
||||
return "Администрирование события"
|
||||
case *models.PersonalBest:
|
||||
return "Персональные достижения"
|
||||
case *models.TrainingPlan:
|
||||
return "Тренировочный план"
|
||||
case *models.EmailVerification:
|
||||
return "Верификация email"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// handlers/auth.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService service.AuthService
|
||||
jwtService service.JWTService
|
||||
logger logger.LoggerInterface
|
||||
emailService service.EmailService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService service.AuthService, jwtService service.JWTService, emailService service.EmailService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
jwtService: jwtService,
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "auth"))),
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.logger.Info("handling register request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Логируем тело запроса для отладки
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to read request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Восстанавливаем тело для дальнейшего использования
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
h.logger.Debug("raw register request body", zap.String("body", string(bodyBytes)))
|
||||
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid JSON payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("parsed register request",
|
||||
zap.String("email", req.Email),
|
||||
zap.String("first_name", req.FirstName),
|
||||
zap.String("last_name", req.LastName),
|
||||
)
|
||||
|
||||
// Валидация обязательных полей
|
||||
if req.FirstName == "" {
|
||||
h.logger.Warn("register failed - first name required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "First name is required")
|
||||
return
|
||||
}
|
||||
if req.LastName == "" {
|
||||
h.logger.Warn("register failed - last name required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Last name is required")
|
||||
return
|
||||
}
|
||||
if req.Email == "" {
|
||||
h.logger.Warn("register failed - email required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Email is required")
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
h.logger.Warn("register failed - password required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Password is required")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 6 {
|
||||
h.logger.Warn("register failed - password too short")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Phone: req.Phone,
|
||||
Experience: req.Experience,
|
||||
Goals: req.Goals,
|
||||
Newsletter: req.Newsletter,
|
||||
Role: "user",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.authService.Register(user); err != nil {
|
||||
h.logger.Error("auth service registration failed",
|
||||
zap.String("email", req.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user registered successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
|
||||
// Отправки сообщения для верификации Email
|
||||
if err := h.emailService.SendVerificationEmail(user.ID, user.Email, user.FirstName); err != nil {
|
||||
h.logger.Error("failed to send verification email",
|
||||
zap.Error(err),
|
||||
zap.Uint("user_id", user.ID))
|
||||
}
|
||||
|
||||
// После успешной регистрации возвращаем данные пользователя
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "User registered successfully",
|
||||
"user": toUserResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling login request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем Content-Type
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
h.logger.Warn("invalid content type", zap.String("content_type", r.Header.Get("Content-Type")))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
|
||||
// Читаем и логируем тело запроса
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to read request body")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Восстанавливаем тело
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
h.logger.Debug("request body", zap.String("body", string(bodyBytes)))
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("JSON decode failed",
|
||||
zap.Error(err),
|
||||
zap.String("raw_body", string(bodyBytes)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
|
||||
// Валидация
|
||||
if req.Email == "" || req.Password == "" {
|
||||
h.logger.Warn("validation failed",
|
||||
zap.String("email", req.Email),
|
||||
zap.Int("password_len", len(req.Password)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("attempting login", zap.String("email", req.Email))
|
||||
|
||||
user, token, err := h.authService.Login(req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.logger.Warn("login failed", zap.String("email", req.Email), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем куки
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(24 * time.Hour),
|
||||
})
|
||||
|
||||
h.logger.Info("login successful",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Login successful",
|
||||
"token": token,
|
||||
"user": toUserResponse(user),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Устанавливаем CORS заголовки
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
h.logger.Info("handling logout request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Удаляем куку
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "auth_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
h.logger.Info("user logged out successfully")
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Logout successful",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// handlers/avatar.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AvatarHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
avatarService service.AvatarService
|
||||
}
|
||||
|
||||
func NewAvatarHandler(avatarService service.AvatarService) *AvatarHandler {
|
||||
return &AvatarHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "avatar"))),
|
||||
avatarService: avatarService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AvatarHandler) UploadAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("UploadAvatar START",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
h.logger.Debug("UploadAvatar END",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
)
|
||||
}()
|
||||
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("UploadAvatar: authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("UploadAvatar: user authenticated",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
zap.String("username", user.FirstName+user.LastName),
|
||||
)
|
||||
|
||||
// Парсим multipart форму
|
||||
h.logger.Debug("UploadAvatar: parsing multipart form")
|
||||
if err := r.ParseMultipartForm(10 << 20); err != nil { // 10MB limit
|
||||
h.logger.Error("UploadAvatar: failed to parse form", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to parse form: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("UploadAvatar: getting file from form")
|
||||
file, header, err := r.FormFile("avatar")
|
||||
if err != nil {
|
||||
h.logger.Error("UploadAvatar: failed to get file from form", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to get file: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
h.logger.Debug("UploadAvatar: file received",
|
||||
zap.String("filename", header.Filename),
|
||||
zap.Int64("size", header.Size),
|
||||
zap.String("content_type", header.Header.Get("Content-Type")),
|
||||
)
|
||||
|
||||
// Проверяем тип файла
|
||||
allowedTypes := map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/png": true,
|
||||
"image/gif": true,
|
||||
}
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
if !allowedTypes[contentType] {
|
||||
h.logger.Warn("UploadAvatar: invalid file type",
|
||||
zap.String("content_type", contentType),
|
||||
zap.String("filename", header.Filename),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Only JPEG, PNG and GIF images are allowed")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("UploadAvatar: file type validated successfully")
|
||||
|
||||
// Загружаем аватар
|
||||
h.logger.Debug("UploadAvatar: calling avatarService.UploadAvatar",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
)
|
||||
avatarPath, err := h.avatarService.UploadAvatar(user.ID, file, header)
|
||||
if err != nil {
|
||||
h.logger.Error("UploadAvatar: failed to upload avatar", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to upload avatar: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("UploadAvatar: avatar uploaded successfully",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
zap.String("avatar_path", avatarPath),
|
||||
)
|
||||
|
||||
// Возвращаем ответ с полем success
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Avatar uploaded successfully",
|
||||
"avatar": avatarPath,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AvatarHandler) DeleteAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("DeleteAvatar START",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
h.logger.Debug("DeleteAvatar END",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
)
|
||||
}()
|
||||
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("DeleteAvatar: authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("DeleteAvatar: user authenticated",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
zap.String("username", user.FirstName+user.LastName),
|
||||
)
|
||||
|
||||
h.logger.Debug("DeleteAvatar: calling avatarService.DeleteAvatar",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
)
|
||||
if err := h.avatarService.DeleteAvatar(user.ID); err != nil {
|
||||
h.logger.Error("DeleteAvatar: failed to delete avatar", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete avatar: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("DeleteAvatar: avatar deleted successfully",
|
||||
zap.Int64("user_id", int64(user.ID)),
|
||||
)
|
||||
|
||||
// Возвращаем ответ с полем success
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Avatar deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GET /v1/user/avatars/{filename}
|
||||
func (h *AvatarHandler) GetAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
filename := chi.URLParam(r, "filename")
|
||||
|
||||
h.logger.Debug("GetAvatar START",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("filename", filename),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.String("url", r.URL.String()),
|
||||
)
|
||||
|
||||
defer func() {
|
||||
h.logger.Debug("GetAvatar END",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("filename", filename),
|
||||
)
|
||||
}()
|
||||
|
||||
// Валидация имени файла
|
||||
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") {
|
||||
h.logger.Warn("GetAvatar: invalid filename", zap.String("filename", filename))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid filename")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("GetAvatar: handling get avatar request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("filename", filename),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Используем ServeAvatarFile для обслуживания файла
|
||||
h.logger.Debug("GetAvatar: calling avatarService.ServeAvatarFile",
|
||||
zap.String("filename", filename),
|
||||
)
|
||||
contentType, err := h.avatarService.ServeAvatarFile(w, filename)
|
||||
if err != nil {
|
||||
h.logger.Warn("GetAvatar: failed to serve avatar file",
|
||||
zap.String("filename", filename),
|
||||
zap.Error(err),
|
||||
)
|
||||
|
||||
switch {
|
||||
case err.Error() == "avatar file not found":
|
||||
h.logger.Warn("GetAvatar: avatar file not found", zap.String("filename", filename))
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Avatar not found")
|
||||
case err.Error() == "invalid filename" || err.Error() == "unsupported file format":
|
||||
h.logger.Warn("GetAvatar: invalid filename or format",
|
||||
zap.String("filename", filename),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, err.Error())
|
||||
default:
|
||||
h.logger.Error("GetAvatar: internal server error", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to serve avatar")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем заголовки для кэширования
|
||||
h.logger.Debug("GetAvatar: setting response headers",
|
||||
zap.String("content_type", contentType),
|
||||
)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000") // Кэш на 1 год
|
||||
w.Header().Set("Expires", time.Now().Add(365*24*time.Hour).Format(http.TimeFormat))
|
||||
|
||||
h.logger.Info("GetAvatar: avatar served successfully",
|
||||
zap.String("filename", filename),
|
||||
zap.String("content_type", contentType),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// handlers/email_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EmailHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
emailService *service.EmailService
|
||||
}
|
||||
|
||||
func NewEmailHandler(emailService *service.EmailService) *EmailHandler {
|
||||
return &EmailHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "email"))),
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyEmail подтверждает email пользователя
|
||||
func (h *EmailHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling email verification request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
h.logger.Warn("email verification failed - token is required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Токен обязателен")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.emailService.VerifyEmail(token); err != nil {
|
||||
h.logger.Error("email verification failed, expired",
|
||||
zap.Error(err),
|
||||
zap.String("token", token),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("email successfully verified",
|
||||
zap.String("token", token),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Email успешно подтвержден",
|
||||
})
|
||||
}
|
||||
|
||||
// RequestPasswordReset запрашивает сброс пароля
|
||||
func (h *EmailHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling password reset request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
var req models.PasswordResetRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("password reset request failed - invalid request format",
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.emailService.SendPasswordResetEmail(req.Email); err != nil {
|
||||
h.logger.Error("password reset request failed",
|
||||
zap.Error(err),
|
||||
zap.String("email", req.Email),
|
||||
)
|
||||
// Для безопасности всегда возвращаем успех
|
||||
}
|
||||
|
||||
h.logger.Info("password reset request processed",
|
||||
zap.String("email", req.Email),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Если email зарегистрирован, инструкции по восстановлению пароля будут отправлены",
|
||||
})
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset подтверждает сброс пароля
|
||||
func (h *EmailHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling password reset confirmation request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
var req models.PasswordResetConfirm
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("password reset confirmation failed - invalid request format",
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.emailService.ResetPassword(req.Token, req.Password); err != nil {
|
||||
h.logger.Error("password reset confirmation failed",
|
||||
zap.Error(err),
|
||||
zap.String("token", req.Token),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Неверный или просроченный токен")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("password successfully reset",
|
||||
zap.String("token", req.Token),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Пароль успешно изменен",
|
||||
})
|
||||
}
|
||||
|
||||
type NewsletterRequest struct {
|
||||
Subject string `json:"subject" validate:"required"`
|
||||
Content string `json:"content" validate:"required"`
|
||||
}
|
||||
|
||||
// SendNewsletter отправляет рассылку новостей
|
||||
func (h *EmailHandler) SendNewsletter(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling newsletter sending request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
var req NewsletterRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("newsletter sending failed - invalid request format",
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Неверный формат запроса")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.emailService.SendNewsletterToSubscribers(req.Subject, req.Content); err != nil {
|
||||
h.logger.Error("newsletter sending failed",
|
||||
zap.Error(err),
|
||||
zap.String("subject", req.Subject),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить рассылку")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("newsletter sent successfully",
|
||||
zap.String("subject", req.Subject),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Рассылка отправлена подписчикам",
|
||||
})
|
||||
}
|
||||
|
||||
// ResendVerification повторно отправляет email верификации
|
||||
func (h *EmailHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling resend verification request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("resend verification failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Пользователь не авторизован")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем пользователя
|
||||
userData, err := h.emailService.GetUserByID(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Warn("resend verification failed - user not found",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Пользователь не найден")
|
||||
return
|
||||
}
|
||||
|
||||
if userData.EmailVerified {
|
||||
h.logger.Warn("resend verification failed - email already verified",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", userData.Email),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Email уже подтвержден")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.emailService.SendVerificationEmail(userData.ID, userData.Email, userData.FirstName); err != nil {
|
||||
h.logger.Error("resend verification failed",
|
||||
zap.Error(err),
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", userData.Email),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Не удалось отправить email подтверждения")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("verification email resent successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", userData.Email),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Email подтверждения отправлен повторно",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
// handlers/event_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EventHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
eventService service.EventService
|
||||
}
|
||||
|
||||
func NewEventHandler(eventService service.EventService) *EventHandler {
|
||||
return &EventHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "event"))),
|
||||
eventService: eventService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateEventRequest - DTO для создания события
|
||||
type CreateEventRequest struct {
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"required,min=10"`
|
||||
Date time.Time `json:"date" validate:"required"`
|
||||
Location string `json:"location" validate:"required,max=255"`
|
||||
Type models.EventType `json:"type" validate:"required,oneof=race training social workshop"`
|
||||
Distance string `json:"distance" validate:"max=50"`
|
||||
MaxParticipants int `json:"max_participants" validate:"min=0"`
|
||||
RegistrationOpen bool `json:"registration_open"`
|
||||
Image string `json:"image" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateEventRequest - DTO для обновления события
|
||||
type UpdateEventRequest struct {
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"required,min=10"`
|
||||
Date time.Time `json:"date" validate:"required"`
|
||||
Location string `json:"location" validate:"required,max=255"`
|
||||
Type models.EventType `json:"type" validate:"required,oneof=race training social workshop"`
|
||||
Distance string `json:"distance" validate:"max=50"`
|
||||
MaxParticipants int `json:"max_participants" validate:"min=0"`
|
||||
RegistrationOpen bool `json:"registration_open"`
|
||||
Image string `json:"image" validate:"max=500"`
|
||||
}
|
||||
|
||||
// EventResponse - DTO для ответа с событием
|
||||
type EventResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Date time.Time `json:"date"`
|
||||
Location string `json:"location"`
|
||||
Type models.EventType `json:"type"`
|
||||
Distance string `json:"distance"`
|
||||
ParticipantsCount int `json:"participants_count"`
|
||||
MaxParticipants int `json:"max_participants"`
|
||||
RegistrationOpen bool `json:"registration_open"`
|
||||
Image string `json:"image"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateEvent создает новое событие
|
||||
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling create event request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("create event failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем права доступа (только админы могут создавать события)
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("create event failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateEventRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("validation failed for create event", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем модель события
|
||||
event := &models.Event{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Date: req.Date,
|
||||
Location: req.Location,
|
||||
Type: req.Type,
|
||||
Distance: req.Distance,
|
||||
MaxParticipants: req.MaxParticipants,
|
||||
RegistrationOpen: req.RegistrationOpen,
|
||||
Image: req.Image,
|
||||
}
|
||||
|
||||
if err := h.eventService.CreateEvent(event); err != nil {
|
||||
h.logger.Error("failed to create event", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create event: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("event created successfully",
|
||||
zap.Uint("event_id", event.ID),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "Event created successfully",
|
||||
"event": toEventResponse(event),
|
||||
})
|
||||
}
|
||||
|
||||
// GetEvent возвращает событие по ID
|
||||
func (h *EventHandler) GetEvent(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get event request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Извлекаем ID события из URL параметров
|
||||
eventID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
event, err := h.eventService.GetEventByID(uint(eventID))
|
||||
if err != nil {
|
||||
h.logger.Warn("event not found",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Event not found")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("event retrieved successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, toEventResponse(event))
|
||||
}
|
||||
|
||||
// GetAllEvents возвращает все события
|
||||
func (h *EventHandler) GetAllEvents(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get all events request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
events, err := h.eventService.GetAllEvents()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get events", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get events: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем в response формат
|
||||
var eventResponses []EventResponse
|
||||
for _, event := range events {
|
||||
eventResponses = append(eventResponses, toEventResponse(&event))
|
||||
}
|
||||
|
||||
h.logger.Info("events list retrieved successfully",
|
||||
zap.Int("events_count", len(eventResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, eventResponses)
|
||||
}
|
||||
|
||||
// UpdateEvent обновляет событие
|
||||
func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update event request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update event failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("update event failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID события
|
||||
eventID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateEventRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("validation failed for update event", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем модель события для обновления
|
||||
event := &models.Event{
|
||||
ID: uint(eventID),
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Date: req.Date,
|
||||
Location: req.Location,
|
||||
Type: req.Type,
|
||||
Distance: req.Distance,
|
||||
MaxParticipants: req.MaxParticipants,
|
||||
RegistrationOpen: req.RegistrationOpen,
|
||||
Image: req.Image,
|
||||
}
|
||||
|
||||
if err := h.eventService.UpdateEvent(event); err != nil {
|
||||
h.logger.Error("failed to update event",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update event: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("event updated successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Event updated successfully",
|
||||
"event": toEventResponse(event),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteEvent удаляет событие
|
||||
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling delete event request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("delete event failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("delete event failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID события
|
||||
eventID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.eventService.DeleteEvent(uint(eventID)); err != nil {
|
||||
h.logger.Error("failed to delete event",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete event: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("event deleted successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Event deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetEventsByType возвращает события по типу
|
||||
func (h *EventHandler) GetEventsByType(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get events by type request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
eventType := models.EventType(r.PathValue("type"))
|
||||
|
||||
// Валидация типа события
|
||||
validTypes := []models.EventType{"race", "training", "social", "workshop"}
|
||||
if !isValidEventType(eventType, validTypes) {
|
||||
h.logger.Warn("invalid event type", zap.String("event_type", string(eventType)))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event type")
|
||||
return
|
||||
}
|
||||
|
||||
events, err := h.eventService.GetEventsByType(eventType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get events by type",
|
||||
zap.String("event_type", string(eventType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get events: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var eventResponses []EventResponse
|
||||
for _, event := range events {
|
||||
eventResponses = append(eventResponses, toEventResponse(&event))
|
||||
}
|
||||
|
||||
h.logger.Info("events by type retrieved successfully",
|
||||
zap.String("event_type", string(eventType)),
|
||||
zap.Int("events_count", len(eventResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, eventResponses)
|
||||
}
|
||||
|
||||
// GetUpcomingEvents возвращает предстоящие события
|
||||
func (h *EventHandler) GetUpcomingEvents(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get upcoming events request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
events, err := h.eventService.GetUpcomingEvents()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get upcoming events", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get upcoming events: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var eventResponses []EventResponse
|
||||
for _, event := range events {
|
||||
eventResponses = append(eventResponses, toEventResponse(&event))
|
||||
}
|
||||
|
||||
h.logger.Info("upcoming events retrieved successfully",
|
||||
zap.Int("events_count", len(eventResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, eventResponses)
|
||||
}
|
||||
|
||||
// ToggleRegistrationStatus переключает статус регистрации
|
||||
func (h *EventHandler) ToggleRegistrationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling toggle registration status request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("toggle registration status failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("toggle registration status failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID события
|
||||
eventID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RegistrationOpen bool `json:"registration_open" validate:"required"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.eventService.ToggleRegistrationStatus(uint(eventID), req.RegistrationOpen); err != nil {
|
||||
h.logger.Error("failed to toggle registration status",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Bool("registration_open", req.RegistrationOpen),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to toggle registration status: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("registration status toggled successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Bool("registration_open", req.RegistrationOpen),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Registration status updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// toEventResponse преобразует модель события в response DTO
|
||||
func toEventResponse(event *models.Event) EventResponse {
|
||||
return EventResponse{
|
||||
ID: event.ID,
|
||||
Title: event.Title,
|
||||
Description: event.Description,
|
||||
Date: event.Date,
|
||||
Location: event.Location,
|
||||
Type: event.Type,
|
||||
Distance: event.Distance,
|
||||
ParticipantsCount: event.ParticipantsCount,
|
||||
MaxParticipants: event.MaxParticipants,
|
||||
RegistrationOpen: event.RegistrationOpen,
|
||||
Image: event.Image,
|
||||
CreatedAt: event.CreatedAt,
|
||||
UpdatedAt: event.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// isValidEventType проверяет валидность типа события
|
||||
func isValidEventType(eventType models.EventType, validTypes []models.EventType) bool {
|
||||
for _, validType := range validTypes {
|
||||
if eventType == validType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
// handlers/event_registration_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EventRegistrationHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
registrationService service.EventRegistrationService
|
||||
}
|
||||
|
||||
func NewEventRegistrationHandler(registrationService service.EventRegistrationService) *EventRegistrationHandler {
|
||||
return &EventRegistrationHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "event_registration"))),
|
||||
registrationService: registrationService,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterForEventRequest - DTO для регистрации на событие
|
||||
type RegisterForEventRequest struct {
|
||||
EventID uint `json:"event_id" validate:"required"`
|
||||
Notes string `json:"notes" validate:"max=500"`
|
||||
}
|
||||
|
||||
// UpdateRegistrationRequest - DTO для обновления регистрации
|
||||
type UpdateRegistrationRequest struct {
|
||||
Notes string `json:"notes" validate:"max=500"`
|
||||
}
|
||||
|
||||
// RegistrationResponse - DTO для ответа с регистрацией
|
||||
type RegistrationResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
EventID uint `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
ResultTime string `json:"result_time"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Event EventResponse `json:"event,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterForEvent регистрирует пользователя на событие
|
||||
func (h *EventRegistrationHandler) RegisterForEvent(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling register for event request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("register for event failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req RegisterForEventRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("validation failed for register for event", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем модель регистрации
|
||||
registration := &models.EventRegistration{
|
||||
UserID: user.ID,
|
||||
EventID: req.EventID,
|
||||
Status: "pending",
|
||||
Notes: req.Notes,
|
||||
}
|
||||
|
||||
if err := h.registrationService.RegisterForEvent(registration); err != nil {
|
||||
h.logger.Error("failed to register for event",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("event_id", req.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
statusCode := http.StatusInternalServerError
|
||||
if err.Error() == "event not found" {
|
||||
statusCode = http.StatusNotFound
|
||||
} else if err.Error() == "user already registered for this event" {
|
||||
statusCode = http.StatusConflict
|
||||
} else if err.Error() == "registration is closed for this event" {
|
||||
statusCode = http.StatusForbidden
|
||||
} else if err.Error() == "event is full" {
|
||||
statusCode = http.StatusConflict
|
||||
}
|
||||
utils.RespondWithError(w, statusCode, "Failed to register for event: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user registered for event successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("event_id", req.EventID),
|
||||
zap.Uint("registration_id", registration.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "Successfully registered for event",
|
||||
"registration": toRegistrationResponse(registration),
|
||||
})
|
||||
}
|
||||
|
||||
// GetRegistration возвращает регистрацию по ID
|
||||
func (h *EventRegistrationHandler) GetRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get registration request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get registration failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID регистрации
|
||||
registrationID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid registration ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid registration ID")
|
||||
return
|
||||
}
|
||||
|
||||
registration, err := h.registrationService.GetRegistrationByID(uint(registrationID))
|
||||
if err != nil {
|
||||
h.logger.Warn("registration not found",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Registration not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем права доступа (пользователь может видеть только свои регистрации, админ - все)
|
||||
if user.Role != "admin" && registration.UserID != user.ID {
|
||||
h.logger.Warn("access denied to registration",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("registration_user_id", registration.UserID),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("registration retrieved successfully",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, toRegistrationResponse(registration))
|
||||
}
|
||||
|
||||
// GetUserRegistrations возвращает все регистрации пользователя
|
||||
func (h *EventRegistrationHandler) GetUserRegistrations(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user registrations request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get user registrations failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
registrations, err := h.registrationService.GetRegistrationsByUserID(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user registrations",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get registrations: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var registrationResponses []RegistrationResponse
|
||||
for _, registration := range registrations {
|
||||
registrationResponses = append(registrationResponses, toRegistrationResponse(®istration))
|
||||
}
|
||||
|
||||
h.logger.Info("user registrations retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("registrations_count", len(registrationResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, registrationResponses)
|
||||
}
|
||||
|
||||
// GetEventRegistrations возвращает все регистрации на событие
|
||||
func (h *EventRegistrationHandler) GetEventRegistrations(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get event registrations request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права (только админы могут видеть все регистрации на событие)
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get event registrations failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("get event registrations failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID события
|
||||
eventID, err := strconv.ParseUint(r.PathValue("eventId"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
registrations, err := h.registrationService.GetRegistrationsByEventID(uint(eventID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get event registrations",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get registrations: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var registrationResponses []RegistrationResponse
|
||||
for _, registration := range registrations {
|
||||
registrationResponses = append(registrationResponses, toRegistrationResponse(®istration))
|
||||
}
|
||||
|
||||
h.logger.Info("event registrations retrieved successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Int("registrations_count", len(registrationResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, registrationResponses)
|
||||
}
|
||||
|
||||
// CancelRegistration отменяет регистрацию
|
||||
func (h *EventRegistrationHandler) CancelRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling cancel registration request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("cancel registration failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID регистрации
|
||||
registrationID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid registration ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid registration ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
registration, err := h.registrationService.GetRegistrationByID(uint(registrationID))
|
||||
if err != nil {
|
||||
h.logger.Warn("registration not found for cancellation",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Registration not found")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" && registration.UserID != user.ID {
|
||||
h.logger.Warn("access denied to cancel registration",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("registration_user_id", registration.UserID),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Access denied")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.registrationService.CancelRegistration(uint(registrationID)); err != nil {
|
||||
h.logger.Error("failed to cancel registration",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to cancel registration: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("registration cancelled successfully",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Registration cancelled successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRegistrationStatus обновляет статус регистрации
|
||||
func (h *EventRegistrationHandler) UpdateRegistrationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update registration status request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права (только админы)
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update registration status failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("update registration status failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID регистрации
|
||||
registrationID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid registration ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid registration ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status" validate:"required,oneof=pending confirmed cancelled completed"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("validation failed for update registration status", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.registrationService.UpdateRegistrationStatus(uint(registrationID), req.Status); err != nil {
|
||||
h.logger.Error("failed to update registration status",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.String("status", req.Status),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update registration status: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("registration status updated successfully",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.String("status", req.Status),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Registration status updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateResultTime обновляет результат забега
|
||||
func (h *EventRegistrationHandler) UpdateResultTime(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update result time request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Проверяем аутентификацию и права (только админы)
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update result time failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role != "admin" {
|
||||
h.logger.Warn("update result time failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID регистрации
|
||||
registrationID, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid registration ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid registration ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ResultTime string `json:"result_time" validate:"required,max=20"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("validation failed for update result time", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.registrationService.UpdateResultTime(uint(registrationID), req.ResultTime); err != nil {
|
||||
h.logger.Error("failed to update result time",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.String("result_time", req.ResultTime),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update result time: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("result time updated successfully",
|
||||
zap.Uint("registration_id", uint(registrationID)),
|
||||
zap.String("result_time", req.ResultTime),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Result time updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// CheckEventAvailability проверяет доступность мест на событии
|
||||
func (h *EventRegistrationHandler) CheckEventAvailability(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling check event availability request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Извлекаем ID события
|
||||
eventID, err := strconv.ParseUint(r.PathValue("eventId"), 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid event ID", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid event ID")
|
||||
return
|
||||
}
|
||||
|
||||
available, err := h.registrationService.CheckEventAvailability(uint(eventID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to check event availability",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to check availability: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("event availability checked successfully",
|
||||
zap.Uint("event_id", uint(eventID)),
|
||||
zap.Bool("available", available),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"event_id": eventID,
|
||||
"available": available,
|
||||
})
|
||||
}
|
||||
|
||||
// toRegistrationResponse преобразует модель регистрации в response DTO
|
||||
func toRegistrationResponse(registration *models.EventRegistration) RegistrationResponse {
|
||||
response := RegistrationResponse{
|
||||
ID: registration.ID,
|
||||
UserID: registration.UserID,
|
||||
EventID: registration.EventID,
|
||||
Status: registration.Status,
|
||||
Notes: registration.Notes,
|
||||
ResultTime: registration.ResultTime,
|
||||
CreatedAt: registration.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: registration.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
// Включаем информацию о событии, если она загружена
|
||||
if registration.Event != nil {
|
||||
response.Event = toEventResponse(registration.Event)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
)
|
||||
|
||||
// Общая функция для преобразования User в UserResponse
|
||||
func toUserResponse(user *models.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Avatar: user.Avatar,
|
||||
Phone: user.Phone,
|
||||
Experience: user.Experience,
|
||||
Goals: user.Goals,
|
||||
Newsletter: user.Newsletter,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// handlers/handlers.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/config"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/email"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
healthHandler *HealthHandler
|
||||
authHandler *AuthHandler
|
||||
userHandler *UserHandler
|
||||
avatarHandler *AvatarHandler
|
||||
newsHandler *NewsHandler
|
||||
reviewHandler *ReviewHandler
|
||||
userStatsHandler *UserStatsHandler
|
||||
userWorkoutHandler *UserWorkoutHandler
|
||||
userAchievementHandler *UserAchievementHandler
|
||||
eventHandler *EventHandler
|
||||
eventRegistrationHandler *EventRegistrationHandler
|
||||
personalBestHandler *PersonalBestHandler
|
||||
trainingPlanHandler *TrainingPlanHandler
|
||||
emailHandler *EmailHandler
|
||||
// Здесь будут добавлены другие обработчики
|
||||
// userHandler *UserHandler
|
||||
// eventHandler *EventHandler
|
||||
// reviewHandler *ReviewHandler
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB, cfg *config.Config) *Handler {
|
||||
// Инициализация репозиториев
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
newsRepo := repository.NewNewsRepository(db)
|
||||
commentRepo := repository.NewCommentRepository(db)
|
||||
reviewRepo := repository.NewReviewRepository(db)
|
||||
userStatsRepo := repository.NewUserStatsRepository(db)
|
||||
userWorkoutRepo := repository.NewWorkoutRepository(db)
|
||||
userAchievemenRepo := repository.NewAchievementRepository(db)
|
||||
eventRepo := repository.NewEventRepository(db)
|
||||
eventRegistrationRepo := repository.NewEventRegistrationRepository(db)
|
||||
personalBestRepo := repository.NewPersonalBestRepository(db)
|
||||
trainingPlanRepo := repository.NewTrainingPlanRepository(db)
|
||||
emailRepo := repository.NewEmailRepository(db)
|
||||
|
||||
// Initialize logger
|
||||
baseLogger := logger.NewWrapper(logger.Get()) // Создаем базовый логгер
|
||||
|
||||
// getConfig
|
||||
emailSender, err := email.NewService(config.Load())
|
||||
if err != nil {
|
||||
baseLogger.Info("error to load config", zap.Error(err))
|
||||
}
|
||||
|
||||
// Инициализация сервисов
|
||||
jwtService := service.NewJWTService(cfg.JWTSecret)
|
||||
authService := service.NewAuthService(userRepo, jwtService, baseLogger)
|
||||
userService := service.NewUserService(userRepo, jwtService, baseLogger)
|
||||
avatarService := service.NewAvatarService(userRepo, baseLogger)
|
||||
newsService := service.NewNewsService(newsRepo, commentRepo, baseLogger)
|
||||
reviewService := service.NewReviewService(reviewRepo, baseLogger)
|
||||
userStatsService := service.NewUserStatsService(userStatsRepo)
|
||||
userWorkoutService := service.NewWorkoutService(userWorkoutRepo)
|
||||
achievementService := service.NewAchievementService(userAchievemenRepo)
|
||||
eventRegistrationService := service.NewEventRegistrationService(eventRegistrationRepo, eventRepo, baseLogger)
|
||||
eventService := service.NewEventService(eventRepo, eventRegistrationRepo, baseLogger)
|
||||
personalBestService := service.NewPersonalBestService(personalBestRepo, userStatsService)
|
||||
trainingPlanService := service.NewTrainingPlanService(*trainingPlanRepo)
|
||||
emailService := service.NewEmailService(*emailRepo, userRepo, *emailSender)
|
||||
|
||||
// Инициализация обработчиков
|
||||
healthHandler := NewHealthHandler()
|
||||
authHandler := NewAuthHandler(authService, jwtService, emailService)
|
||||
userHandler := NewUserHandler(&userService)
|
||||
newsHandler := NewNewsHandler(newsService, baseLogger)
|
||||
avatarHandler := NewAvatarHandler(avatarService)
|
||||
reviewHandler := NewReviewHandler(reviewService, baseLogger)
|
||||
userStatsHandler := NewUserStatsHandler(userStatsService)
|
||||
userWorkoutHandler := NewUserWorkoutHandler(userWorkoutService)
|
||||
userAchievementHandler := NewUserAchievementHandler(*achievementService)
|
||||
eventHandler := NewEventHandler(eventService)
|
||||
eventRegistrationHandler := NewEventRegistrationHandler(eventRegistrationService)
|
||||
personalBestHandler := NewPersonalBestHandler(*personalBestService)
|
||||
trainingPlanHandler := NewTrainingPlanHandler(trainingPlanService)
|
||||
emailHandler := NewEmailHandler(&emailService)
|
||||
|
||||
return &Handler{
|
||||
healthHandler: healthHandler,
|
||||
authHandler: authHandler,
|
||||
userHandler: userHandler,
|
||||
newsHandler: newsHandler,
|
||||
avatarHandler: avatarHandler,
|
||||
reviewHandler: reviewHandler,
|
||||
userStatsHandler: userStatsHandler,
|
||||
userWorkoutHandler: userWorkoutHandler,
|
||||
userAchievementHandler: userAchievementHandler,
|
||||
eventHandler: eventHandler,
|
||||
eventRegistrationHandler: eventRegistrationHandler,
|
||||
personalBestHandler: personalBestHandler,
|
||||
trainingPlanHandler: trainingPlanHandler,
|
||||
emailHandler: emailHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// Геттеры для обработчиков (опционально, для удобства)
|
||||
func (h *Handler) EmailHandler() *EmailHandler {
|
||||
return h.emailHandler
|
||||
}
|
||||
|
||||
func (h *Handler) TrainingPlanHandler() *TrainingPlanHandler {
|
||||
return h.trainingPlanHandler
|
||||
}
|
||||
|
||||
func (h *Handler) PersonalBestHandler() *PersonalBestHandler {
|
||||
return h.personalBestHandler
|
||||
}
|
||||
|
||||
func (h *Handler) EventHandler() *EventHandler {
|
||||
return h.eventHandler
|
||||
}
|
||||
|
||||
func (h *Handler) EventRegistrationHandler() *EventRegistrationHandler {
|
||||
return h.eventRegistrationHandler
|
||||
}
|
||||
|
||||
func (h *Handler) HealthHandler() *HealthHandler {
|
||||
return h.healthHandler
|
||||
}
|
||||
|
||||
func (h *Handler) AuthHandler() *AuthHandler {
|
||||
return h.authHandler
|
||||
}
|
||||
|
||||
func (h *Handler) UserHandler() *UserHandler {
|
||||
return h.userHandler
|
||||
}
|
||||
|
||||
func (h *Handler) AvatarHandler() *AvatarHandler {
|
||||
return h.avatarHandler
|
||||
}
|
||||
|
||||
func (h *Handler) NewsHandler() *NewsHandler {
|
||||
return h.newsHandler
|
||||
}
|
||||
|
||||
func (h *Handler) ReviewHandler() *ReviewHandler {
|
||||
return h.reviewHandler
|
||||
}
|
||||
|
||||
func (h *Handler) UserStatsHandler() *UserStatsHandler {
|
||||
return h.userStatsHandler
|
||||
}
|
||||
|
||||
func (h *Handler) UserWorkoutHandler() *UserWorkoutHandler {
|
||||
return h.userWorkoutHandler
|
||||
}
|
||||
|
||||
func (h *Handler) UserAchievementHandler() *UserAchievementHandler {
|
||||
return h.userAchievementHandler
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
)
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Service is healthy",
|
||||
}
|
||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]string{
|
||||
"status": "ok",
|
||||
"message": "API is working",
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type NewsHandler struct {
|
||||
newsService service.NewsService
|
||||
logger logger.LoggerInterface
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewNewsHandler(newsService service.NewsService, log logger.LoggerInterface) *NewsHandler {
|
||||
return &NewsHandler{
|
||||
newsService: newsService,
|
||||
logger: log,
|
||||
validator: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetNews возвращает список новостей с пагинацией и фильтрацией
|
||||
func (h *NewsHandler) GetNews(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start GetNews Method")
|
||||
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
h.logger.Debug("GetNews parameters",
|
||||
zap.Int("limit", limit),
|
||||
zap.Int("offset", offset),
|
||||
zap.String("category", category),
|
||||
)
|
||||
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 50 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
news, total, err := h.newsService.GetAllNews(limit, offset, category)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get news", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get news")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Successfully retrieved news",
|
||||
zap.Int("count", len(news)),
|
||||
zap.Int("total", int(total)),
|
||||
)
|
||||
|
||||
h.logger.Debug("End GetNews Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"news": news,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetNewsByID возвращает конкретную новость
|
||||
func (h *NewsHandler) GetNewsByID(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start GetNewsByID Method")
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
h.logger.Debug("GetNewsByID parameters", zap.String("id", idStr))
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid news ID", zap.String("id", idStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID")
|
||||
return
|
||||
}
|
||||
|
||||
news, err := h.newsService.GetNewsByID(uint(id))
|
||||
if err != nil {
|
||||
h.logger.Warn("News not found", zap.Uint("id", uint(id)), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusNotFound, "News not found")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Successfully retrieved news by ID", zap.Uint("id", uint(id)))
|
||||
h.logger.Debug("End GetNewsByID Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, news)
|
||||
}
|
||||
|
||||
// CreateNews создает новую новость
|
||||
func (h *NewsHandler) CreateNews(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start CreateNews Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("CreateNews user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in CreateNews",
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateNewsRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("Invalid request body in CreateNews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("CreateNews request data",
|
||||
zap.String("title", req.Title),
|
||||
zap.String("category", string(req.Category)),
|
||||
)
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
h.logger.Warn("Validation failed in CreateNews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
news, err := h.newsService.CreateNews(req, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create news", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create news")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Successfully created news",
|
||||
zap.Uint("newsID", news.ID),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
h.logger.Debug("End CreateNews Method")
|
||||
utils.RespondWithJSON(w, http.StatusCreated, news)
|
||||
}
|
||||
|
||||
// UpdateNews обновляет новость
|
||||
func (h *NewsHandler) UpdateNews(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start UpdateNews Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("UpdateNews user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in UpdateNews")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
h.logger.Debug("UpdateNews parameters", zap.String("id", idStr))
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid news ID in UpdateNews", zap.String("id", idStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateNewsRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("Invalid request body in UpdateNews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("UpdateNews request data",
|
||||
zap.String("title", req.Title),
|
||||
zap.String("category", string(req.Category)),
|
||||
)
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
h.logger.Warn("Validation failed in UpdateNews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
news, err := h.newsService.UpdateNews(uint(id), req, userID)
|
||||
if err != nil {
|
||||
if err.Error() == "access denied" {
|
||||
h.logger.Warn("Access denied in UpdateNews",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("newsID", uint(id)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Access denied")
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to update news", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update news")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Successfully updated news",
|
||||
zap.Uint("newsID", uint(id)),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
h.logger.Debug("End UpdateNews Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, news)
|
||||
}
|
||||
|
||||
// DeleteNews удаляет новость
|
||||
func (h *NewsHandler) DeleteNews(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start DeleteNews Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("DeleteNews user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in DeleteNews")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
h.logger.Debug("DeleteNews parameters", zap.String("id", idStr))
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid news ID in DeleteNews", zap.String("id", idStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.newsService.DeleteNews(uint(id), userID)
|
||||
if err != nil {
|
||||
if err.Error() == "access denied" {
|
||||
h.logger.Warn("Access denied in DeleteNews",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("newsID", uint(id)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Access denied")
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete news", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete news")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Successfully deleted news",
|
||||
zap.Uint("newsID", uint(id)),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
h.logger.Debug("End DeleteNews Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "News deleted successfully"})
|
||||
}
|
||||
|
||||
// CreateComment создает комментарий к новости
|
||||
func (h *NewsHandler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start CreateComment Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("CreateComment user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in CreateComment")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
newsIDStr := chi.URLParam(r, "id")
|
||||
h.logger.Debug("CreateComment parameters", zap.String("newsID", newsIDStr))
|
||||
|
||||
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid news ID in CreateComment", zap.String("newsID", newsIDStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreateCommentRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Warn("Invalid request body in CreateComment", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("CreateComment request data",
|
||||
zap.String("content", req.Content),
|
||||
)
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
h.logger.Warn("Validation failed in CreateComment", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Validation failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := h.newsService.CreateComment(uint(newsID), req, userID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to create comment", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create comment")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Successfully created comment",
|
||||
zap.Uint("commentID", comment.ID),
|
||||
zap.Uint("newsID", uint(newsID)),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
h.logger.Debug("End CreateComment Method")
|
||||
utils.RespondWithJSON(w, http.StatusCreated, comment)
|
||||
}
|
||||
|
||||
// GetComments возвращает комментарии к новости
|
||||
func (h *NewsHandler) GetComments(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start GetComments Method")
|
||||
|
||||
newsIDStr := chi.URLParam(r, "id")
|
||||
h.logger.Debug("GetComments parameters", zap.String("newsID", newsIDStr))
|
||||
|
||||
newsID, err := strconv.ParseUint(newsIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid news ID in GetComments", zap.String("newsID", newsIDStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid news ID")
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := h.newsService.GetCommentsByNewsID(uint(newsID))
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get comments", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get comments")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Successfully retrieved comments",
|
||||
zap.Uint("newsID", uint(newsID)),
|
||||
zap.Int("count", len(comments)),
|
||||
)
|
||||
h.logger.Debug("End GetComments Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, comments)
|
||||
}
|
||||
|
||||
// DeleteComment удаляет комментарий
|
||||
func (h *NewsHandler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start DeleteComment Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("DeleteComment user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in DeleteComment")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
commentIDStr := chi.URLParam(r, "commentId")
|
||||
h.logger.Debug("DeleteComment parameters", zap.String("commentID", commentIDStr))
|
||||
|
||||
commentID, err := strconv.ParseUint(commentIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("Invalid comment ID in DeleteComment", zap.String("commentID", commentIDStr), zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid comment ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.newsService.DeleteComment(uint(commentID), userID)
|
||||
if err != nil {
|
||||
if err.Error() == "access denied" {
|
||||
h.logger.Warn("Access denied in DeleteComment",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("commentID", uint(commentID)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Access denied")
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete comment", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Successfully deleted comment",
|
||||
zap.Uint("commentID", uint(commentID)),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
h.logger.Debug("End DeleteComment Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "Comment deleted successfully"})
|
||||
}
|
||||
|
||||
// GetUserNews возвращает новости конкретного пользователя
|
||||
func (h *NewsHandler) GetUserNews(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("Start GetUserNews Method")
|
||||
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
h.logger.Debug("GetUserNews user context", zap.Uint("userID", userID), zap.Bool("ok", ok))
|
||||
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in GetUserNews")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
||||
|
||||
h.logger.Debug("GetUserNews parameters",
|
||||
zap.Int("limit", limit),
|
||||
zap.Int("offset", offset),
|
||||
)
|
||||
|
||||
if limit == 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
news, total, err := h.newsService.GetUserNews(userID, limit, offset)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get user news", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user news")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Successfully retrieved user news",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Int("count", len(news)),
|
||||
zap.Int("total", int(total)),
|
||||
)
|
||||
h.logger.Debug("End GetUserNews Method")
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"news": news,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
// handlers/personal_best_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type PersonalBestHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
personalBestService service.PersonalBestService
|
||||
}
|
||||
|
||||
func NewPersonalBestHandler(personalBestService service.PersonalBestService) *PersonalBestHandler {
|
||||
return &PersonalBestHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "personal_best"))),
|
||||
personalBestService: personalBestService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePersonalBest создает новый личный рекорд
|
||||
func (h *PersonalBestHandler) CreatePersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling create personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("create personal best failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.PersonalBestCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if req.DistanceType == "" {
|
||||
h.logger.Warn("create personal best failed - distance type required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Distance type is required")
|
||||
return
|
||||
}
|
||||
if req.Time == "" {
|
||||
h.logger.Warn("create personal best failed - time required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Time is required")
|
||||
return
|
||||
}
|
||||
if req.Date.IsZero() {
|
||||
h.logger.Warn("create personal best failed - date required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Date is required")
|
||||
return
|
||||
}
|
||||
|
||||
personalBest, err := h.personalBestService.CreatePersonalBest(user.ID, req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create personal best", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create personal best: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best created successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("personal_best_id", personalBest.ID),
|
||||
zap.String("distance_type", string(personalBest.DistanceType)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, personalBest)
|
||||
}
|
||||
|
||||
// GetPersonalBest возвращает личный рекорд по ID
|
||||
func (h *PersonalBestHandler) GetPersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid personal best ID", zap.String("id", idStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid personal best ID")
|
||||
return
|
||||
}
|
||||
|
||||
personalBest, err := h.personalBestService.GetPersonalBestByID(uint(id))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get personal best", zap.Error(err))
|
||||
if err.Error() == "record not found" {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Personal best not found")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get personal best: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best retrieved successfully",
|
||||
zap.Uint("personal_best_id", personalBest.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, personalBest)
|
||||
}
|
||||
|
||||
// GetUserPersonalBests возвращает все личные рекорды пользователя
|
||||
func (h *PersonalBestHandler) GetUserPersonalBests(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user personal bests request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get personal bests failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
personalBests, err := h.personalBestService.GetUserPersonalBests(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get personal bests", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get personal bests: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user personal bests retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("count", len(personalBests)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, personalBests)
|
||||
}
|
||||
|
||||
// UpdatePersonalBest обновляет личный рекорд
|
||||
func (h *PersonalBestHandler) UpdatePersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update personal best failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid personal best ID", zap.String("id", idStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid personal best ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.PersonalBestUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
personalBest, err := h.personalBestService.UpdatePersonalBest(uint(id), user.ID, req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update personal best", zap.Error(err))
|
||||
if err.Error() == "record not found" {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Personal best not found or access denied")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update personal best: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best updated successfully",
|
||||
zap.Uint("personal_best_id", personalBest.ID),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, personalBest)
|
||||
}
|
||||
|
||||
// DeletePersonalBest удаляет личный рекорд
|
||||
func (h *PersonalBestHandler) DeletePersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling delete personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("delete personal best failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid personal best ID", zap.String("id", idStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid personal best ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.personalBestService.DeletePersonalBest(uint(id), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete personal best", zap.Error(err))
|
||||
if err.Error() == "record not found" {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Personal best not found or access denied")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete personal best: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best deleted successfully",
|
||||
zap.Uint("personal_best_id", uint(id)),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Personal best deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetPersonalBestsByDistance возвращает личные рекорды по дистанции
|
||||
func (h *PersonalBestHandler) GetPersonalBestsByDistance(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get personal bests by distance request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get personal bests by distance failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
distanceType := models.DistanceType(chi.URLParam(r, "distanceType"))
|
||||
if distanceType == "" {
|
||||
h.logger.Warn("distance type parameter is required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Distance type parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация типа дистанции
|
||||
validDistances := map[models.DistanceType]bool{
|
||||
models.Distance5K: true,
|
||||
models.Distance10K: true,
|
||||
models.DistanceHalf: true,
|
||||
models.DistanceFull: true,
|
||||
models.DistanceOther: true,
|
||||
}
|
||||
|
||||
if !validDistances[distanceType] {
|
||||
h.logger.Warn("invalid distance type", zap.String("distance_type", string(distanceType)))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid distance type")
|
||||
return
|
||||
}
|
||||
|
||||
personalBests, err := h.personalBestService.GetPersonalBestsByDistance(user.ID, distanceType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get personal bests by distance", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get personal bests: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal bests by distance retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("distance_type", string(distanceType)),
|
||||
zap.Int("count", len(personalBests)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, personalBests)
|
||||
}
|
||||
|
||||
// GetBestByDistance возвращает лучший результат на дистанции
|
||||
func (h *PersonalBestHandler) GetBestByDistance(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get best by distance request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get best by distance failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
distanceType := models.DistanceType(chi.URLParam(r, "distanceType"))
|
||||
if distanceType == "" {
|
||||
h.logger.Warn("distance type parameter is required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Distance type parameter is required")
|
||||
return
|
||||
}
|
||||
|
||||
best, err := h.personalBestService.GetBestByDistance(user.ID, distanceType)
|
||||
if err != nil {
|
||||
if err.Error() == "record not found" {
|
||||
h.logger.Info("no personal best found for distance",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("distance_type", string(distanceType)),
|
||||
)
|
||||
utils.RespondWithJSON(w, http.StatusOK, nil)
|
||||
return
|
||||
}
|
||||
h.logger.Error("failed to get best by distance", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get best result: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("best by distance retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("distance_type", string(distanceType)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, best)
|
||||
}
|
||||
|
||||
// GetPersonalBestsSummary возвращает сводку лучших результатов
|
||||
func (h *PersonalBestHandler) GetPersonalBestsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get personal bests summary request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get personal bests summary failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.personalBestService.GetPersonalBestsSummary(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get personal bests summary", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get personal bests summary: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal bests summary retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// VerifyPersonalBest подтверждает личный рекорд
|
||||
func (h *PersonalBestHandler) VerifyPersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling verify personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("verify personal best failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid personal best ID", zap.String("id", idStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid personal best ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.personalBestService.VerifyPersonalBest(uint(id), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to verify personal best", zap.Error(err))
|
||||
if err.Error() == "record not found" {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Personal best not found or access denied")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to verify personal best: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best verified successfully",
|
||||
zap.Uint("personal_best_id", uint(id)),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Personal best verified successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetRecentPersonalBests возвращает последние личные рекорды
|
||||
func (h *PersonalBestHandler) GetRecentPersonalBests(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get recent personal bests request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get recent personal bests failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
limit := 10 // default limit
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
personalBests, err := h.personalBestService.GetRecentPersonalBests(user.ID, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get recent personal bests", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get recent personal bests: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("recent personal bests retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("limit", limit),
|
||||
zap.Int("count", len(personalBests)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, personalBests)
|
||||
}
|
||||
|
||||
// CalculatePace вычисляет темп
|
||||
func (h *PersonalBestHandler) CalculatePace(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling calculate pace request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
var req struct {
|
||||
Time string `json:"time"`
|
||||
DistanceType models.DistanceType `json:"distance_type"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Time == "" || req.DistanceType == "" {
|
||||
h.logger.Warn("time and distance type are required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Time and distance type are required")
|
||||
return
|
||||
}
|
||||
|
||||
pace, err := h.personalBestService.CalculatePace(req.Time, req.DistanceType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to calculate pace", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to calculate pace: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("pace calculated successfully",
|
||||
zap.String("time", req.Time),
|
||||
zap.String("distance_type", string(req.DistanceType)),
|
||||
zap.String("pace", pace),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"time": req.Time,
|
||||
"distance_type": req.DistanceType,
|
||||
"pace": pace,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// handlers/review_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ReviewHandler обрабатывает HTTP-запросы, связанные с отзывами
|
||||
type ReviewHandler struct {
|
||||
reviewService service.ReviewService // Сервис для работы с отзывами
|
||||
logger logger.LoggerInterface // Логгер для записи событий
|
||||
}
|
||||
|
||||
// NewReviewHandler создает новый экземпляр ReviewHandler
|
||||
func NewReviewHandler(reviewService service.ReviewService, logger logger.LoggerInterface) *ReviewHandler {
|
||||
return &ReviewHandler{
|
||||
reviewService: reviewService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetReviews возвращает список отзывов с пагинацией и фильтрацией
|
||||
func (h *ReviewHandler) GetReviews(w http.ResponseWriter, r *http.Request) {
|
||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
sortBy := r.URL.Query().Get("sort")
|
||||
filter := r.URL.Query().Get("filter")
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 6
|
||||
}
|
||||
|
||||
reviews, totalPages, err := h.reviewService.GetAllReviews(page, limit, sortBy, filter)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get reviews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get reviews")
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"reviews": reviews,
|
||||
"current_page": page,
|
||||
"total_pages": totalPages,
|
||||
"total_items": len(reviews),
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetReviewsStats возвращает статистику отзывов
|
||||
func (h *ReviewHandler) GetReviewsStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.reviewService.GetReviewsStats()
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to get reviews stats", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get reviews statistics")
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetMyReviews возвращает отзывы текущего аутентифицированного пользователя
|
||||
func (h *ReviewHandler) GetMyReviews(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем ID пользователя из контекста (добавляется middleware аутентификации)
|
||||
userID, ok := r.Context().Value("middleware.UserIDKey").(uint)
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in GetMyReviews",
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем отзывы пользователя из сервиса
|
||||
reviews, err := h.reviewService.GetUserReviews(userID)
|
||||
if err != nil {
|
||||
h.logger.With(zap.Int("userID", int(userID))).Error("Failed to get user reviews", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get your reviews")
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, reviews)
|
||||
}
|
||||
|
||||
// CreateReview создает новый отзыв от имени текущего пользователя
|
||||
func (h *ReviewHandler) CreateReview(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем ID пользователя из контекста
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in CreateReview",
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("Successfully extracted userID from context",
|
||||
zap.Uint("userID", userID),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
)
|
||||
|
||||
// Декодируем тело запроса
|
||||
var req models.CreateReviewRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("Failed to decode review request",
|
||||
zap.Error(err),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем отзыв через сервис
|
||||
review, err := h.reviewService.CreateReview(&req, userID)
|
||||
if err != nil {
|
||||
h.logger.With(zap.Int("userID", int(userID))).Error("Failed to create review", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create review")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Review created successfully",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Any("review_id", review.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, review)
|
||||
}
|
||||
|
||||
// GetReviewByID возвращает отзыв по его идентификатору
|
||||
func (h *ReviewHandler) GetReviewByID(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем ID отзыва из параметров URL
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid review ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем отзыв из сервиса
|
||||
review, err := h.reviewService.GetReviewByID(uint(id))
|
||||
if err != nil {
|
||||
h.logger.With(zap.Int("id", int(id))).Error("Failed to get review", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Review not found")
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, review)
|
||||
}
|
||||
|
||||
// UpdateReview обновляет существующий отзыв
|
||||
func (h *ReviewHandler) UpdateReview(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем ID пользователя из контекста
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in UpdateReview",
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.Uint("userID", userID),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем флаг администратора из контекста
|
||||
isAdmin, _ := r.Context().Value("IsAdmin").(bool)
|
||||
|
||||
// Получаем ID отзыва из параметров URL
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid review ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Декодируем тело запроса
|
||||
var req models.UpdateReviewRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("Failed to decode update review request",
|
||||
zap.Error(err),
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("review_id", uint(id)),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем отзыв через сервис
|
||||
review, err := h.reviewService.UpdateReview(uint(id), &req, userID, isAdmin)
|
||||
if err != nil {
|
||||
h.logger.With(zap.Int("id", int(id))).With(zap.Int("userID", int(userID))).Error("Failed to update review", zap.Error(err))
|
||||
if err.Error() == "unauthorized" {
|
||||
utils.RespondWithError(w, http.StatusForbidden, "You can only update your own reviews")
|
||||
return
|
||||
}
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update review")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Review updated successfully",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("review_id", uint(id)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, review)
|
||||
}
|
||||
|
||||
// DeleteReview удаляет отзыв
|
||||
func (h *ReviewHandler) DeleteReview(w http.ResponseWriter, r *http.Request) {
|
||||
// Получаем ID пользователя из контекста
|
||||
userID, ok := r.Context().Value(middleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
h.logger.Warn("Failed to get userID from context in DeleteReview",
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("method", r.Method),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем флаг администратора из контекста
|
||||
isAdmin, _ := r.Context().Value("IsAdmin").(bool)
|
||||
|
||||
// Получаем ID отзыва из параметров URL
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid review ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Удаляем отзыв через сервис
|
||||
err = h.reviewService.DeleteReview(uint(id), userID, isAdmin)
|
||||
if err != nil {
|
||||
h.logger.With(zap.Int("id", int(id))).With(zap.Int("userID", int(userID))).Error("Failed to delete review", zap.Error(err))
|
||||
if err.Error() == "unauthorized" {
|
||||
utils.RespondWithError(w, http.StatusForbidden, "You can only delete your own reviews")
|
||||
return
|
||||
}
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete review")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Review deleted successfully",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("review_id", uint(id)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{"message": "Review deleted successfully"})
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
// handlers/training_plan_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type TrainingPlanHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
trainingPlanService service.TrainingPlanService
|
||||
}
|
||||
|
||||
func NewTrainingPlanHandler(trainingPlanService service.TrainingPlanService) *TrainingPlanHandler {
|
||||
return &TrainingPlanHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "training_plan"))),
|
||||
trainingPlanService: trainingPlanService,
|
||||
}
|
||||
}
|
||||
|
||||
// TrainingPlanResponse - DTO для ответа с планом тренировок
|
||||
type TrainingPlanResponse struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Weeks int `json:"weeks"`
|
||||
WorkoutsPerWeek int `json:"workouts_per_week"`
|
||||
TargetDistance string `json:"target_distance"`
|
||||
TargetDate time.Time `json:"target_date"`
|
||||
CurrentWeek int `json:"current_week"`
|
||||
Completed bool `json:"completed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Workouts []TrainingWorkoutResponse `json:"workouts,omitempty"`
|
||||
}
|
||||
|
||||
// TrainingWorkoutResponse - DTO для ответа с тренировкой плана
|
||||
type TrainingWorkoutResponse struct {
|
||||
ID uint `json:"id"`
|
||||
PlanID uint `json:"plan_id"`
|
||||
Week int `json:"week"`
|
||||
Day int `json:"day"`
|
||||
Type models.WorkoutType `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Distance float64 `json:"distance_km"`
|
||||
Duration int `json:"duration_min"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateTrainingPlan создает новый план тренировок
|
||||
func (h *TrainingPlanHandler) CreateTrainingPlan(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling create training plan request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("create training plan failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.TrainingPlanCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("creating training plan",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("title", req.Title),
|
||||
zap.Int("weeks", req.Weeks),
|
||||
zap.Int("workouts_per_week", req.WorkoutsPerWeek),
|
||||
)
|
||||
|
||||
// Создаем план тренировок через сервис
|
||||
plan, err := h.trainingPlanService.CreateTrainingPlan(user.ID, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create training plan in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create training plan: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("training plan created successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", plan.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "Training plan created successfully",
|
||||
"plan": toTrainingPlanResponse(plan),
|
||||
})
|
||||
}
|
||||
|
||||
// GetTrainingPlans возвращает все планы тренировок пользователя
|
||||
func (h *TrainingPlanHandler) GetTrainingPlans(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling get training plans request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get training plans failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("getting training plans for user", zap.Uint("user_id", user.ID))
|
||||
|
||||
// Получаем планы тренировок через сервис
|
||||
plans, err := h.trainingPlanService.GetTrainingPlansByUserID(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get training plans from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get training plans: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем в response формат
|
||||
var planResponses []TrainingPlanResponse
|
||||
for _, plan := range plans {
|
||||
planResponses = append(planResponses, toTrainingPlanResponse(&plan))
|
||||
}
|
||||
|
||||
h.logger.Debug("training plans retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("plans_count", len(planResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, planResponses)
|
||||
}
|
||||
|
||||
// GetTrainingPlanByID возвращает план тренировок по ID
|
||||
func (h *TrainingPlanHandler) GetTrainingPlanByID(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling get training plan by ID request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get training plan failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID плана из URL параметров
|
||||
planIDStr := r.URL.Query().Get("id")
|
||||
if planIDStr == "" {
|
||||
h.logger.Warn("get training plan failed - plan ID required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Plan ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("get training plan failed - invalid plan ID",
|
||||
zap.String("plan_id", planIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid plan ID")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("getting training plan by ID",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
// Получаем план тренировок через сервис
|
||||
plan, err := h.trainingPlanService.GetTrainingPlanByID(user.ID, uint(planID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get training plan from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get training plan: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("training plan retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, toTrainingPlanResponse(plan))
|
||||
}
|
||||
|
||||
// UpdateTrainingPlan обновляет план тренировок
|
||||
func (h *TrainingPlanHandler) UpdateTrainingPlan(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update training plan request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update training plan failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID плана из URL параметров
|
||||
planIDStr := r.URL.Query().Get("id")
|
||||
if planIDStr == "" {
|
||||
h.logger.Warn("update training plan failed - plan ID required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Plan ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("update training plan failed - invalid plan ID",
|
||||
zap.String("plan_id", planIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid plan ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.TrainingPlanUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updating training plan",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.String("title", req.Title),
|
||||
)
|
||||
|
||||
// Обновляем план тренировок через сервис
|
||||
plan, err := h.trainingPlanService.UpdateTrainingPlan(user.ID, uint(planID), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update training plan in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update training plan: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("training plan updated successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Training plan updated successfully",
|
||||
"plan": toTrainingPlanResponse(plan),
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTrainingPlan удаляет план тренировок
|
||||
func (h *TrainingPlanHandler) DeleteTrainingPlan(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling delete training plan request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("delete training plan failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID плана из URL параметров
|
||||
planIDStr := r.URL.Query().Get("id")
|
||||
if planIDStr == "" {
|
||||
h.logger.Warn("delete training plan failed - plan ID required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Plan ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("delete training plan failed - invalid plan ID",
|
||||
zap.String("plan_id", planIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid plan ID")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("deleting training plan",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
// Удаляем план тренировок через сервис
|
||||
if err := h.trainingPlanService.DeleteTrainingPlan(user.ID, uint(planID)); err != nil {
|
||||
h.logger.Error("failed to delete training plan in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete training plan: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("training plan deleted successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Training plan deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetActiveTrainingPlan возвращает активный план тренировок пользователя
|
||||
func (h *TrainingPlanHandler) GetActiveTrainingPlan(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Debug("handling get active training plan request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get active training plan failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("getting active training plan for user", zap.Uint("user_id", user.ID))
|
||||
|
||||
// Получаем активный план тренировок через сервис
|
||||
plan, err := h.trainingPlanService.GetActiveTrainingPlan(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get active training plan from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get active training plan: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Debug("active training plan retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", plan.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, toTrainingPlanResponse(plan))
|
||||
}
|
||||
|
||||
// MarkTrainingPlanAsCompleted помечает план тренировок как завершенный
|
||||
func (h *TrainingPlanHandler) MarkTrainingPlanAsCompleted(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling mark training plan as completed request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("mark training plan as completed failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID плана из URL параметров
|
||||
planIDStr := r.URL.Query().Get("id")
|
||||
if planIDStr == "" {
|
||||
h.logger.Warn("mark training plan as completed failed - plan ID required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Plan ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("mark training plan as completed failed - invalid plan ID",
|
||||
zap.String("plan_id", planIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid plan ID")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("marking training plan as completed",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
// Помечаем план как завершенный через сервис
|
||||
if err := h.trainingPlanService.MarkTrainingPlanAsCompleted(user.ID, uint(planID)); err != nil {
|
||||
h.logger.Error("failed to mark training plan as completed in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to mark training plan as completed: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("training plan marked as completed successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Training plan marked as completed successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCurrentWeek обновляет текущую неделю плана тренировок
|
||||
func (h *TrainingPlanHandler) UpdateCurrentWeek(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update current week request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update current week failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Извлекаем ID плана из URL параметров
|
||||
planIDStr := r.URL.Query().Get("id")
|
||||
if planIDStr == "" {
|
||||
h.logger.Warn("update current week failed - plan ID required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Plan ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
planID, err := strconv.ParseUint(planIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("update current week failed - invalid plan ID",
|
||||
zap.String("plan_id", planIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid plan ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
CurrentWeek int `json:"current_week" validate:"required,min=1,max=52"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updating current week for training plan",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Int("current_week", req.CurrentWeek),
|
||||
)
|
||||
|
||||
// Обновляем текущую неделю через сервис
|
||||
if err := h.trainingPlanService.UpdateCurrentWeek(user.ID, uint(planID), req.CurrentWeek); err != nil {
|
||||
h.logger.Error("failed to update current week in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update current week: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("current week updated successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("plan_id", uint(planID)),
|
||||
zap.Int("current_week", req.CurrentWeek),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Current week updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// Вспомогательные функции для преобразования моделей в DTO
|
||||
|
||||
func toTrainingPlanResponse(plan *models.TrainingPlan) TrainingPlanResponse {
|
||||
response := TrainingPlanResponse{
|
||||
ID: plan.ID,
|
||||
UserID: plan.UserID,
|
||||
Title: plan.Title,
|
||||
Description: plan.Description,
|
||||
Weeks: plan.Weeks,
|
||||
WorkoutsPerWeek: plan.WorkoutsPerWeek,
|
||||
TargetDistance: plan.TargetDistance,
|
||||
TargetDate: plan.TargetDate,
|
||||
CurrentWeek: plan.CurrentWeek,
|
||||
Completed: plan.Completed,
|
||||
CreatedAt: plan.CreatedAt,
|
||||
UpdatedAt: plan.UpdatedAt,
|
||||
}
|
||||
|
||||
// Преобразуем тренировки, если они загружены
|
||||
if plan.Workouts != nil {
|
||||
for _, workout := range plan.Workouts {
|
||||
response.Workouts = append(response.Workouts, toTrainingWorkoutResponse(&workout))
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func toTrainingWorkoutResponse(workout *models.TrainingWorkout) TrainingWorkoutResponse {
|
||||
return TrainingWorkoutResponse{
|
||||
ID: workout.ID,
|
||||
PlanID: workout.PlanID,
|
||||
Week: workout.Week,
|
||||
Day: workout.Day,
|
||||
Type: workout.Type,
|
||||
Description: workout.Description,
|
||||
Distance: workout.Distance,
|
||||
Duration: workout.Duration,
|
||||
Completed: workout.Completed,
|
||||
CompletedAt: workout.CompletedAt,
|
||||
CreatedAt: workout.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// handlers/user.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
userService service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(userService service.UserService) *UserHandler {
|
||||
return &UserHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user"))),
|
||||
userService: userService,
|
||||
}
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Avatar string `json:"avatar"`
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// GetUsers возвращает список всех пользователей
|
||||
func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get users request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста для проверки аутентификации
|
||||
_, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get users failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем список пользователей из сервиса
|
||||
users, err := h.userService.GetAllUsers()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get users from service", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get users: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем в response формат
|
||||
var userResponses []UserResponse
|
||||
for _, user := range users {
|
||||
userResponses = append(userResponses, toUserResponse(&user))
|
||||
}
|
||||
|
||||
h.logger.Info("users list retrieved successfully",
|
||||
zap.Int("users_count", len(userResponses)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, userResponses)
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.logger.Info("handling get profile request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get profile failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("profile retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
zap.String("avatar", user.Avatar),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, toUserResponse(user))
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
h.logger.Info("handling update profile request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Логируем тело запроса для отладки
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to read request body", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Failed to read request body: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Восстанавливаем тело для дальнейшего использования
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
h.logger.Debug("raw request body", zap.String("body", string(bodyBytes)))
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
currentUser, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update profile failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация обязательных полей
|
||||
if req.FirstName == "" {
|
||||
h.logger.Warn("update profile failed - first name required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "First name is required")
|
||||
return
|
||||
}
|
||||
if req.LastName == "" {
|
||||
h.logger.Warn("update profile failed - last name required")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Last name is required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updating user profile",
|
||||
zap.Uint("user_id", currentUser.ID),
|
||||
zap.String("first_name", req.FirstName),
|
||||
zap.String("last_name", req.LastName),
|
||||
zap.String("experience", req.Experience),
|
||||
zap.String("goals", req.Goals),
|
||||
zap.Bool("newsletter", req.Newsletter),
|
||||
)
|
||||
|
||||
// Обновляем данные пользователя
|
||||
updatedUser := &models.User{
|
||||
ID: currentUser.ID,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Phone: req.Phone,
|
||||
Experience: req.Experience,
|
||||
Goals: req.Goals,
|
||||
Newsletter: req.Newsletter,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Сохраняем обновленные данные
|
||||
if err := h.userService.UpdateProfile(updatedUser); err != nil {
|
||||
h.logger.Error("failed to update profile in service",
|
||||
zap.Uint("user_id", currentUser.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update profile: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("profile updated successfully",
|
||||
zap.Uint("user_id", currentUser.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Profile updated successfully",
|
||||
"user": toUserResponse(updatedUser),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
// handlers/user_achievement_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserAchievementHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
achievementService service.AchievementService
|
||||
}
|
||||
|
||||
func NewUserAchievementHandler(achievementService service.AchievementService) *UserAchievementHandler {
|
||||
return &UserAchievementHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user_achievement"))),
|
||||
achievementService: achievementService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAchievementsByType возвращает достижения по типу
|
||||
func (h *UserAchievementHandler) GetAchievementsByType(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get achievements by type request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get achievements by type failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем тип из URL параметров
|
||||
achievementType := chi.URLParam(r, "type")
|
||||
if achievementType == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Achievement type is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Валидируем тип достижения
|
||||
validType := models.AchievementType(achievementType)
|
||||
switch validType {
|
||||
case models.AchievementTypeDistance, models.AchievementTypeSpeed,
|
||||
models.AchievementTypeConsistency, models.AchievementTypeEvent,
|
||||
models.AchievementTypeSpecial:
|
||||
// valid type
|
||||
default:
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid achievement type")
|
||||
return
|
||||
}
|
||||
|
||||
achievements, err := h.achievementService.GetAchievementsByType(user.ID, validType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get achievements by type",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("type", achievementType),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievements: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievements by type retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("type", achievementType),
|
||||
zap.Int("achievements_count", len(achievements)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievements)
|
||||
}
|
||||
|
||||
// GetAchievementByID возвращает достижение по ID
|
||||
func (h *UserAchievementHandler) GetAchievementByID(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get achievement by ID request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get achievement by ID failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID достижения из URL параметров
|
||||
achievementIDStr := chi.URLParam(r, "id")
|
||||
if achievementIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Achievement ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
achievementID, err := strconv.ParseUint(achievementIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid achievement ID")
|
||||
return
|
||||
}
|
||||
|
||||
achievement, err := h.achievementService.GetAchievementByID(uint(achievementID), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get achievement by ID",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
if err == service.ErrAchievementNotFound {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Achievement not found")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievement: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievement retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievement)
|
||||
}
|
||||
|
||||
// UpdateAchievement обновляет достижение
|
||||
func (h *UserAchievementHandler) UpdateAchievement(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update achievement request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update achievement failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID достижения из URL параметров
|
||||
achievementIDStr := chi.URLParam(r, "id")
|
||||
if achievementIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Achievement ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
achievementID, err := strconv.ParseUint(achievementIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid achievement ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AchievementCreateRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode achievement update request", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация запроса
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("achievement update validation failed", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем достижение через сервис
|
||||
achievement, err := h.achievementService.UpdateAchievement(uint(achievementID), user.ID, req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update achievement",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
switch err {
|
||||
case service.ErrAchievementNotFound:
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Achievement not found")
|
||||
case service.ErrAchievementAlreadyExists:
|
||||
utils.RespondWithError(w, http.StatusConflict, "Achievement with this title already exists")
|
||||
default:
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update achievement: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievement updated successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Achievement updated successfully",
|
||||
"achievement": achievement,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPublicUserAchievements возвращает достижения пользователя для публичного просмотра
|
||||
func (h *UserAchievementHandler) GetPublicUserAchievements(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get public user achievements request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем userID из URL параметров
|
||||
userIDStr := r.URL.Query().Get("userID")
|
||||
if userIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "User ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(userIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем только подтвержденные достижения для публичного просмотра
|
||||
achievements, err := h.achievementService.GetVerifiedAchievements(uint(userID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get public user achievements",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievements: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("public user achievements retrieved successfully",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Int("achievements_count", len(achievements)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievements)
|
||||
}
|
||||
|
||||
// GetPublicUserAchievementsSummary возвращает сводку по достижениям пользователя для публичного просмотра
|
||||
func (h *UserAchievementHandler) GetPublicUserAchievementsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get public user achievements summary request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем userID из URL параметров
|
||||
userIDStr := r.URL.Query().Get("userID")
|
||||
if userIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "User ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(userIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.achievementService.GetUserAchievementsSummary(uint(userID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get public user achievements summary",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievements summary: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("public user achievements summary retrieved successfully",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Int("total_achievements", summary.TotalAchievements),
|
||||
zap.Int("completed", summary.Completed),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// GetPublicRecentAchievements возвращает последние достижения пользователя для публичного просмотра
|
||||
func (h *UserAchievementHandler) GetPublicRecentAchievements(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get public recent achievements request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем userID из URL параметров
|
||||
userIDStr := r.URL.Query().Get("userID")
|
||||
if userIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "User ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := strconv.ParseUint(userIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем параметр limit из query string (по умолчанию 10)
|
||||
limit := 10
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
if limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем только подтвержденные достижения
|
||||
achievements, err := h.achievementService.GetVerifiedRecentAchievements(uint(userID), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get public recent achievements",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Int("limit", limit),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get recent achievements: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("public recent achievements retrieved successfully",
|
||||
zap.Uint("user_id", uint(userID)),
|
||||
zap.Int("achievements_count", len(achievements)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievements)
|
||||
}
|
||||
|
||||
// CreateAchievement создает новое достижение для пользователя
|
||||
func (h *UserAchievementHandler) CreateAchievement(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling create achievement request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("create achievement failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AchievementCreateRequest
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode achievement request", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация запроса
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("achievement validation failed", zap.Error(err))
|
||||
utils.RespondWithValidationError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем достижение через сервис
|
||||
achievement, err := h.achievementService.CreateAchievement(user.ID, req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create achievement",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
if err == service.ErrAchievementAlreadyExists {
|
||||
utils.RespondWithError(w, http.StatusConflict, "Achievement with this title already exists")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create achievement: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievement created successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", achievement.ID),
|
||||
zap.String("title", achievement.Title),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "Achievement created successfully",
|
||||
"achievement": achievement,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserAchievements возвращает все достижения пользователя
|
||||
func (h *UserAchievementHandler) GetUserAchievements(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user achievements request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get achievements failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
achievements, err := h.achievementService.GetUserAchievements(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user achievements",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievements: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user achievements retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("achievements_count", len(achievements)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievements)
|
||||
}
|
||||
|
||||
// GetUserAchievementsSummary возвращает сводку по достижениям пользователя
|
||||
func (h *UserAchievementHandler) GetUserAchievementsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user achievements summary request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get achievements summary failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
summary, err := h.achievementService.GetUserAchievementsSummary(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user achievements summary",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get achievements summary: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user achievements summary retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("total_achievements", summary.TotalAchievements),
|
||||
zap.Int("completed", summary.Completed),
|
||||
zap.Float64("progress_percent", summary.ProgressPercent),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
// GetRecentAchievements возвращает последние достижения пользователя
|
||||
func (h *UserAchievementHandler) GetRecentAchievements(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get recent achievements request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get recent achievements failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем параметр limit из query string (по умолчанию 10)
|
||||
limit := 10
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
if limitStr != "" {
|
||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||||
limit = parsedLimit
|
||||
}
|
||||
}
|
||||
|
||||
achievements, err := h.achievementService.GetRecentAchievements(user.ID, limit)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get recent achievements",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("limit", limit),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get recent achievements: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("recent achievements retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("achievements_count", len(achievements)),
|
||||
zap.Int("limit", limit),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, achievements)
|
||||
}
|
||||
|
||||
// VerifyAchievement подтверждает достижение пользователя
|
||||
func (h *UserAchievementHandler) VerifyAchievement(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling verify achievement request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("verify achievement failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID достижения из URL параметров
|
||||
achievementIDStr := r.URL.Query().Get("id")
|
||||
if achievementIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Achievement ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
achievementID, err := strconv.ParseUint(achievementIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid achievement ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.achievementService.VerifyAchievement(uint(achievementID), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to verify achievement",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
if err == service.ErrAchievementNotFound {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Achievement not found")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to verify achievement: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievement verified successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Achievement verified successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAchievement удаляет достижение пользователя
|
||||
func (h *UserAchievementHandler) DeleteAchievement(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling delete achievement request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("delete achievement failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID достижения из URL параметров
|
||||
achievementIDStr := r.URL.Query().Get("id")
|
||||
if achievementIDStr == "" {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Achievement ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
achievementID, err := strconv.ParseUint(achievementIDStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid achievement ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.achievementService.DeleteAchievement(uint(achievementID), user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to delete achievement",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
if err == service.ErrAchievementNotFound {
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Achievement not found")
|
||||
} else {
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete achievement: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("achievement deleted successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("achievement_id", uint(achievementID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Achievement deleted successfully",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// handlers/user_stats_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserStatsHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
userStatsService service.UserStatsService
|
||||
}
|
||||
|
||||
func NewUserStatsHandler(userStatsService service.UserStatsService) *UserStatsHandler {
|
||||
return &UserStatsHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user_stats"))),
|
||||
userStatsService: userStatsService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserStats возвращает статистику текущего пользователя
|
||||
func (h *UserStatsHandler) GetUserStats(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user stats request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get user stats failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем статистику через сервис
|
||||
stats, err := h.userStatsService.GetUserStats(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user stats from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user stats: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user stats retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Float64("total_distance", stats.TotalDistance),
|
||||
zap.Int("workouts_count", stats.WorkoutsCount),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetUserStatsByID возвращает статистику пользователя по ID (для администраторов)
|
||||
func (h *UserStatsHandler) GetUserStatsByID(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get user stats by ID request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем текущего пользователя для проверки прав
|
||||
currentUser, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get user stats by ID failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем права администратора
|
||||
if currentUser.Role != "admin" {
|
||||
h.logger.Warn("get user stats by ID failed - insufficient permissions",
|
||||
zap.Uint("user_id", currentUser.ID),
|
||||
zap.String("role", currentUser.Role),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID пользователя из параметров URL
|
||||
userIDStr := chi.URLParam(r, "userID")
|
||||
userID, err := strconv.ParseUint(userIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid user ID parameter",
|
||||
zap.String("user_id_param", userIDStr),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid user ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем статистику через сервис
|
||||
stats, err := h.userStatsService.GetUserStats(uint(userID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user stats by ID from service",
|
||||
zap.Uint("target_user_id", uint(userID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get user stats: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user stats by ID retrieved successfully",
|
||||
zap.Uint("admin_user_id", currentUser.ID),
|
||||
zap.Uint("target_user_id", uint(userID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// UpdatePersonalBest обновляет личный рекорд пользователя
|
||||
func (h *UserStatsHandler) UpdatePersonalBest(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update personal best request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update personal best failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DistanceType string `json:"distance_type"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode update personal best request",
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация обязательных полей
|
||||
if req.DistanceType == "" || req.Time == "" {
|
||||
h.logger.Warn("update personal best failed - missing required fields")
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Distance type and time are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация типа дистанции
|
||||
validDistanceTypes := map[string]bool{
|
||||
"5k": true, "10k": true, "half": true, "marathon": true,
|
||||
}
|
||||
if !validDistanceTypes[req.DistanceType] {
|
||||
h.logger.Warn("update personal best failed - invalid distance type",
|
||||
zap.String("distance_type", req.DistanceType),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid distance type. Must be: 5k, 10k, half, marathon")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updating personal best",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("distance_type", req.DistanceType),
|
||||
zap.String("time", req.Time),
|
||||
)
|
||||
|
||||
// Обновляем личный рекорд через сервис
|
||||
if err := h.userStatsService.UpdatePersonalBest(user.ID, req.DistanceType, req.Time); err != nil {
|
||||
h.logger.Error("failed to update personal best in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update personal best: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("personal best updated successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("distance_type", req.DistanceType),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Personal best updated successfully",
|
||||
"distance_type": req.DistanceType,
|
||||
"time": req.Time,
|
||||
})
|
||||
}
|
||||
|
||||
// IncrementWorkout увеличивает счетчик тренировок и обновляет статистику
|
||||
func (h *UserStatsHandler) IncrementWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling increment workout request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("increment workout failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Distance float64 `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
if err := utils.DecodeJSONBody(w, r, &req); err != nil {
|
||||
h.logger.Error("failed to decode increment workout request",
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация данных тренировки
|
||||
if req.Distance <= 0 {
|
||||
h.logger.Warn("increment workout failed - invalid distance",
|
||||
zap.Float64("distance", req.Distance),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Distance must be greater than 0")
|
||||
return
|
||||
}
|
||||
if req.Duration <= 0 {
|
||||
h.logger.Warn("increment workout failed - invalid duration",
|
||||
zap.Int("duration", req.Duration),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Duration must be greater than 0")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("incrementing workout stats",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Float64("distance", req.Distance),
|
||||
zap.Int("duration", req.Duration),
|
||||
)
|
||||
|
||||
// Обновляем статистику через сервис
|
||||
if err := h.userStatsService.IncrementWorkout(user.ID, req.Distance, req.Duration); err != nil {
|
||||
h.logger.Error("failed to increment workout in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update workout stats: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout stats incremented successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Float64("distance", req.Distance),
|
||||
zap.Int("duration", req.Duration),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Workout stats updated successfully",
|
||||
"distance": req.Distance,
|
||||
"duration": req.Duration,
|
||||
})
|
||||
}
|
||||
|
||||
// ResetWeeklyDistance сбрасывает недельный пробег
|
||||
func (h *UserStatsHandler) ResetWeeklyDistance(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling reset weekly distance request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("reset weekly distance failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("resetting weekly distance",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
// Сбрасываем недельный пробег через сервис
|
||||
if err := h.userStatsService.ResetWeeklyDistance(user.ID); err != nil {
|
||||
h.logger.Error("failed to reset weekly distance in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to reset weekly distance: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("weekly distance reset successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Weekly distance reset successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ResetMonthlyDistance сбрасывает месячный пробег
|
||||
func (h *UserStatsHandler) ResetMonthlyDistance(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling reset monthly distance request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("reset monthly distance failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("resetting monthly distance",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
// Сбрасываем месячный пробег через сервис
|
||||
if err := h.userStatsService.ResetMonthlyDistance(user.ID); err != nil {
|
||||
h.logger.Error("failed to reset monthly distance in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to reset monthly distance: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("monthly distance reset successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Monthly distance reset successfully",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// handlers/user_workout_handler.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
"api_bb/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type UserWorkoutHandler struct {
|
||||
logger logger.LoggerInterface
|
||||
workoutService service.WorkoutService
|
||||
}
|
||||
|
||||
func NewUserWorkoutHandler(workoutService service.WorkoutService) *UserWorkoutHandler {
|
||||
return &UserWorkoutHandler{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("handler", "user_workout"))),
|
||||
workoutService: workoutService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWorkout создает новую тренировку
|
||||
func (h *UserWorkoutHandler) CreateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling create workout request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("create workout failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.WorkoutCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("create workout failed - validation error", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Validation error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("creating new workout",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("type", string(req.Type)),
|
||||
zap.Float64("distance", req.Distance),
|
||||
zap.Int("duration", req.Duration),
|
||||
)
|
||||
|
||||
// Создаем тренировку
|
||||
workout, err := h.workoutService.CreateWorkout(user.ID, &req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create workout in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to create workout: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout created successfully",
|
||||
zap.Uint("workout_id", workout.ID),
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"message": "Workout created successfully",
|
||||
"workout": workout,
|
||||
})
|
||||
}
|
||||
|
||||
// GetWorkouts возвращает список тренировок пользователя
|
||||
func (h *UserWorkoutHandler) GetWorkouts(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get workouts request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get workouts failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
workouts, err := h.workoutService.GetUserWorkouts(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get user workouts from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workouts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("user workouts retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("workouts_count", len(workouts)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, workouts)
|
||||
}
|
||||
|
||||
// GetWorkoutByID возвращает тренировку по ID
|
||||
func (h *UserWorkoutHandler) GetWorkoutByID(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get workout by ID request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get workout failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID тренировки из URL параметров
|
||||
workoutIDStr := chi.URLParam(r, "id")
|
||||
workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID")
|
||||
return
|
||||
}
|
||||
|
||||
workout, err := h.workoutService.GetWorkoutByID(user.ID, uint(workoutID))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get workout from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusNotFound, "Workout not found: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, workout)
|
||||
}
|
||||
|
||||
// UpdateWorkout обновляет тренировку
|
||||
func (h *UserWorkoutHandler) UpdateWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling update workout request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("update workout failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID тренировки из URL параметров
|
||||
workoutIDStr := chi.URLParam(r, "id")
|
||||
workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID")
|
||||
return
|
||||
}
|
||||
|
||||
var req models.WorkoutUpdateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.logger.Error("failed to decode JSON payload", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid request payload: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Валидация
|
||||
if err := utils.ValidateStruct(req); err != nil {
|
||||
h.logger.Warn("update workout failed - validation error", zap.Error(err))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Validation error: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("updating workout",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
zap.String("type", string(req.Type)),
|
||||
)
|
||||
|
||||
// Обновляем тренировку
|
||||
workout, err := h.workoutService.UpdateWorkout(user.ID, uint(workoutID), &req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update workout in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to update workout: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout updated successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Workout updated successfully",
|
||||
"workout": workout,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteWorkout удаляет тренировку
|
||||
func (h *UserWorkoutHandler) DeleteWorkout(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling delete workout request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("delete workout failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем ID тренировки из URL параметров
|
||||
workoutIDStr := chi.URLParam(r, "id")
|
||||
workoutID, err := strconv.ParseUint(workoutIDStr, 10, 32)
|
||||
if err != nil {
|
||||
h.logger.Warn("invalid workout ID", zap.String("workout_id", workoutIDStr))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout ID")
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("deleting workout",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
)
|
||||
|
||||
// Удаляем тренировку
|
||||
if err := h.workoutService.DeleteWorkout(user.ID, uint(workoutID)); err != nil {
|
||||
h.logger.Error("failed to delete workout in service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to delete workout: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout deleted successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Uint("workout_id", uint(workoutID)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Workout deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetWorkoutStats возвращает статистику тренировок
|
||||
func (h *UserWorkoutHandler) GetWorkoutStats(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get workout stats request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get workout stats failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.workoutService.GetWorkoutStats(user.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get workout stats from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workout stats: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workout stats retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Int("total_workouts", stats.TotalWorkouts),
|
||||
zap.Float64("total_distance", stats.TotalDistance),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetWorkoutsByType возвращает тренировки по типу
|
||||
func (h *UserWorkoutHandler) GetWorkoutsByType(w http.ResponseWriter, r *http.Request) {
|
||||
h.logger.Info("handling get workouts by type request",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := middleware.GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
h.logger.Warn("get workouts by type failed - authentication required")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем тип тренировки из URL параметров
|
||||
workoutType := models.WorkoutType(chi.URLParam(r, "type"))
|
||||
|
||||
// Валидация типа тренировки
|
||||
validTypes := map[models.WorkoutType]bool{
|
||||
models.WorkoutTypeEasy: true,
|
||||
models.WorkoutTypeTempo: true,
|
||||
models.WorkoutTypeInterval: true,
|
||||
models.WorkoutTypeLong: true,
|
||||
models.WorkoutTypeRecovery: true,
|
||||
}
|
||||
|
||||
if !validTypes[workoutType] {
|
||||
h.logger.Warn("invalid workout type", zap.String("type", string(workoutType)))
|
||||
utils.RespondWithError(w, http.StatusBadRequest, "Invalid workout type")
|
||||
return
|
||||
}
|
||||
|
||||
workouts, err := h.workoutService.GetWorkoutsByType(user.ID, workoutType)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get workouts by type from service",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("type", string(workoutType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusInternalServerError, "Failed to get workouts: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("workouts by type retrieved successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("type", string(workoutType)),
|
||||
zap.Int("workouts_count", len(workouts)),
|
||||
)
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, workouts)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// models/achievement.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AchievementType string
|
||||
|
||||
const (
|
||||
AchievementTypeDistance AchievementType = "distance"
|
||||
AchievementTypeSpeed AchievementType = "speed"
|
||||
AchievementTypeConsistency AchievementType = "consistency"
|
||||
AchievementTypeEvent AchievementType = "event"
|
||||
AchievementTypeSpecial AchievementType = "special"
|
||||
)
|
||||
|
||||
type Achievement struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Type AchievementType `json:"type" gorm:"type:varchar(20);not null"`
|
||||
Title string `json:"title" gorm:"size:255;not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Result string `json:"result" gorm:"size:100"` // Достигнутый результат
|
||||
Distance string `json:"distance" gorm:"size:50"` // Дистанция достижения
|
||||
Date time.Time `json:"date" gorm:"not null"`
|
||||
Verified bool `json:"verified" gorm:"default:false"`
|
||||
BadgeImage string `json:"badge_image" gorm:"size:500"` // Изображение бейджа
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook
|
||||
func (a *Achievement) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now()
|
||||
}
|
||||
if a.UpdatedAt.IsZero() {
|
||||
a.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (a *Achievement) BeforeUpdate(tx *gorm.DB) error {
|
||||
a.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для создания достижения
|
||||
type AchievementCreateRequest struct {
|
||||
Type AchievementType `json:"type" validate:"required,oneof=distance speed consistency event special"`
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
Result string `json:"result" validate:"max=100"`
|
||||
Distance string `json:"distance" validate:"max=50"`
|
||||
Date time.Time `json:"date" validate:"required"`
|
||||
BadgeImage string `json:"badge_image" validate:"max=500"`
|
||||
}
|
||||
|
||||
// DTO для ответа с достижениями пользователя
|
||||
type UserAchievementsResponse struct {
|
||||
TotalAchievements int `json:"total_achievements"`
|
||||
Completed int `json:"completed"`
|
||||
ProgressPercent float64 `json:"progress_percent"`
|
||||
Achievements []Achievement `json:"achievements"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// models/common.go
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Общая структура для информации об авторе
|
||||
type AuthorInfo struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Email string `json:"email,omitempty"` // Добавляем email
|
||||
}
|
||||
|
||||
// DTO для пагинации
|
||||
type PaginationRequest struct {
|
||||
Page int `form:"page" validate:"min=1" default:"1"`
|
||||
PerPage int `form:"per_page" validate:"min=1,max=100" default:"10"`
|
||||
}
|
||||
|
||||
type PaginationResponse struct {
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Total int `json:"total"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// DTO для фильтров
|
||||
type DateRangeFilter struct {
|
||||
StartDate *time.Time `form:"start_date"`
|
||||
EndDate *time.Time `form:"end_date"`
|
||||
}
|
||||
|
||||
type WorkoutFilter struct {
|
||||
DateRangeFilter
|
||||
Type string `form:"type"`
|
||||
UserID uint `form:"user_id"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// models/email.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmailVerification struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Token string `json:"token" gorm:"size:100;not null;uniqueIndex"`
|
||||
Email string `json:"email" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"size:20;not null"` // verification, password_reset
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
|
||||
Used bool `json:"used" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
|
||||
// Связи
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type PasswordResetRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
}
|
||||
|
||||
type PasswordResetConfirm struct {
|
||||
Token string `json:"token" validate:"required"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// models/event.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeRace EventType = "race"
|
||||
EventTypeTraining EventType = "training"
|
||||
EventTypeSocial EventType = "social"
|
||||
EventTypeWorkshop EventType = "workshop"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Title string `gorm:"size:255;not null" json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `gorm:"type:text;not null" json:"description" validate:"required,min=10"`
|
||||
Date time.Time `gorm:"not null" json:"date" validate:"required"`
|
||||
Location string `gorm:"size:255;not null" json:"location" validate:"required,max=255"`
|
||||
Type EventType `gorm:"size:50;not null" json:"type" validate:"required,oneof=race training social workshop"`
|
||||
Distance string `gorm:"size:50" json:"distance" validate:"max=50"`
|
||||
ParticipantsCount int `gorm:"default:0" json:"participants_count"`
|
||||
MaxParticipants int `gorm:"default:0" json:"max_participants" validate:"min=0"`
|
||||
RegistrationOpen bool `gorm:"default:true" json:"registration_open"`
|
||||
Image string `gorm:"size:500" json:"image" validate:"max=500"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
Registrations []EventRegistration `gorm:"foreignKey:EventID" json:"registrations,omitempty"`
|
||||
}
|
||||
|
||||
type EventRegistration struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null" json:"user_id"`
|
||||
EventID uint `gorm:"not null" json:"event_id"`
|
||||
Status string `gorm:"size:50;default:pending" json:"status" validate:"oneof=pending confirmed cancelled completed"`
|
||||
Notes string `gorm:"type:text" json:"notes" validate:"max=500"`
|
||||
ResultTime string `gorm:"size:20" json:"result_time" validate:"max=20"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Event *Event `gorm:"foreignKey:EventID" json:"event,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// models/gallery.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GalleryCategory string
|
||||
|
||||
const (
|
||||
GalleryCategoryTraining GalleryCategory = "training"
|
||||
GalleryCategoryEvents GalleryCategory = "events"
|
||||
GalleryCategoryCommunity GalleryCategory = "community"
|
||||
GalleryCategoryAchievements GalleryCategory = "achievements"
|
||||
)
|
||||
|
||||
type Gallery struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"size:255;not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
ImagePath string `json:"image_path" gorm:"size:500;not null"` // Путь к изображению
|
||||
Category GalleryCategory `json:"category" gorm:"type:varchar(20);not null"`
|
||||
AuthorID uint `json:"author_id" gorm:"not null;index"`
|
||||
EventDate *time.Time `json:"event_date"` // Дата события на фото
|
||||
Views int `json:"views" gorm:"default:0"`
|
||||
Likes int `json:"likes" gorm:"default:0"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
Author User `json:"author,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook
|
||||
func (g *Gallery) BeforeCreate(tx *gorm.DB) error {
|
||||
if g.CreatedAt.IsZero() {
|
||||
g.CreatedAt = time.Now()
|
||||
}
|
||||
if g.UpdatedAt.IsZero() {
|
||||
g.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (g *Gallery) BeforeUpdate(tx *gorm.DB) error {
|
||||
g.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для создания записи в галерее
|
||||
type GalleryCreateRequest struct {
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
ImagePath string `json:"image_path" validate:"required,max=500"`
|
||||
Category GalleryCategory `json:"category" validate:"required,oneof=training events community achievements"`
|
||||
EventDate *time.Time `json:"event_date"`
|
||||
}
|
||||
|
||||
// DTO для ответа с галереей
|
||||
type GalleryResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImagePath string `json:"image_path"`
|
||||
Category GalleryCategory `json:"category"`
|
||||
EventDate *time.Time `json:"event_date"`
|
||||
Views int `json:"views"`
|
||||
Likes int `json:"likes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NewsCategory string
|
||||
|
||||
const (
|
||||
NewsCategoryEvents NewsCategory = "events"
|
||||
NewsCategoryTraining NewsCategory = "training"
|
||||
NewsCategoryAchievements NewsCategory = "achievements"
|
||||
NewsCategoryCommunity NewsCategory = "community"
|
||||
)
|
||||
|
||||
type News struct {
|
||||
ID uint `json:"id" gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||
|
||||
Title string `json:"title" gorm:"size:255;not null"`
|
||||
Excerpt string `json:"excerpt" gorm:"size:500;not null"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
Image string `json:"image" gorm:"size:255"`
|
||||
Category NewsCategory `json:"category" gorm:"type:varchar(20);not null"`
|
||||
Views int `json:"views" gorm:"default:0"`
|
||||
|
||||
// Связи
|
||||
AuthorID uint `json:"author_id" gorm:"not null"`
|
||||
Author User `json:"author" gorm:"foreignKey:AuthorID"`
|
||||
|
||||
Comments []Comment `json:"comments,omitempty" gorm:"foreignKey:NewsID"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID uint `json:"id" gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
|
||||
// Связи
|
||||
NewsID uint `json:"news_id" gorm:"not null"`
|
||||
AuthorID uint `json:"author_id" gorm:"not null"`
|
||||
Author User `json:"author" gorm:"foreignKey:AuthorID"`
|
||||
}
|
||||
|
||||
// DTO для создания новости
|
||||
type CreateNewsRequest struct {
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Excerpt string `json:"excerpt" validate:"required,min=10,max=500"`
|
||||
Content string `json:"content" validate:"required,min=50"`
|
||||
Image string `json:"image"`
|
||||
Category NewsCategory `json:"category" validate:"required,oneof=events training achievements community"`
|
||||
}
|
||||
|
||||
// DTO для обновления новости
|
||||
type UpdateNewsRequest struct {
|
||||
Title string `json:"title" validate:"omitempty,min=5,max=255"`
|
||||
Excerpt string `json:"excerpt" validate:"omitempty,min=10,max=500"`
|
||||
Content string `json:"content" validate:"omitempty,min=50"`
|
||||
Image string `json:"image"`
|
||||
Category NewsCategory `json:"category" validate:"omitempty,oneof=events training achievements community"`
|
||||
}
|
||||
|
||||
// DTO для ответа с новостью
|
||||
type NewsResponse struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Content string `json:"content"`
|
||||
Image string `json:"image"`
|
||||
Category NewsCategory `json:"category"`
|
||||
Views int `json:"views"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
Comments int `json:"comments_count"`
|
||||
}
|
||||
|
||||
// DTO для комментария
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=1000"`
|
||||
}
|
||||
|
||||
type CommentResponse struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Content string `json:"content"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// models/personal_best.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DistanceType string
|
||||
|
||||
const (
|
||||
Distance5K DistanceType = "5k"
|
||||
Distance10K DistanceType = "10k"
|
||||
DistanceHalf DistanceType = "half_marathon"
|
||||
DistanceFull DistanceType = "marathon"
|
||||
DistanceOther DistanceType = "other"
|
||||
)
|
||||
|
||||
type PersonalBest struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
DistanceType DistanceType `json:"distance_type" gorm:"type:varchar(20);not null"`
|
||||
Time string `json:"time" gorm:"size:20;not null"` // Время в формате "HH:MM:SS"
|
||||
Pace string `json:"pace" gorm:"size:20"` // Темп
|
||||
Date time.Time `json:"date" gorm:"not null"`
|
||||
Verified bool `json:"verified" gorm:"default:false"` // Подтвержден ли результат
|
||||
EventName string `json:"event_name" gorm:"size:255"` // Название забега
|
||||
Location string `json:"location" gorm:"size:255"` // Место проведения
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook
|
||||
func (pb *PersonalBest) BeforeCreate(tx *gorm.DB) error {
|
||||
if pb.CreatedAt.IsZero() {
|
||||
pb.CreatedAt = time.Now()
|
||||
}
|
||||
if pb.UpdatedAt.IsZero() {
|
||||
pb.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (pb *PersonalBest) BeforeUpdate(tx *gorm.DB) error {
|
||||
pb.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для создания личного рекорда
|
||||
type PersonalBestCreateRequest struct {
|
||||
DistanceType DistanceType `json:"distance_type" validate:"required,oneof=5k 10k half_marathon marathon other"`
|
||||
Time string `json:"time" validate:"required,max=20"`
|
||||
Pace string `json:"pace" validate:"max=20"`
|
||||
Date time.Time `json:"date" validate:"required"`
|
||||
EventName string `json:"event_name" validate:"max=255"`
|
||||
Location string `json:"location" validate:"max=255"`
|
||||
}
|
||||
|
||||
// DTO для обновления личного рекорда
|
||||
type PersonalBestUpdateRequest struct {
|
||||
DistanceType DistanceType `json:"distance_type" validate:"omitempty,oneof=5k 10k half_marathon marathon other"`
|
||||
Time string `json:"time" validate:"omitempty,max=20"`
|
||||
Pace string `json:"pace" validate:"omitempty,max=20"`
|
||||
Date time.Time `json:"date"`
|
||||
EventName string `json:"event_name" validate:"omitempty,max=255"`
|
||||
Location string `json:"location" validate:"omitempty,max=255"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// PersonalBestsSummary представляет сводку лучших результатов по дистанциям
|
||||
type PersonalBestsSummary struct {
|
||||
Best5K string `json:"best_5k,omitempty"`
|
||||
Best5KPace string `json:"best_5k_pace,omitempty"`
|
||||
Best10K string `json:"best_10k,omitempty"`
|
||||
Best10KPace string `json:"best_10k_pace,omitempty"`
|
||||
BestHalf string `json:"best_half_marathon,omitempty"`
|
||||
BestHalfPace string `json:"best_half_marathon_pace,omitempty"`
|
||||
BestMarathon string `json:"best_marathon,omitempty"`
|
||||
BestMarathonPace string `json:"best_marathon_pace,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// models/review.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Review struct {
|
||||
ID uint `json:"id" gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"`
|
||||
|
||||
Rating int `json:"rating" gorm:"not null;check:rating >= 1 AND rating <= 5"`
|
||||
Text string `json:"text" gorm:"type:text;not null"`
|
||||
Achievement string `json:"achievement" gorm:"size:255"`
|
||||
Distance string `json:"distance" gorm:"size:50"`
|
||||
Improvement string `json:"improvement" gorm:"size:100"`
|
||||
Trainings int `json:"trainings" gorm:"default:0"`
|
||||
Verified bool `json:"verified" gorm:"default:false"`
|
||||
|
||||
// Связи
|
||||
AuthorID uint `json:"author_id" gorm:"not null"`
|
||||
Author User `json:"author" gorm:"foreignKey:AuthorID"`
|
||||
}
|
||||
|
||||
// DTO для создания отзыва
|
||||
type CreateReviewRequest struct {
|
||||
Rating int `json:"rating" validate:"required,min=1,max=5"`
|
||||
Text string `json:"text" validate:"required,min=10,max=500"`
|
||||
Achievement string `json:"achievement" validate:"max=255"`
|
||||
Distance string `json:"distance" validate:"max=50"`
|
||||
Improvement string `json:"improvement" validate:"max=100"`
|
||||
Trainings int `json:"trainings" validate:"min=0"`
|
||||
}
|
||||
|
||||
// DTO для обновления отзыва
|
||||
type UpdateReviewRequest struct {
|
||||
Rating int `json:"rating" validate:"omitempty,min=1,max=5"`
|
||||
Text string `json:"text" validate:"omitempty,min=10,max=500"`
|
||||
Achievement string `json:"achievement" validate:"omitempty,max=255"`
|
||||
Distance string `json:"distance" validate:"omitempty,max=50"`
|
||||
Improvement string `json:"improvement" validate:"omitempty,max=100"`
|
||||
Trainings int `json:"trainings" validate:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
// DTO для ответа с отзывом
|
||||
type ReviewResponse struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Rating int `json:"rating"`
|
||||
Text string `json:"text"`
|
||||
Achievement string `json:"achievement,omitempty"`
|
||||
Distance string `json:"distance,omitempty"`
|
||||
Improvement string `json:"improvement,omitempty"`
|
||||
Trainings int `json:"trainings"`
|
||||
Verified bool `json:"verified"`
|
||||
Author AuthorInfo `json:"author"`
|
||||
}
|
||||
|
||||
// DTO для статистики отзывов
|
||||
type ReviewsStatsResponse struct {
|
||||
TotalReviews int `json:"total_reviews"`
|
||||
AverageRating float64 `json:"average_rating"`
|
||||
SuccessStories int `json:"success_stories"`
|
||||
RatingDistribution map[int]int `json:"rating_distribution"`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// models/training_plan.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrainingPlan struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Title string `json:"title" gorm:"size:255;not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Weeks int `json:"weeks" gorm:"not null;default:12"` // Длительность плана в неделях
|
||||
WorkoutsPerWeek int `json:"workouts_per_week" gorm:"not null;default:3"` // Тренировок в неделю
|
||||
TargetDistance string `json:"target_distance" gorm:"size:50"` // Целевая дистанция
|
||||
TargetDate time.Time `json:"target_date"` // Дата цели
|
||||
CurrentWeek int `json:"current_week" gorm:"default:1"` // Текущая неделя
|
||||
Completed bool `json:"completed" gorm:"default:false"` // Завершен ли план
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
Workouts []TrainingWorkout `json:"workouts,omitempty" gorm:"foreignKey:PlanID"`
|
||||
}
|
||||
|
||||
type TrainingWorkout struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
PlanID uint `json:"plan_id" gorm:"not null;index"`
|
||||
Week int `json:"week" gorm:"not null"` // Неделя плана
|
||||
Day int `json:"day" gorm:"not null"` // День недели (1-7)
|
||||
Type WorkoutType `json:"type" gorm:"type:varchar(20);not null"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Distance float64 `json:"distance_km" gorm:"type:decimal(5,2)"`
|
||||
Duration int `json:"duration_min"`
|
||||
Completed bool `json:"completed" gorm:"default:false"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate hooks
|
||||
func (tp *TrainingPlan) BeforeCreate(tx *gorm.DB) error {
|
||||
if tp.CreatedAt.IsZero() {
|
||||
tp.CreatedAt = time.Now()
|
||||
}
|
||||
if tp.UpdatedAt.IsZero() {
|
||||
tp.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *TrainingWorkout) BeforeCreate(tx *gorm.DB) error {
|
||||
if tw.CreatedAt.IsZero() {
|
||||
tw.CreatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (tp *TrainingPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||
tp.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для создания плана тренировок
|
||||
type TrainingPlanCreateRequest struct {
|
||||
Title string `json:"title" validate:"required,min=5,max=255"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
Weeks int `json:"weeks" validate:"required,min=1,max=52"`
|
||||
WorkoutsPerWeek int `json:"workouts_per_week" validate:"required,min=1,max=7"`
|
||||
TargetDistance string `json:"target_distance" validate:"max=50"`
|
||||
TargetDate time.Time `json:"target_date"`
|
||||
}
|
||||
|
||||
// DTO для обновления плана тренировок
|
||||
type TrainingPlanUpdateRequest struct {
|
||||
Title string `json:"title" validate:"min=5,max=255"`
|
||||
Description string `json:"description" validate:"max=1000"`
|
||||
Weeks int `json:"weeks" validate:"min=1,max=52"`
|
||||
WorkoutsPerWeek int `json:"workouts_per_week" validate:"min=1,max=7"`
|
||||
TargetDistance string `json:"target_distance" validate:"max=50"`
|
||||
TargetDate time.Time `json:"target_date"`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// models/user.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// models/user.go - добавить поле Avatar
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Email string `json:"email" gorm:"uniqueIndex;not null"`
|
||||
Password string `json:"-" gorm:"not null"`
|
||||
FirstName string `json:"first_name" gorm:"not null"`
|
||||
LastName string `json:"last_name" gorm:"not null"`
|
||||
Avatar string `json:"avatar"` // Путь к файлу аватара
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
Role string `json:"role" gorm:"default:user"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
EmailVerified bool `json:"email_verified" gorm:"default:false"`
|
||||
VerifiedAt time.Time `json:"verified_at"`
|
||||
|
||||
// Связи
|
||||
Workouts []Workout `json:"workouts,omitempty" gorm:"foreignKey:UserID"`
|
||||
PersonalBests []PersonalBest `json:"personal_bests,omitempty" gorm:"foreignKey:UserID"`
|
||||
Achievements []Achievement `json:"achievements,omitempty" gorm:"foreignKey:UserID"`
|
||||
TrainingPlans []TrainingPlan `json:"training_plans,omitempty" gorm:"foreignKey:UserID"`
|
||||
News []News `json:"news,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
Comments []Comment `json:"comments,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
Reviews []Review `json:"reviews,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
Gallery []Gallery `json:"gallery,omitempty" gorm:"foreignKey:AuthorID"`
|
||||
EventRegistrations []EventRegistration `json:"event_registrations,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
type UserUpdate struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HashPassword хеширует пароль перед сохранением
|
||||
func (u *User) HashPassword() error {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Password = string(hashedPassword)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword проверяет пароль
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// BeforeCreate hook для GORM
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
||||
if u.CreatedAt.IsZero() {
|
||||
u.CreatedAt = time.Now()
|
||||
}
|
||||
if u.UpdatedAt.IsZero() {
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
return u.HashPassword()
|
||||
}
|
||||
|
||||
// BeforeUpdate hook для GORM
|
||||
func (u *User) BeforeUpdate(tx *gorm.DB) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для обновления профиля
|
||||
type UserUpdateRequest struct {
|
||||
FirstName string `json:"first_name" validate:"required,min=2,max=100"`
|
||||
LastName string `json:"last_name" validate:"required,min=2,max=100"`
|
||||
Phone string `json:"phone" validate:"max=20"`
|
||||
Experience string `json:"experience" validate:"max=50"`
|
||||
Goals string `json:"goals" validate:"max=100"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
}
|
||||
|
||||
// DTO для ответа с пользователем (без sensitive данных)
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Avatar string `json:"avatar"`
|
||||
Phone string `json:"phone"`
|
||||
Experience string `json:"experience"`
|
||||
Goals string `json:"goals"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// DTO для ответа с пользователем и статистикой
|
||||
type UserWithStatsResponse struct {
|
||||
UserResponse
|
||||
Stats *UserStatsResponse `json:"stats,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// models/user_stats.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserStats struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"uniqueIndex;not null"`
|
||||
TotalDistance float64 `json:"total_distance" gorm:"type:decimal(10,2);default:0"` // Общий пробег в км
|
||||
TotalTime int `json:"total_time" gorm:"default:0"` // Общее время в минутах
|
||||
AvgPace string `json:"avg_pace" gorm:"size:20"` // Средний темп
|
||||
WorkoutsCount int `json:"workouts_count" gorm:"default:0"` // Количество тренировок
|
||||
CurrentStreak int `json:"current_streak" gorm:"default:0"` // Текущая серия дней подряд
|
||||
LongestStreak int `json:"longest_streak" gorm:"default:0"` // Самая длинная серия
|
||||
WeeklyDistance float64 `json:"weekly_distance" gorm:"type:decimal(8,2);default:0"` // Пробег за неделю
|
||||
MonthlyDistance float64 `json:"monthly_distance" gorm:"type:decimal(8,2);default:0"` // Пробег за месяц
|
||||
Best5K string `json:"best_5k" gorm:"size:20"` // Лучший результат на 5к
|
||||
Best10K string `json:"best_10k" gorm:"size:20"` // Лучший результат на 10к
|
||||
BestHalf string `json:"best_half" gorm:"size:20"` // Лучший результат на полумарафон
|
||||
BestMarathon string `json:"best_marathon" gorm:"size:20"` // Лучший результат на марафон
|
||||
LastWorkout time.Time `json:"last_workout"` // Последняя тренировка
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook
|
||||
func (us *UserStats) BeforeCreate(tx *gorm.DB) error {
|
||||
if us.CreatedAt.IsZero() {
|
||||
us.CreatedAt = time.Now()
|
||||
}
|
||||
if us.UpdatedAt.IsZero() {
|
||||
us.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (us *UserStats) BeforeUpdate(tx *gorm.DB) error {
|
||||
us.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для статистики пользователя
|
||||
type UserStatsResponse struct {
|
||||
TotalDistance float64 `json:"total_distance"`
|
||||
TotalTime int `json:"total_time"`
|
||||
AvgPace string `json:"avg_pace"`
|
||||
WorkoutsCount int `json:"workouts_count"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
WeeklyDistance float64 `json:"weekly_distance"`
|
||||
MonthlyDistance float64 `json:"monthly_distance"`
|
||||
PersonalBests PersonalBestsSummary `json:"personal_bests"`
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// models/workout.go
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WorkoutType string
|
||||
|
||||
const (
|
||||
WorkoutTypeEasy WorkoutType = "easy"
|
||||
WorkoutTypeTempo WorkoutType = "tempo"
|
||||
WorkoutTypeInterval WorkoutType = "interval"
|
||||
WorkoutTypeLong WorkoutType = "long"
|
||||
WorkoutTypeRecovery WorkoutType = "recovery"
|
||||
)
|
||||
|
||||
type Workout struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Type WorkoutType `json:"type" gorm:"type:varchar(20);not null"`
|
||||
Distance float64 `json:"distance_km" gorm:"type:decimal(5,2);not null"` // Дистанция в км
|
||||
Duration int `json:"duration_min" gorm:"not null"` // Продолжительность в минутах
|
||||
Pace string `json:"pace" gorm:"size:20"` // Темп (например, "5:30")
|
||||
Calories int `json:"calories" gorm:"default:0"` // Сожженные калории
|
||||
Notes string `json:"notes" gorm:"type:text"` // Заметки к тренировке
|
||||
Date time.Time `json:"date" gorm:"not null;index"` // Дата тренировки
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Связи
|
||||
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook
|
||||
func (w *Workout) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.CreatedAt.IsZero() {
|
||||
w.CreatedAt = time.Now()
|
||||
}
|
||||
if w.UpdatedAt.IsZero() {
|
||||
w.UpdatedAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate hook
|
||||
func (w *Workout) BeforeUpdate(tx *gorm.DB) error {
|
||||
w.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DTO для создания тренировки
|
||||
type WorkoutCreateRequest struct {
|
||||
Type WorkoutType `json:"type" validate:"required,oneof=easy tempo interval long recovery"`
|
||||
Distance float64 `json:"distance_km" validate:"required,min=0.1,max=1000"`
|
||||
Duration int `json:"duration_min" validate:"required,min=1,max=1440"`
|
||||
Pace string `json:"pace" validate:"maxlen=20"`
|
||||
Calories int `json:"calories" validate:"minint=0,maxint=5000"`
|
||||
Notes string `json:"notes" validate:"maxlen=1000"`
|
||||
Date time.Time `json:"date" validate:"required"`
|
||||
}
|
||||
|
||||
// DTO для обновления тренировки
|
||||
type WorkoutUpdateRequest struct {
|
||||
Type WorkoutType `json:"type" validate:"oneof=easy tempo interval long recovery"`
|
||||
Distance float64 `json:"distance_km" validate:"min=0.1,max=1000"`
|
||||
Duration int `json:"duration_min" validate:"min=1,max=1440"`
|
||||
Pace string `json:"pace" validate:"maxlen=20"`
|
||||
Calories int `json:"calories" validate:"minint=0,maxint=5000"`
|
||||
Notes string `json:"notes" validate:"maxlen=1000"`
|
||||
Date time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// DTO для статистики тренировок
|
||||
type WorkoutStatsResponse struct {
|
||||
TotalWorkouts int `json:"total_workouts"`
|
||||
TotalDistance float64 `json:"total_distance_km"`
|
||||
TotalTime int `json:"total_time_min"`
|
||||
AveragePace string `json:"average_pace"`
|
||||
MonthlyStats []MonthlyStat `json:"monthly_stats"`
|
||||
}
|
||||
|
||||
type MonthlyStat struct {
|
||||
Month string `json:"month"`
|
||||
Distance float64 `json:"distance_km"`
|
||||
Workouts int `json:"workouts"`
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// repositories/achievement_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"api_bb/internal/models"
|
||||
)
|
||||
|
||||
type AchievementRepository interface {
|
||||
Create(achievement *models.Achievement) error
|
||||
GetByID(id uint) (*models.Achievement, error)
|
||||
GetByUserID(userID uint) ([]models.Achievement, error)
|
||||
GetByUserAndType(userID uint, achievementType models.AchievementType) ([]models.Achievement, error)
|
||||
GetVerifiedByUserID(userID uint) ([]models.Achievement, error)
|
||||
GetByDateRange(userID uint, startDate, endDate time.Time) ([]models.Achievement, error)
|
||||
Update(achievement *models.Achievement) error
|
||||
Delete(id uint) error
|
||||
VerifyAchievement(id uint) error
|
||||
GetUserAchievementsSummary(userID uint) (*models.UserAchievementsResponse, error)
|
||||
GetRecentAchievements(userID uint, limit int) ([]models.Achievement, error)
|
||||
CountByType(userID uint) (map[models.AchievementType]int64, error)
|
||||
ExistsByTitleAndUser(userID uint, title string) (bool, error)
|
||||
}
|
||||
|
||||
type achievementRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAchievementRepository(db *gorm.DB) AchievementRepository {
|
||||
return &achievementRepository{db: db}
|
||||
}
|
||||
|
||||
// Create создает новое достижение
|
||||
func (r *achievementRepository) Create(achievement *models.Achievement) error {
|
||||
return r.db.Create(achievement).Error
|
||||
}
|
||||
|
||||
// GetByID возвращает достижение по ID
|
||||
func (r *achievementRepository) GetByID(id uint) (*models.Achievement, error) {
|
||||
var achievement models.Achievement
|
||||
err := r.db.Preload("User").First(&achievement, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &achievement, nil
|
||||
}
|
||||
|
||||
// GetByUserID возвращает все достижения пользователя
|
||||
func (r *achievementRepository) GetByUserID(userID uint) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ?", userID).
|
||||
Order("date DESC, created_at DESC").
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// GetByUserAndType возвращает достижения пользователя по типу
|
||||
func (r *achievementRepository) GetByUserAndType(userID uint, achievementType models.AchievementType) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ? AND type = ?", userID, achievementType).
|
||||
Order("date DESC").
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// GetVerifiedByUserID возвращает подтвержденные достижения пользователя
|
||||
func (r *achievementRepository) GetVerifiedByUserID(userID uint) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ? AND verified = ?", userID, true).
|
||||
Order("date DESC").
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// GetByDateRange возвращает достижения за период времени
|
||||
func (r *achievementRepository) GetByDateRange(userID uint, startDate, endDate time.Time) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Order("date DESC").
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// Update обновляет достижение
|
||||
func (r *achievementRepository) Update(achievement *models.Achievement) error {
|
||||
return r.db.Save(achievement).Error
|
||||
}
|
||||
|
||||
// Delete удаляет достижение
|
||||
func (r *achievementRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Achievement{}, id).Error
|
||||
}
|
||||
|
||||
// VerifyAchievement подтверждает достижение
|
||||
func (r *achievementRepository) VerifyAchievement(id uint) error {
|
||||
return r.db.Model(&models.Achievement{}).
|
||||
Where("id = ?", id).
|
||||
Update("verified", true).
|
||||
Error
|
||||
}
|
||||
|
||||
// GetUserAchievementsSummary возвращает сводку по достижениям пользователя
|
||||
func (r *achievementRepository) GetUserAchievementsSummary(userID uint) (*models.UserAchievementsResponse, error) {
|
||||
var totalCount int64
|
||||
var verifiedCount int64
|
||||
|
||||
// Считаем общее количество достижений
|
||||
err := r.db.Model(&models.Achievement{}).
|
||||
Where("user_id = ?", userID).
|
||||
Count(&totalCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Считаем количество подтвержденных достижений
|
||||
err = r.db.Model(&models.Achievement{}).
|
||||
Where("user_id = ? AND verified = ?", userID, true).
|
||||
Count(&verifiedCount).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем все достижения пользователя
|
||||
achievements, err := r.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Вычисляем процент прогресса
|
||||
progressPercent := 0.0
|
||||
if totalCount > 0 {
|
||||
progressPercent = (float64(verifiedCount) / float64(totalCount)) * 100
|
||||
}
|
||||
|
||||
return &models.UserAchievementsResponse{
|
||||
TotalAchievements: int(totalCount),
|
||||
Completed: int(verifiedCount),
|
||||
ProgressPercent: progressPercent,
|
||||
Achievements: achievements,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRecentAchievements возвращает последние достижения пользователя
|
||||
func (r *achievementRepository) GetRecentAchievements(userID uint, limit int) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ?", userID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// CountByType возвращает количество достижений по типам
|
||||
func (r *achievementRepository) CountByType(userID uint) (map[models.AchievementType]int64, error) {
|
||||
type CountResult struct {
|
||||
Type models.AchievementType
|
||||
Count int64
|
||||
}
|
||||
|
||||
var results []CountResult
|
||||
err := r.db.Model(&models.Achievement{}).
|
||||
Select("type, COUNT(*) as count").
|
||||
Where("user_id = ?", userID).
|
||||
Group("type").
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[models.AchievementType]int64)
|
||||
for _, result := range results {
|
||||
counts[result.Type] = result.Count
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// ExistsByTitleAndUser проверяет, существует ли достижение с таким названием у пользователя
|
||||
func (r *achievementRepository) ExistsByTitleAndUser(userID uint, title string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Achievement{}).
|
||||
Where("user_id = ? AND title = ?", userID, title).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetUnverifiedAchievements возвращает неподтвержденные достижения
|
||||
func (r *achievementRepository) GetUnverifiedAchievements(userID uint) ([]models.Achievement, error) {
|
||||
var achievements []models.Achievement
|
||||
err := r.db.Where("user_id = ? AND verified = ?", userID, false).
|
||||
Order("created_at DESC").
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return achievements, nil
|
||||
}
|
||||
|
||||
// GetAchievementsWithPagination возвращает достижения с пагинацией
|
||||
func (r *achievementRepository) GetAchievementsWithPagination(userID uint, page, pageSize int) ([]models.Achievement, int64, error) {
|
||||
var achievements []models.Achievement
|
||||
var totalCount int64
|
||||
|
||||
// Считаем общее количество
|
||||
err := r.db.Model(&models.Achievement{}).
|
||||
Where("user_id = ?", userID).
|
||||
Count(&totalCount).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Получаем данные с пагинацией
|
||||
offset := (page - 1) * pageSize
|
||||
err = r.db.Where("user_id = ?", userID).
|
||||
Order("date DESC, created_at DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&achievements).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return achievements, totalCount, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CommentRepository interface {
|
||||
Create(comment *models.Comment) error
|
||||
GetByNewsID(newsID uint) ([]models.Comment, error)
|
||||
Delete(id uint) error
|
||||
GetByID(id uint) (*models.Comment, error)
|
||||
}
|
||||
|
||||
type commentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCommentRepository(db *gorm.DB) CommentRepository {
|
||||
return &commentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *commentRepository) Create(comment *models.Comment) error {
|
||||
return r.db.Create(comment).Error
|
||||
}
|
||||
|
||||
func (r *commentRepository) GetByNewsID(newsID uint) ([]models.Comment, error) {
|
||||
var comments []models.Comment
|
||||
err := r.db.Preload("Author").Where("news_id = ?", newsID).
|
||||
Order("created_at ASC").Find(&comments).Error
|
||||
return comments, err
|
||||
}
|
||||
|
||||
func (r *commentRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Comment{}, id).Error
|
||||
}
|
||||
|
||||
func (r *commentRepository) GetByID(id uint) (*models.Comment, error) {
|
||||
var comment models.Comment
|
||||
err := r.db.Preload("Author").Where("id = ?", id).First(&comment).Error
|
||||
return &comment, err
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// repository/email_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmailRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEmailRepository(db *gorm.DB) *EmailRepository {
|
||||
return &EmailRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *EmailRepository) CreateVerificationToken(verification *models.EmailVerification) error {
|
||||
return r.db.Create(verification).Error
|
||||
}
|
||||
|
||||
func (r *EmailRepository) GetVerificationToken(token string) (*models.EmailVerification, error) {
|
||||
var verification models.EmailVerification
|
||||
err := r.db.Where("token = ? AND used = ? AND expires_at > ?", token, false, time.Now()).
|
||||
Preload("User").
|
||||
First(&verification).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &verification, nil
|
||||
}
|
||||
|
||||
func (r *EmailRepository) MarkTokenAsUsed(token string) error {
|
||||
return r.db.Model(&models.EmailVerification{}).
|
||||
Where("token = ?", token).
|
||||
Updates(map[string]interface{}{
|
||||
"used": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *EmailRepository) DeleteExpiredTokens() error {
|
||||
return r.db.Where("expires_at < ?", time.Now()).Delete(&models.EmailVerification{}).Error
|
||||
}
|
||||
|
||||
func (r *EmailRepository) GetUsersWithNewsletter() ([]models.User, error) {
|
||||
var users []models.User
|
||||
err := r.db.Where("newsletter = ? AND email_verified = ?", true, true).
|
||||
Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
// MarkEmailAsVerified помечает email пользователя как верифицированный
|
||||
func (r *EmailRepository) MarkEmailAsVerified(userID uint) error {
|
||||
return r.db.Model(&models.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"email_verified": true,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetUserByEmail возвращает пользователя по email
|
||||
func (r *EmailRepository) GetUserByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdatePassword обновляет пароль пользователя
|
||||
func (r *EmailRepository) UpdatePassword(userID uint, newPassword string) error {
|
||||
return r.db.Model(&models.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"password": newPassword,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// repository/event_registration_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EventRegistrationRepository interface {
|
||||
Create(registration *models.EventRegistration) error
|
||||
FindByID(id uint) (*models.EventRegistration, error)
|
||||
FindByEventID(eventID uint) ([]models.EventRegistration, error)
|
||||
FindByUserID(userID uint) ([]models.EventRegistration, error)
|
||||
FindByEventAndUser(eventID, userID uint) (*models.EventRegistration, error)
|
||||
Update(registration *models.EventRegistration) error
|
||||
Delete(id uint) error
|
||||
UpdateStatus(registrationID uint, status string) error
|
||||
UpdateResultTime(registrationID uint, resultTime string) error
|
||||
CountByEventID(eventID uint) (int64, error)
|
||||
}
|
||||
|
||||
type eventRegistrationRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEventRegistrationRepository(db *gorm.DB) EventRegistrationRepository {
|
||||
return &eventRegistrationRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) Create(registration *models.EventRegistration) error {
|
||||
return r.db.Create(registration).Error
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) FindByID(id uint) (*models.EventRegistration, error) {
|
||||
var registration models.EventRegistration
|
||||
err := r.db.Preload("Event").Preload("User").First(®istration, id).Error
|
||||
return ®istration, err
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) FindByEventID(eventID uint) ([]models.EventRegistration, error) {
|
||||
var registrations []models.EventRegistration
|
||||
err := r.db.Preload("User").Where("event_id = ?", eventID).Find(®istrations).Error
|
||||
return registrations, err
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) FindByUserID(userID uint) ([]models.EventRegistration, error) {
|
||||
var registrations []models.EventRegistration
|
||||
err := r.db.Preload("Event").Where("user_id = ?", userID).Find(®istrations).Error
|
||||
return registrations, err
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) FindByEventAndUser(eventID, userID uint) (*models.EventRegistration, error) {
|
||||
var registration models.EventRegistration
|
||||
err := r.db.Where("event_id = ? AND user_id = ?", eventID, userID).First(®istration).Error
|
||||
return ®istration, err
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) Update(registration *models.EventRegistration) error {
|
||||
return r.db.Save(registration).Error
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.EventRegistration{}, id).Error
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) UpdateStatus(registrationID uint, status string) error {
|
||||
result := r.db.Model(&models.EventRegistration{}).Where("id = ?", registrationID).Update("status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("registration not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) UpdateResultTime(registrationID uint, resultTime string) error {
|
||||
result := r.db.Model(&models.EventRegistration{}).Where("id = ?", registrationID).Update("result_time", resultTime)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("registration not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *eventRegistrationRepository) CountByEventID(eventID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.EventRegistration{}).Where("event_id = ? AND status IN ?", eventID, []string{"pending", "confirmed"}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// repository/event_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EventRepository interface {
|
||||
Create(event *models.Event) error
|
||||
FindByID(id uint) (*models.Event, error)
|
||||
FindAll() ([]models.Event, error)
|
||||
Update(event *models.Event) error
|
||||
Delete(id uint) error
|
||||
FindByType(eventType models.EventType) ([]models.Event, error)
|
||||
FindUpcoming() ([]models.Event, error)
|
||||
FindByDateRange(startDate, endDate time.Time) ([]models.Event, error)
|
||||
UpdateParticipantsCount(eventID uint, count int) error
|
||||
UpdateRegistrationStatus(eventID uint, registrationOpen bool) error
|
||||
}
|
||||
|
||||
type eventRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewEventRepository(db *gorm.DB) EventRepository {
|
||||
return &eventRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *eventRepository) Create(event *models.Event) error {
|
||||
return r.db.Create(event).Error
|
||||
}
|
||||
|
||||
func (r *eventRepository) FindByID(id uint) (*models.Event, error) {
|
||||
var event models.Event
|
||||
err := r.db.Preload("Registrations").First(&event, id).Error
|
||||
return &event, err
|
||||
}
|
||||
|
||||
func (r *eventRepository) FindAll() ([]models.Event, error) {
|
||||
var events []models.Event
|
||||
err := r.db.Order("date DESC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func (r *eventRepository) Update(event *models.Event) error {
|
||||
return r.db.Save(event).Error
|
||||
}
|
||||
|
||||
func (r *eventRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Event{}, id).Error
|
||||
}
|
||||
|
||||
func (r *eventRepository) FindByType(eventType models.EventType) ([]models.Event, error) {
|
||||
var events []models.Event
|
||||
err := r.db.Where("type = ?", eventType).Order("date DESC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func (r *eventRepository) FindUpcoming() ([]models.Event, error) {
|
||||
var events []models.Event
|
||||
err := r.db.Where("date >= ?", time.Now()).Order("date ASC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func (r *eventRepository) FindByDateRange(startDate, endDate time.Time) ([]models.Event, error) {
|
||||
var events []models.Event
|
||||
err := r.db.Where("date BETWEEN ? AND ?", startDate, endDate).Order("date ASC").Find(&events).Error
|
||||
return events, err
|
||||
}
|
||||
|
||||
func (r *eventRepository) UpdateParticipantsCount(eventID uint, count int) error {
|
||||
result := r.db.Model(&models.Event{}).Where("id = ?", eventID).Update("participants_count", count)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("event not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *eventRepository) UpdateRegistrationStatus(eventID uint, registrationOpen bool) error {
|
||||
result := r.db.Model(&models.Event{}).Where("id = ?", eventID).Update("registration_open", registrationOpen)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("event not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// repository/gallery_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GalleryRepository interface {
|
||||
Create(gallery *models.Gallery) error
|
||||
FindByID(id uint) (*models.Gallery, error)
|
||||
FindAll() ([]models.Gallery, error)
|
||||
Update(gallery *models.Gallery) error
|
||||
Delete(id uint) error
|
||||
FindByCategory(category models.GalleryCategory) ([]models.Gallery, error)
|
||||
FindByAuthor(authorID uint) ([]models.Gallery, error)
|
||||
FindPopular(limit int) ([]models.Gallery, error)
|
||||
FindRecent(limit int) ([]models.Gallery, error)
|
||||
IncrementViews(galleryID uint) error
|
||||
IncrementLikes(galleryID uint) error
|
||||
DecrementLikes(galleryID uint) error
|
||||
FindByEventDateRange(startDate, endDate time.Time) ([]models.Gallery, error)
|
||||
}
|
||||
|
||||
type galleryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewGalleryRepository(db *gorm.DB) GalleryRepository {
|
||||
return &galleryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *galleryRepository) Create(gallery *models.Gallery) error {
|
||||
return r.db.Create(gallery).Error
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindByID(id uint) (*models.Gallery, error) {
|
||||
var gallery models.Gallery
|
||||
err := r.db.Preload("Author").First(&gallery, id).Error
|
||||
return &gallery, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindAll() ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").Order("created_at DESC").Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) Update(gallery *models.Gallery) error {
|
||||
return r.db.Save(gallery).Error
|
||||
}
|
||||
|
||||
func (r *galleryRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Gallery{}, id).Error
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindByCategory(category models.GalleryCategory) ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").Where("category = ?", category).Order("created_at DESC").Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindByAuthor(authorID uint) ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").Where("author_id = ?", authorID).Order("created_at DESC").Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindPopular(limit int) ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").Order("views DESC, likes DESC").Limit(limit).Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindRecent(limit int) ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").Order("created_at DESC").Limit(limit).Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
|
||||
func (r *galleryRepository) IncrementViews(galleryID uint) error {
|
||||
result := r.db.Model(&models.Gallery{}).Where("id = ?", galleryID).Update("views", gorm.Expr("views + ?", 1))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("gallery not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *galleryRepository) IncrementLikes(galleryID uint) error {
|
||||
result := r.db.Model(&models.Gallery{}).Where("id = ?", galleryID).Update("likes", gorm.Expr("likes + ?", 1))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("gallery not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *galleryRepository) DecrementLikes(galleryID uint) error {
|
||||
result := r.db.Model(&models.Gallery{}).Where("id = ? AND likes > 0", galleryID).Update("likes", gorm.Expr("likes - ?", 1))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("gallery not found or likes already zero")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *galleryRepository) FindByEventDateRange(startDate, endDate time.Time) ([]models.Gallery, error) {
|
||||
var galleries []models.Gallery
|
||||
err := r.db.Preload("Author").
|
||||
Where("event_date BETWEEN ? AND ?", startDate, endDate).
|
||||
Order("event_date DESC").
|
||||
Find(&galleries).Error
|
||||
return galleries, err
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NewsRepository interface {
|
||||
Create(news *models.News) error
|
||||
GetByID(id uint) (*models.News, error)
|
||||
GetAll(limit, offset int, category string) ([]models.News, int64, error)
|
||||
Update(news *models.News) error
|
||||
Delete(id uint) error
|
||||
IncrementViews(id uint) error
|
||||
GetByAuthor(authorID uint, limit, offset int) ([]models.News, int64, error)
|
||||
}
|
||||
|
||||
type newsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewNewsRepository(db *gorm.DB) NewsRepository {
|
||||
return &newsRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *newsRepository) Create(news *models.News) error {
|
||||
return r.db.Create(news).Error
|
||||
}
|
||||
|
||||
func (r *newsRepository) GetByID(id uint) (*models.News, error) {
|
||||
var news models.News
|
||||
err := r.db.Preload("Author").Preload("Comments.Author").
|
||||
Where("id = ?", id).First(&news).Error
|
||||
return &news, err
|
||||
}
|
||||
|
||||
func (r *newsRepository) GetAll(limit, offset int, category string) ([]models.News, int64, error) {
|
||||
var news []models.News
|
||||
var total int64
|
||||
|
||||
query := r.db.Preload("Author")
|
||||
|
||||
if category != "" && category != "all" {
|
||||
query = query.Where("category = ?", category)
|
||||
}
|
||||
|
||||
// Получаем общее количество
|
||||
if err := query.Model(&models.News{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Получаем данные с пагинацией
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(limit).Offset(offset).
|
||||
Find(&news).Error
|
||||
|
||||
return news, total, err
|
||||
}
|
||||
|
||||
func (r *newsRepository) Update(news *models.News) error {
|
||||
return r.db.Save(news).Error
|
||||
}
|
||||
|
||||
func (r *newsRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.News{}, id).Error
|
||||
}
|
||||
|
||||
func (r *newsRepository) IncrementViews(id uint) error {
|
||||
return r.db.Model(&models.News{}).Where("id = ?", id).
|
||||
Update("views", gorm.Expr("views + ?", 1)).Error
|
||||
}
|
||||
|
||||
func (r *newsRepository) GetByAuthor(authorID uint, limit, offset int) ([]models.News, int64, error) {
|
||||
var news []models.News
|
||||
var total int64
|
||||
|
||||
query := r.db.Preload("Author").Where("author_id = ?", authorID)
|
||||
|
||||
if err := query.Model(&models.News{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
err := query.Order("created_at DESC").
|
||||
Limit(limit).Offset(offset).
|
||||
Find(&news).Error
|
||||
|
||||
return news, total, err
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// repositories/personal_best_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PersonalBestRepository interface {
|
||||
Create(personalBest *models.PersonalBest) error
|
||||
GetByID(id uint) (*models.PersonalBest, error)
|
||||
GetByUserID(userID uint) ([]models.PersonalBest, error)
|
||||
GetByUserAndDistance(userID uint, distanceType models.DistanceType) ([]models.PersonalBest, error)
|
||||
GetBestByDistance(userID uint, distanceType models.DistanceType) (*models.PersonalBest, error)
|
||||
Update(personalBest *models.PersonalBest) error
|
||||
Delete(id uint) error
|
||||
GetVerifiedByUserID(userID uint) ([]models.PersonalBest, error)
|
||||
GetByDateRange(userID uint, startDate, endDate time.Time) ([]models.PersonalBest, error)
|
||||
GetPersonalBestsSummary(userID uint) (*models.PersonalBestsSummary, error)
|
||||
ExistsBetterTime(userID uint, distanceType models.DistanceType, time string) (bool, error)
|
||||
CalculatePace(timeStr string, distanceType models.DistanceType) (string, error)
|
||||
GetRecentPersonalBests(userID uint, limit int) ([]models.PersonalBest, error)
|
||||
GetByEventName(userID uint, eventName string) ([]models.PersonalBest, error)
|
||||
}
|
||||
|
||||
type personalBestRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPersonalBestRepository(db *gorm.DB) PersonalBestRepository {
|
||||
return &personalBestRepository{db: db}
|
||||
}
|
||||
|
||||
// Create создает новый личный рекорд
|
||||
func (r *personalBestRepository) Create(personalBest *models.PersonalBest) error {
|
||||
return r.db.Create(personalBest).Error
|
||||
}
|
||||
|
||||
// GetByID возвращает личный рекорд по ID
|
||||
func (r *personalBestRepository) GetByID(id uint) (*models.PersonalBest, error) {
|
||||
var personalBest models.PersonalBest
|
||||
err := r.db.Preload("User").First(&personalBest, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &personalBest, nil
|
||||
}
|
||||
|
||||
// GetByUserID возвращает все личные рекорды пользователя
|
||||
func (r *personalBestRepository) GetByUserID(userID uint) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ?", userID).
|
||||
Preload("User").
|
||||
Order("distance_type, time").
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetByUserAndDistance возвращает личные рекорды пользователя по дистанции
|
||||
func (r *personalBestRepository) GetByUserAndDistance(userID uint, distanceType models.DistanceType) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ? AND distance_type = ?", userID, distanceType).
|
||||
Preload("User").
|
||||
Order("time").
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetBestByDistance возвращает лучший результат пользователя на дистанции
|
||||
func (r *personalBestRepository) GetBestByDistance(userID uint, distanceType models.DistanceType) (*models.PersonalBest, error) {
|
||||
var personalBest models.PersonalBest
|
||||
err := r.db.Where("user_id = ? AND distance_type = ?", userID, distanceType).
|
||||
Preload("User").
|
||||
Order("time").
|
||||
First(&personalBest).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &personalBest, nil
|
||||
}
|
||||
|
||||
// Update обновляет личный рекорд
|
||||
func (r *personalBestRepository) Update(personalBest *models.PersonalBest) error {
|
||||
return r.db.Save(personalBest).Error
|
||||
}
|
||||
|
||||
// Delete удаляет личный рекорд
|
||||
func (r *personalBestRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.PersonalBest{}, id).Error
|
||||
}
|
||||
|
||||
// GetVerifiedByUserID возвращает подтвержденные личные рекорды пользователя
|
||||
func (r *personalBestRepository) GetVerifiedByUserID(userID uint) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ? AND verified = ?", userID, true).
|
||||
Preload("User").
|
||||
Order("distance_type, time").
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetByDateRange возвращает личные рекорды за период времени
|
||||
func (r *personalBestRepository) GetByDateRange(userID uint, startDate, endDate time.Time) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Preload("User").
|
||||
Order("date DESC, distance_type").
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetRecentPersonalBests возвращает последние личные рекорды
|
||||
func (r *personalBestRepository) GetRecentPersonalBests(userID uint, limit int) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ?", userID).
|
||||
Preload("User").
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetByEventName возвращает личные рекорды по названию события
|
||||
func (r *personalBestRepository) GetByEventName(userID uint, eventName string) ([]models.PersonalBest, error) {
|
||||
var personalBests []models.PersonalBest
|
||||
err := r.db.Where("user_id = ? AND event_name LIKE ?", userID, "%"+eventName+"%").
|
||||
Preload("User").
|
||||
Order("date DESC").
|
||||
Find(&personalBests).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return personalBests, nil
|
||||
}
|
||||
|
||||
// GetPersonalBestsSummary возвращает сводку лучших результатов по дистанциям
|
||||
// GetPersonalBestsSummary возвращает сводку лучших результатов по дистанциям
|
||||
func (r *personalBestRepository) GetPersonalBestsSummary(userID uint) (*models.PersonalBestsSummary, error) {
|
||||
summary := &models.PersonalBestsSummary{}
|
||||
|
||||
// Получаем лучший результат для каждой дистанции
|
||||
distances := []models.DistanceType{
|
||||
models.Distance5K,
|
||||
models.Distance10K,
|
||||
models.DistanceHalf,
|
||||
models.DistanceFull,
|
||||
}
|
||||
|
||||
for _, distance := range distances {
|
||||
best, err := r.GetBestByDistance(userID, distance)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
if best != nil {
|
||||
switch distance {
|
||||
case models.Distance5K:
|
||||
summary.Best5K = best.Time
|
||||
summary.Best5KPace = best.Pace
|
||||
case models.Distance10K:
|
||||
summary.Best10K = best.Time
|
||||
summary.Best10KPace = best.Pace
|
||||
case models.DistanceHalf:
|
||||
summary.BestHalf = best.Time
|
||||
summary.BestHalfPace = best.Pace
|
||||
case models.DistanceFull:
|
||||
summary.BestMarathon = best.Time
|
||||
summary.BestMarathonPace = best.Pace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// ExistsBetterTime проверяет, есть ли у пользователя уже лучший результат на этой дистанции
|
||||
func (r *personalBestRepository) ExistsBetterTime(userID uint, distanceType models.DistanceType, time string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.PersonalBest{}).
|
||||
Where("user_id = ? AND distance_type = ? AND time < ?", userID, distanceType, time).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CalculatePace вычисляет темп на основе времени и дистанции
|
||||
func (r *personalBestRepository) CalculatePace(timeStr string, distanceType models.DistanceType) (string, error) {
|
||||
// Парсим время из строки "HH:MM:SS"
|
||||
t, err := time.Parse("15:04:05", timeStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Преобразуем в секунды
|
||||
totalSeconds := t.Hour()*3600 + t.Minute()*60 + t.Second()
|
||||
|
||||
// Определяем дистанцию в метрах
|
||||
var distanceMeters float64
|
||||
switch distanceType {
|
||||
case models.Distance5K:
|
||||
distanceMeters = 5000
|
||||
case models.Distance10K:
|
||||
distanceMeters = 10000
|
||||
case models.DistanceHalf:
|
||||
distanceMeters = 21097.5 // 21.0975 km
|
||||
case models.DistanceFull:
|
||||
distanceMeters = 42195 // 42.195 km
|
||||
default:
|
||||
return "", nil // Для других дистанций не вычисляем темп
|
||||
}
|
||||
|
||||
// Вычисляем темп в секундах на километр
|
||||
paceSecondsPerKm := float64(totalSeconds) / (distanceMeters / 1000)
|
||||
|
||||
// Форматируем темп в "MM:SS"
|
||||
minutes := int(paceSecondsPerKm) / 60
|
||||
seconds := int(paceSecondsPerKm) % 60
|
||||
|
||||
return utils.FormatPace(minutes, seconds), nil
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// repository/review_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"database/sql"
|
||||
"math"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReviewRepository interface {
|
||||
Create(review *models.Review) error
|
||||
GetByID(id uint) (*models.Review, error)
|
||||
GetAll(page, limit int, sortBy, filter string) ([]models.Review, int64, error)
|
||||
GetByAuthorID(authorID uint) ([]models.Review, error)
|
||||
Update(review *models.Review) error
|
||||
Delete(id uint) error
|
||||
GetStats() (*models.ReviewsStatsResponse, error)
|
||||
GetRatingDistribution() (map[int]int, error)
|
||||
}
|
||||
|
||||
type reviewRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
||||
return &reviewRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *reviewRepository) Create(review *models.Review) error {
|
||||
return r.db.Create(review).Error
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetByID(id uint) (*models.Review, error) {
|
||||
var review models.Review
|
||||
err := r.db.Preload("Author").First(&review, id).Error
|
||||
return &review, err
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetAll(page, limit int, sortBy, filter string) ([]models.Review, int64, error) {
|
||||
var reviews []models.Review
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Review{}).Preload("Author")
|
||||
|
||||
// Применяем фильтрацию по рейтингу
|
||||
if filter != "" && filter != "all" {
|
||||
query = query.Where("rating >= ?", filter)
|
||||
}
|
||||
|
||||
// Считаем общее количество
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Применяем сортировку
|
||||
switch sortBy {
|
||||
case "newest":
|
||||
query = query.Order("created_at DESC")
|
||||
case "oldest":
|
||||
query = query.Order("created_at ASC")
|
||||
case "highest":
|
||||
query = query.Order("rating DESC, created_at DESC")
|
||||
case "lowest":
|
||||
query = query.Order("rating ASC, created_at DESC")
|
||||
default:
|
||||
query = query.Order("created_at DESC")
|
||||
}
|
||||
|
||||
// Применяем пагинацию
|
||||
offset := (page - 1) * limit
|
||||
err := query.Offset(offset).Limit(limit).Find(&reviews).Error
|
||||
|
||||
return reviews, total, err
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetByAuthorID(authorID uint) ([]models.Review, error) {
|
||||
var reviews []models.Review
|
||||
err := r.db.Where("author_id = ?", authorID).Preload("Author").Find(&reviews).Error
|
||||
return reviews, err
|
||||
}
|
||||
|
||||
func (r *reviewRepository) Update(review *models.Review) error {
|
||||
return r.db.Save(review).Error
|
||||
}
|
||||
|
||||
func (r *reviewRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Review{}, id).Error
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetStats() (*models.ReviewsStatsResponse, error) {
|
||||
var totalReviews int64
|
||||
var averageRating float64
|
||||
var successStories int64
|
||||
|
||||
// Общее количество отзывов
|
||||
if err := r.db.Model(&models.Review{}).Count(&totalReviews).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Средний рейтинг - ИСПРАВЛЕННАЯ ЧАСТЬ
|
||||
var nullRating sql.NullFloat64
|
||||
if err := r.db.Model(&models.Review{}).Select("AVG(rating)").Row().Scan(&nullRating); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if nullRating.Valid {
|
||||
averageRating = nullRating.Float64
|
||||
} else {
|
||||
averageRating = 0
|
||||
}
|
||||
|
||||
// Количество успешных историй (отзывы с рейтингом >= 4 и достижениями)
|
||||
if err := r.db.Model(&models.Review{}).
|
||||
Where("rating >= ? AND achievement != ?", 4, "").
|
||||
Count(&successStories).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Распределение по рейтингам
|
||||
ratingDistribution, err := r.GetRatingDistribution()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.ReviewsStatsResponse{
|
||||
TotalReviews: int(totalReviews),
|
||||
AverageRating: math.Round(averageRating*100) / 100, // Округляем до 2 знаков
|
||||
SuccessStories: int(successStories),
|
||||
RatingDistribution: ratingDistribution,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *reviewRepository) GetRatingDistribution() (map[int]int, error) {
|
||||
var results []struct {
|
||||
Rating int
|
||||
Count int
|
||||
}
|
||||
|
||||
err := r.db.Model(&models.Review{}).
|
||||
Select("rating, COUNT(*) as count").
|
||||
Group("rating").
|
||||
Order("rating DESC").
|
||||
Scan(&results).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
distribution := make(map[int]int)
|
||||
for _, result := range results {
|
||||
distribution[result.Rating] = result.Count
|
||||
}
|
||||
|
||||
// Заполняем отсутствующие рейтинги нулями
|
||||
for i := 1; i <= 5; i++ {
|
||||
if _, exists := distribution[i]; !exists {
|
||||
distribution[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
return distribution, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// repositories/training_plan_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrainingPlanRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewTrainingPlanRepository(db *gorm.DB) *TrainingPlanRepository {
|
||||
return &TrainingPlanRepository{db: db}
|
||||
}
|
||||
|
||||
// Create создает новый план тренировок
|
||||
func (r *TrainingPlanRepository) Create(plan *models.TrainingPlan) error {
|
||||
return r.db.Create(plan).Error
|
||||
}
|
||||
|
||||
// GetByID возвращает план тренировок по ID
|
||||
func (r *TrainingPlanRepository) GetByID(id uint) (*models.TrainingPlan, error) {
|
||||
var plan models.TrainingPlan
|
||||
err := r.db.Preload("Workouts").First(&plan, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// GetByUserID возвращает все планы тренировок пользователя
|
||||
func (r *TrainingPlanRepository) GetByUserID(userID uint) ([]models.TrainingPlan, error) {
|
||||
var plans []models.TrainingPlan
|
||||
err := r.db.Preload("Workouts").Where("user_id = ?", userID).Find(&plans).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// Update обновляет план тренировок
|
||||
func (r *TrainingPlanRepository) Update(plan *models.TrainingPlan) error {
|
||||
return r.db.Save(plan).Error
|
||||
}
|
||||
|
||||
// Delete удаляет план тренировок
|
||||
func (r *TrainingPlanRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.TrainingPlan{}, id).Error
|
||||
}
|
||||
|
||||
// UpdateCurrentWeek обновляет текущую неделю плана тренировок
|
||||
func (r *TrainingPlanRepository) UpdateCurrentWeek(id uint, currentWeek int) error {
|
||||
return r.db.Model(&models.TrainingPlan{}).Where("id = ?", id).Update("current_week", currentWeek).Error
|
||||
}
|
||||
|
||||
// MarkAsCompleted помечает план тренировок как завершенный
|
||||
func (r *TrainingPlanRepository) MarkAsCompleted(id uint) error {
|
||||
return r.db.Model(&models.TrainingPlan{}).Where("id = ?", id).Update("completed", true).Error
|
||||
}
|
||||
|
||||
// GetActivePlan возвращает активный (не завершенный) план тренировок пользователя
|
||||
func (r *TrainingPlanRepository) GetActivePlan(userID uint) (*models.TrainingPlan, error) {
|
||||
var plan models.TrainingPlan
|
||||
err := r.db.Preload("Workouts").Where("user_id = ? AND completed = ?", userID, false).First(&plan).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// CreateWorkout создает тренировку в плане
|
||||
func (r *TrainingPlanRepository) CreateWorkout(workout *models.TrainingWorkout) error {
|
||||
return r.db.Create(workout).Error
|
||||
}
|
||||
|
||||
// GetWorkoutByID возвращает тренировку по ID
|
||||
func (r *TrainingPlanRepository) GetWorkoutByID(id uint) (*models.TrainingWorkout, error) {
|
||||
var workout models.TrainingWorkout
|
||||
err := r.db.First(&workout, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &workout, nil
|
||||
}
|
||||
|
||||
// GetWorkoutsByPlanID возвращает все тренировки плана
|
||||
func (r *TrainingPlanRepository) GetWorkoutsByPlanID(planID uint) ([]models.TrainingWorkout, error) {
|
||||
var workouts []models.TrainingWorkout
|
||||
err := r.db.Where("plan_id = ?", planID).Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// UpdateWorkout обновляет тренировку
|
||||
func (r *TrainingPlanRepository) UpdateWorkout(workout *models.TrainingWorkout) error {
|
||||
return r.db.Save(workout).Error
|
||||
}
|
||||
|
||||
// DeleteWorkout удаляет тренировку
|
||||
func (r *TrainingPlanRepository) DeleteWorkout(id uint) error {
|
||||
return r.db.Delete(&models.TrainingWorkout{}, id).Error
|
||||
}
|
||||
|
||||
// MarkWorkoutAsCompleted помечает тренировку как завершенную
|
||||
func (r *TrainingPlanRepository) MarkWorkoutAsCompleted(id uint) error {
|
||||
now := time.Now()
|
||||
return r.db.Model(&models.TrainingWorkout{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"completed": true,
|
||||
"completed_at": now,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetWorkoutsByWeek возвращает тренировки для определенной недели плана
|
||||
func (r *TrainingPlanRepository) GetWorkoutsByWeek(planID uint, week int) ([]models.TrainingWorkout, error) {
|
||||
var workouts []models.TrainingWorkout
|
||||
err := r.db.Where("plan_id = ? AND week = ?", planID, week).Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// GetCompletedWorkouts возвращает завершенные тренировки плана
|
||||
func (r *TrainingPlanRepository) GetCompletedWorkouts(planID uint) ([]models.TrainingWorkout, error) {
|
||||
var workouts []models.TrainingWorkout
|
||||
err := r.db.Where("plan_id = ? AND completed = ?", planID, true).Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(user *models.User) error
|
||||
FindByID(id uint) (*models.User, error)
|
||||
FindByEmail(email string) (*models.User, error)
|
||||
Update(user *models.User) error
|
||||
Delete(id uint) error
|
||||
UpdateExcludeEmail(userUpdate *models.User) error
|
||||
UpdateAvatar(userID uint, avatarPath string) error
|
||||
FindAll() ([]models.User, error)
|
||||
MarkEmailAsVerified(userID uint) error
|
||||
UpdatePassword(userID uint, newPassword string) error
|
||||
GetUserByID(id uint) (*models.User, error)
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateAvatar(userID uint, avatarPath string) error {
|
||||
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("avatar", avatarPath)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
// Add to userRepository implementation
|
||||
func (r *userRepository) FindAll() ([]models.User, error) {
|
||||
var users []models.User
|
||||
err := r.db.Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *userRepository) FindByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
return &user, err
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(user *models.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
// repository/user_repository.go
|
||||
func (r *userRepository) UpdateExcludeEmail(userUpdate *models.User) error {
|
||||
result := r.db.Model(userUpdate).Where("id = ?", userUpdate.ID).Updates(map[string]interface{}{
|
||||
"first_name": userUpdate.FirstName,
|
||||
"last_name": userUpdate.LastName,
|
||||
"avatar": userUpdate.Avatar, // Добавить обновление аватара
|
||||
"phone": userUpdate.Phone,
|
||||
"experience": userUpdate.Experience,
|
||||
"goals": userUpdate.Goals,
|
||||
"newsletter": userUpdate.Newsletter,
|
||||
"updated_at": userUpdate.UpdatedAt,
|
||||
})
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkEmailAsVerified помечает email пользователя как верифицированный
|
||||
func (r userRepository) MarkEmailAsVerified(userID uint) error {
|
||||
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("email_verified", true)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword обновляет пароль пользователя
|
||||
func (r userRepository) UpdatePassword(userID uint, newPassword string) error {
|
||||
result := r.db.Model(&models.User{}).Where("id = ?", userID).Update("password", newPassword)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r userRepository) GetUserByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
return &user, err
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// repositories/user_stats_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/pkg/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserStatsRepository interface {
|
||||
Create(userStats *models.UserStats) error
|
||||
GetByID(id uint) (*models.UserStats, error)
|
||||
GetByUserID(userID uint) (*models.UserStats, error)
|
||||
Update(userStats *models.UserStats) error
|
||||
Delete(id uint) error
|
||||
UpdateStreaks(userID uint, lastWorkout time.Time) error
|
||||
UpdateWeeklyDistance(userID uint, distance float64) error
|
||||
UpdateMonthlyDistance(userID uint, distance float64) error
|
||||
IncrementWorkouts(userID uint, distance float64, duration int) error
|
||||
UpdatePersonalBest(userID uint, distanceType string, time string) error
|
||||
GetUserStatsResponse(userID uint) (*models.UserStatsResponse, error)
|
||||
GetByUserIDOrCreate(userID uint) (*models.UserStats, error)
|
||||
}
|
||||
|
||||
type userStatsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserStatsRepository(db *gorm.DB) UserStatsRepository {
|
||||
return &userStatsRepository{db: db}
|
||||
}
|
||||
|
||||
// GetByUserIDOrCreate возвращает статистику по ID пользователя или создает новую
|
||||
func (r *userStatsRepository) GetByUserIDOrCreate(userID uint) (*models.UserStats, error) {
|
||||
userStats, err := r.GetByUserID(userID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Создаем новую статистику
|
||||
newStats := &models.UserStats{
|
||||
UserID: userID,
|
||||
TotalDistance: 0,
|
||||
TotalTime: 0,
|
||||
AvgPace: "0:00",
|
||||
WorkoutsCount: 0,
|
||||
CurrentStreak: 0,
|
||||
LongestStreak: 0,
|
||||
WeeklyDistance: 0,
|
||||
MonthlyDistance: 0,
|
||||
Best5K: "",
|
||||
Best10K: "",
|
||||
BestHalf: "",
|
||||
BestMarathon: "",
|
||||
LastWorkout: time.Time{},
|
||||
}
|
||||
|
||||
if err := r.Create(newStats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStats, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return userStats, nil
|
||||
}
|
||||
|
||||
// Create создает новую статистику пользователя
|
||||
func (r *userStatsRepository) Create(userStats *models.UserStats) error {
|
||||
return r.db.Create(userStats).Error
|
||||
}
|
||||
|
||||
// GetByID возвращает статистику по ID
|
||||
func (r *userStatsRepository) GetByID(id uint) (*models.UserStats, error) {
|
||||
var userStats models.UserStats
|
||||
err := r.db.First(&userStats, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &userStats, nil
|
||||
}
|
||||
|
||||
// GetByUserID возвращает статистику по ID пользователя
|
||||
func (r *userStatsRepository) GetByUserID(userID uint) (*models.UserStats, error) {
|
||||
var userStats models.UserStats
|
||||
err := r.db.Where("user_id = ?", userID).First(&userStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &userStats, nil
|
||||
}
|
||||
|
||||
// Update обновляет статистику пользователя
|
||||
func (r *userStatsRepository) Update(userStats *models.UserStats) error {
|
||||
return r.db.Save(userStats).Error
|
||||
}
|
||||
|
||||
// Delete удаляет статистику пользователя
|
||||
func (r *userStatsRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.UserStats{}, id).Error
|
||||
}
|
||||
|
||||
// UpdateStreaks обновляет серии тренировок
|
||||
func (r *userStatsRepository) UpdateStreaks(userID uint, lastWorkout time.Time) error {
|
||||
userStats, err := r.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Проверяем, была ли тренировка вчера
|
||||
yesterday := time.Now().AddDate(0, 0, -1)
|
||||
if userStats.LastWorkout.Format("2006-01-02") == yesterday.Format("2006-01-02") {
|
||||
// Продолжаем серию
|
||||
userStats.CurrentStreak++
|
||||
} else if userStats.LastWorkout.Format("2006-01-02") != time.Now().Format("2006-01-02") {
|
||||
// Сбрасываем серию, если не было тренировки сегодня или вчера
|
||||
userStats.CurrentStreak = 1
|
||||
}
|
||||
|
||||
// Обновляем самую длинную серию
|
||||
if userStats.CurrentStreak > userStats.LongestStreak {
|
||||
userStats.LongestStreak = userStats.CurrentStreak
|
||||
}
|
||||
|
||||
userStats.LastWorkout = lastWorkout
|
||||
|
||||
return r.Update(userStats)
|
||||
}
|
||||
|
||||
// UpdateWeeklyDistance обновляет недельный пробег
|
||||
func (r *userStatsRepository) UpdateWeeklyDistance(userID uint, distance float64) error {
|
||||
return r.db.Model(&models.UserStats{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("weekly_distance", gorm.Expr("weekly_distance + ?", distance)).
|
||||
Error
|
||||
}
|
||||
|
||||
// UpdateMonthlyDistance обновляет месячный пробег
|
||||
func (r *userStatsRepository) UpdateMonthlyDistance(userID uint, distance float64) error {
|
||||
return r.db.Model(&models.UserStats{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("monthly_distance", gorm.Expr("monthly_distance + ?", distance)).
|
||||
Error
|
||||
}
|
||||
|
||||
// IncrementWorkouts увеличивает счетчик тренировок и обновляет общие показатели
|
||||
func (r *userStatsRepository) IncrementWorkouts(userID uint, distance float64, duration int) error {
|
||||
userStats, err := r.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем общие показатели
|
||||
userStats.WorkoutsCount++
|
||||
userStats.TotalDistance += distance
|
||||
userStats.TotalTime += duration
|
||||
|
||||
// Пересчитываем средний темп (в минутах на км)
|
||||
if userStats.TotalDistance > 0 {
|
||||
avgPaceMinPerKm := float64(userStats.TotalTime) / userStats.TotalDistance
|
||||
minutes := int(avgPaceMinPerKm)
|
||||
seconds := int((avgPaceMinPerKm - float64(minutes)) * 60)
|
||||
userStats.AvgPace = utils.FormatPace(minutes, seconds)
|
||||
}
|
||||
|
||||
return r.Update(userStats)
|
||||
}
|
||||
|
||||
// UpdatePersonalBest обновляет личный рекорд
|
||||
func (r *userStatsRepository) UpdatePersonalBest(userID uint, distanceType string, time string) error {
|
||||
updateField := ""
|
||||
switch distanceType {
|
||||
case "5k":
|
||||
updateField = "best_5k"
|
||||
case "10k":
|
||||
updateField = "best_10k"
|
||||
case "half":
|
||||
updateField = "best_half"
|
||||
case "marathon":
|
||||
updateField = "best_marathon"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return r.db.Model(&models.UserStats{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update(updateField, time).
|
||||
Error
|
||||
}
|
||||
|
||||
// GetUserStatsResponse возвращает статистику в формате DTO
|
||||
func (r *userStatsRepository) GetUserStatsResponse(userID uint) (*models.UserStatsResponse, error) {
|
||||
userStats, err := r.GetByUserIDOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.UserStatsResponse{
|
||||
TotalDistance: userStats.TotalDistance,
|
||||
TotalTime: userStats.TotalTime,
|
||||
AvgPace: userStats.AvgPace,
|
||||
WorkoutsCount: userStats.WorkoutsCount,
|
||||
CurrentStreak: userStats.CurrentStreak,
|
||||
LongestStreak: userStats.LongestStreak,
|
||||
WeeklyDistance: userStats.WeeklyDistance,
|
||||
MonthlyDistance: userStats.MonthlyDistance,
|
||||
PersonalBests: models.PersonalBestsSummary{
|
||||
Best5K: userStats.Best5K,
|
||||
Best10K: userStats.Best10K,
|
||||
BestHalf: userStats.BestHalf,
|
||||
BestMarathon: userStats.BestMarathon,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// repositories/workout_repository.go
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WorkoutRepository interface {
|
||||
Create(workout *models.Workout) error
|
||||
FindByID(id uint) (*models.Workout, error)
|
||||
FindByUserID(userID uint) ([]models.Workout, error)
|
||||
Update(workout *models.Workout) error
|
||||
Delete(id uint) error
|
||||
GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error)
|
||||
FindByDateRange(userID uint, startDate, endDate time.Time) ([]models.Workout, error)
|
||||
GetMonthlyStats(userID uint, year int) ([]models.MonthlyStat, error)
|
||||
GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error)
|
||||
GetByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error)
|
||||
}
|
||||
|
||||
type workoutRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("record not found")
|
||||
)
|
||||
|
||||
func NewWorkoutRepository(db *gorm.DB) WorkoutRepository {
|
||||
return &workoutRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *workoutRepository) Create(workout *models.Workout) error {
|
||||
return r.db.Create(workout).Error
|
||||
}
|
||||
|
||||
func (r *workoutRepository) FindByID(id uint) (*models.Workout, error) {
|
||||
var workout models.Workout
|
||||
err := r.db.Preload("User").First(&workout, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &workout, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) FindByUserID(userID uint) ([]models.Workout, error) {
|
||||
var workouts []models.Workout
|
||||
err := r.db.Preload("User").Where("user_id = ?", userID).Order("date DESC").Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) Update(workout *models.Workout) error {
|
||||
return r.db.Save(workout).Error
|
||||
}
|
||||
|
||||
func (r *workoutRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Workout{}, id).Error
|
||||
}
|
||||
|
||||
func (r *workoutRepository) GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error) {
|
||||
var stats models.WorkoutStatsResponse
|
||||
|
||||
// Получаем общее количество тренировок
|
||||
var totalWorkouts int64
|
||||
if err := r.db.Model(&models.Workout{}).Where("user_id = ?", userID).Count(&totalWorkouts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalWorkouts = int(totalWorkouts)
|
||||
|
||||
// Получаем общую дистанцию
|
||||
var totalDistance struct{ Total float64 }
|
||||
if err := r.db.Model(&models.Workout{}).Where("user_id = ?", userID).Select("COALESCE(SUM(distance_km), 0) as total").Scan(&totalDistance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalDistance = totalDistance.Total
|
||||
|
||||
// Получаем общее время
|
||||
var totalTime struct{ Total int }
|
||||
if err := r.db.Model(&models.Workout{}).Where("user_id = ?", userID).Select("COALESCE(SUM(duration_min), 0) as total").Scan(&totalTime).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalTime = totalTime.Total
|
||||
|
||||
// Получаем месячную статистику
|
||||
monthlyStats, err := r.GetMonthlyStats(userID, time.Now().Year())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.MonthlyStats = monthlyStats
|
||||
|
||||
// Рассчитываем средний темп (упрощенная версия)
|
||||
if totalDistance.Total > 0 && totalTime.Total > 0 {
|
||||
avgPaceMinPerKm := float64(totalTime.Total) / totalDistance.Total
|
||||
minutes := int(avgPaceMinPerKm)
|
||||
seconds := int((avgPaceMinPerKm - float64(minutes)) * 60)
|
||||
stats.AveragePace = fmt.Sprintf("%d:%02d", minutes, seconds)
|
||||
} else {
|
||||
stats.AveragePace = "0:00"
|
||||
}
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) FindByDateRange(userID uint, startDate, endDate time.Time) ([]models.Workout, error) {
|
||||
var workouts []models.Workout
|
||||
err := r.db.Preload("User").
|
||||
Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate).
|
||||
Order("date DESC").
|
||||
Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) GetMonthlyStats(userID uint, year int) ([]models.MonthlyStat, error) {
|
||||
var monthlyStats []models.MonthlyStat
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
TO_CHAR(date, 'YYYY-MM') as month,
|
||||
COALESCE(SUM(distance_km), 0) as distance,
|
||||
COUNT(*) as workouts
|
||||
FROM workouts
|
||||
WHERE user_id = ? AND EXTRACT(YEAR FROM date) = ?
|
||||
GROUP BY TO_CHAR(date, 'YYYY-MM')
|
||||
ORDER BY month DESC
|
||||
`
|
||||
|
||||
err := r.db.Raw(query, userID, year).Scan(&monthlyStats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return monthlyStats, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error) {
|
||||
var workouts []models.Workout
|
||||
err := r.db.Preload("User").
|
||||
Where("user_id = ?", userID).
|
||||
Order("date DESC").
|
||||
Limit(limit).
|
||||
Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
func (r *workoutRepository) GetByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error) {
|
||||
var workouts []models.Workout
|
||||
err := r.db.Preload("User").
|
||||
Where("user_id = ? AND type = ?", userID, workoutType).
|
||||
Order("date DESC").
|
||||
Find(&workouts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workouts, nil
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// routes/routes.go
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"api_bb/internal/config"
|
||||
"api_bb/internal/handlers"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/middleware"
|
||||
)
|
||||
|
||||
func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Apply common middleware
|
||||
for _, m := range middleware.CommonMiddleware() {
|
||||
r.Use(m)
|
||||
}
|
||||
|
||||
// handler
|
||||
h := handlers.NewHandler(db, config)
|
||||
|
||||
// Serve static files (avatars)
|
||||
r.Handle("/uploads/*", http.StripPrefix("/uploads/",
|
||||
http.FileServer(http.Dir("./uploads"))))
|
||||
|
||||
// Initialize repositories
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
|
||||
// Initialize logger
|
||||
baseLogger := logger.NewWrapper(logger.Get())
|
||||
|
||||
// Initialize services with logger
|
||||
jwtService := service.NewJWTService(config.JWTSecret)
|
||||
|
||||
// Email service initialization with fallback
|
||||
var emailHandler *handlers.EmailHandler
|
||||
if h.EmailHandler() != nil {
|
||||
emailHandler = h.EmailHandler()
|
||||
}
|
||||
|
||||
// Health routes
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/health", h.HealthHandler().HealthCheck)
|
||||
r.Get("/check", h.HealthHandler().Check)
|
||||
})
|
||||
|
||||
// API v1 routes
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
|
||||
// Email verification (public) - только если доступен
|
||||
if emailHandler != nil {
|
||||
r.Get("/verify-email", emailHandler.VerifyEmail)
|
||||
}
|
||||
|
||||
// Public auth routes
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/register", h.AuthHandler().Register)
|
||||
r.Post("/login", h.AuthHandler().Login)
|
||||
r.Post("/logout", h.AuthHandler().Logout)
|
||||
|
||||
// Email routes (only if email handler is available)
|
||||
if emailHandler != nil {
|
||||
r.Post("/verify-email/resend", emailHandler.ResendVerification)
|
||||
r.Post("/password-reset/request", emailHandler.RequestPasswordReset)
|
||||
r.Post("/password-reset/confirm", emailHandler.ConfirmPasswordReset)
|
||||
}
|
||||
})
|
||||
|
||||
// Публичные маршруты для достижений (если нужны)
|
||||
r.Route("/achievements", func(r chi.Router) {
|
||||
// Публичные маршруты для просмотра достижений других пользователей
|
||||
r.Get("/user/{userID}", h.UserAchievementHandler().GetPublicUserAchievements)
|
||||
r.Get("/user/{userID}/summary", h.UserAchievementHandler().GetPublicUserAchievementsSummary)
|
||||
r.Get("/user/{userID}/recent", h.UserAchievementHandler().GetPublicRecentAchievements)
|
||||
})
|
||||
|
||||
// Protected routes
|
||||
r.Route("/user", func(r chi.Router) {
|
||||
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
// user profile routes
|
||||
r.Get("/profile", h.UserHandler().GetProfile)
|
||||
r.Post("/editProfile", h.UserHandler().UpdateProfile)
|
||||
r.Get("/", h.UserHandler().GetUsers)
|
||||
|
||||
// Все операции с аватарами теперь через AvatarHandler
|
||||
r.Route("/avatars", func(r chi.Router) {
|
||||
r.Post("/upload", h.AvatarHandler().UploadAvatar)
|
||||
r.Delete("/delete", h.AvatarHandler().DeleteAvatar)
|
||||
r.Get("/{filename}", h.AvatarHandler().GetAvatar)
|
||||
})
|
||||
|
||||
r.Route("/stats", func(r chi.Router) {
|
||||
r.Get("/", h.UserStatsHandler().GetUserStats)
|
||||
r.Get("/{userID}", h.UserStatsHandler().GetUserStatsByID)
|
||||
r.Post("/workout", h.UserStatsHandler().IncrementWorkout)
|
||||
r.Put("/personal-best", h.UserStatsHandler().UpdatePersonalBest)
|
||||
r.Post("/weekly/reset", h.UserStatsHandler().ResetWeeklyDistance)
|
||||
r.Post("/monthly/reset", h.UserStatsHandler().ResetMonthlyDistance)
|
||||
})
|
||||
|
||||
// Маршруты для тренировок
|
||||
r.Route("/workouts", func(r chi.Router) {
|
||||
r.Post("/", h.UserWorkoutHandler().CreateWorkout)
|
||||
r.Get("/", h.UserWorkoutHandler().GetWorkouts)
|
||||
r.Get("/stats", h.UserWorkoutHandler().GetWorkoutStats)
|
||||
r.Get("/type/{type}", h.UserWorkoutHandler().GetWorkoutsByType)
|
||||
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.UserWorkoutHandler().GetWorkoutByID)
|
||||
r.Put("/", h.UserWorkoutHandler().UpdateWorkout)
|
||||
r.Delete("/", h.UserWorkoutHandler().DeleteWorkout)
|
||||
})
|
||||
})
|
||||
|
||||
// Маршруты для достижений (achievements)
|
||||
r.Route("/achievements", func(r chi.Router) {
|
||||
// Создание нового достижения
|
||||
r.Post("/", h.UserAchievementHandler().CreateAchievement)
|
||||
|
||||
// Получение всех достижений пользователя
|
||||
r.Get("/", h.UserAchievementHandler().GetUserAchievements)
|
||||
|
||||
// Получение сводки по достижениям
|
||||
r.Get("/summary", h.UserAchievementHandler().GetUserAchievementsSummary)
|
||||
|
||||
// Получение последних достижений (с опциональным лимитом)
|
||||
r.Get("/recent", h.UserAchievementHandler().GetRecentAchievements)
|
||||
|
||||
// Получение достижений по типу
|
||||
r.Get("/type/{type}", h.UserAchievementHandler().GetAchievementsByType)
|
||||
|
||||
// Операции с конкретным достижением
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
// Получение достижения по ID
|
||||
r.Get("/", h.UserAchievementHandler().GetAchievementByID)
|
||||
|
||||
// Обновление достижения
|
||||
r.Put("/", h.UserAchievementHandler().UpdateAchievement)
|
||||
|
||||
// Удаление достижения
|
||||
r.Delete("/", h.UserAchievementHandler().DeleteAchievement)
|
||||
|
||||
// Подтверждение достижения
|
||||
r.Patch("/verify", h.UserAchievementHandler().VerifyAchievement)
|
||||
})
|
||||
})
|
||||
// Personal Best routes
|
||||
r.Route("/personal-bests", func(r chi.Router) {
|
||||
// CRUD operations
|
||||
r.Post("/", h.PersonalBestHandler().CreatePersonalBest)
|
||||
r.Get("/", h.PersonalBestHandler().GetUserPersonalBests)
|
||||
r.Get("/recent", h.PersonalBestHandler().GetRecentPersonalBests)
|
||||
r.Get("/summary", h.PersonalBestHandler().GetPersonalBestsSummary)
|
||||
r.Post("/calculate-pace", h.PersonalBestHandler().CalculatePace)
|
||||
|
||||
// Distance-specific routes
|
||||
r.Route("/distance/{distanceType}", func(r chi.Router) {
|
||||
r.Get("/", h.PersonalBestHandler().GetPersonalBestsByDistance)
|
||||
r.Get("/best", h.PersonalBestHandler().GetBestByDistance)
|
||||
})
|
||||
|
||||
// Individual personal best routes
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Get("/", h.PersonalBestHandler().GetPersonalBest)
|
||||
r.Put("/", h.PersonalBestHandler().UpdatePersonalBest)
|
||||
r.Delete("/", h.PersonalBestHandler().DeletePersonalBest)
|
||||
r.Patch("/verify", h.PersonalBestHandler().VerifyPersonalBest)
|
||||
})
|
||||
})
|
||||
|
||||
// Маршруты для тренировочных планов (Training Plans)
|
||||
r.Route("/training-plans", func(r chi.Router) {
|
||||
// Создание нового тренировочного плана
|
||||
r.Post("/", h.TrainingPlanHandler().CreateTrainingPlan)
|
||||
|
||||
// Получение всех тренировочных планов пользователя
|
||||
r.Get("/", h.TrainingPlanHandler().GetTrainingPlans)
|
||||
|
||||
// Получение активного тренировочного плана
|
||||
r.Get("/active", h.TrainingPlanHandler().GetActiveTrainingPlan)
|
||||
|
||||
// Обновление текущей недели плана
|
||||
r.Patch("/current-week", h.TrainingPlanHandler().UpdateCurrentWeek)
|
||||
|
||||
// Операции с конкретным тренировочным планом
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
// Получение тренировочного плана по ID
|
||||
r.Get("/", h.TrainingPlanHandler().GetTrainingPlanByID)
|
||||
|
||||
// Обновление тренировочного плана
|
||||
r.Put("/", h.TrainingPlanHandler().UpdateTrainingPlan)
|
||||
|
||||
// Удаление тренировочного плана
|
||||
r.Delete("/", h.TrainingPlanHandler().DeleteTrainingPlan)
|
||||
|
||||
// Пометить план как завершенный
|
||||
r.Patch("/complete", h.TrainingPlanHandler().MarkTrainingPlanAsCompleted)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
r.Route("/news", func(r chi.Router) {
|
||||
|
||||
// Публичные маршруты
|
||||
r.Get("/", h.NewsHandler().GetNews)
|
||||
r.Get("/{id}", h.NewsHandler().GetNewsByID)
|
||||
r.Get("/{id}/comments", h.NewsHandler().GetComments)
|
||||
r.Get("/check", h.HealthHandler().Check)
|
||||
|
||||
// Защищенные маршруты
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
// News EndPoints
|
||||
r.Post("/", h.NewsHandler().CreateNews)
|
||||
r.Put("/{id}", h.NewsHandler().UpdateNews)
|
||||
r.Delete("/{id}", h.NewsHandler().DeleteNews)
|
||||
r.Get("/my/news", h.NewsHandler().GetUserNews)
|
||||
|
||||
r.Post("/{id}/comments", h.NewsHandler().CreateComment)
|
||||
r.Delete("/comments/{commentId}", h.NewsHandler().DeleteComment)
|
||||
|
||||
r.Get("/check", h.HealthHandler().Check)
|
||||
})
|
||||
})
|
||||
|
||||
// Маршруты для отзывов
|
||||
r.Route("/reviews", func(r chi.Router) {
|
||||
// Публичные маршруты
|
||||
r.Get("/", h.ReviewHandler().GetReviews)
|
||||
r.Get("/stats", h.ReviewHandler().GetReviewsStats)
|
||||
r.Get("/{id}", h.ReviewHandler().GetReviewByID)
|
||||
|
||||
// Защищенные маршруты
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
r.Post("/", h.ReviewHandler().CreateReview)
|
||||
r.Get("/my", h.ReviewHandler().GetMyReviews)
|
||||
r.Put("/{id}", h.ReviewHandler().UpdateReview)
|
||||
r.Delete("/{id}", h.ReviewHandler().DeleteReview)
|
||||
})
|
||||
})
|
||||
|
||||
// Events
|
||||
r.Route("/events", func(r chi.Router) {
|
||||
|
||||
// Публичные маршруты
|
||||
r.Get("/", h.EventHandler().GetAllEvents)
|
||||
r.Get("/upcoming", h.EventHandler().GetUpcomingEvents)
|
||||
r.Get("/type/{type}", h.EventHandler().GetEventsByType)
|
||||
r.Get("/{id}", h.EventHandler().GetEvent)
|
||||
r.Get("/{eventId}/availability", h.EventRegistrationHandler().CheckEventAvailability)
|
||||
|
||||
// Защищенные маршруты (требуют аутентификации)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
// Регистрации пользователя
|
||||
r.Post("/register", h.EventRegistrationHandler().RegisterForEvent)
|
||||
r.Get("/my/registrations", h.EventRegistrationHandler().GetUserRegistrations)
|
||||
r.Delete("/registrations/{id}", h.EventRegistrationHandler().CancelRegistration)
|
||||
r.Get("/registrations/{id}", h.EventRegistrationHandler().GetRegistration)
|
||||
})
|
||||
|
||||
// Админские маршруты
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.AuthMiddleware(jwtService, userRepo))
|
||||
r.Use(middleware.RequireAuth)
|
||||
r.Use(middleware.AdminMiddleware)
|
||||
|
||||
// Управление событиями
|
||||
r.Post("/", h.EventHandler().CreateEvent)
|
||||
r.Put("/{id}", h.EventHandler().UpdateEvent)
|
||||
r.Delete("/{id}", h.EventHandler().DeleteEvent)
|
||||
r.Patch("/{id}/registration-status", h.EventHandler().ToggleRegistrationStatus)
|
||||
|
||||
// Управление регистрациями
|
||||
r.Get("/{eventId}/registrations", h.EventRegistrationHandler().GetEventRegistrations)
|
||||
r.Patch("/registrations/{id}/status", h.EventRegistrationHandler().UpdateRegistrationStatus)
|
||||
r.Patch("/registrations/{id}/result-time", h.EventRegistrationHandler().UpdateResultTime)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
// Логируем все зарегистрированные маршруты
|
||||
routeLogger := logger.NewRouteLogger(baseLogger)
|
||||
routeLogger.LogRoutes(r)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// scripts/migrate_existing_users.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func MigrateExistingUsers(db *gorm.DB) error {
|
||||
log := logger.NewWrapper(logger.Get().With(zap.String("script", "migrate_existing_users")))
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userStatsRepo := repository.NewUserStatsRepository(db)
|
||||
|
||||
// Получаем всех пользователей
|
||||
users, err := userRepo.FindAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("starting migration for existing users",
|
||||
zap.Int("total_users", len(users)))
|
||||
|
||||
successCount := 0
|
||||
for _, user := range users {
|
||||
// Проверяем, есть ли уже статистика
|
||||
_, err := userStatsRepo.GetByUserID(user.ID)
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Создаем статистику
|
||||
userStats := &models.UserStats{
|
||||
UserID: user.ID,
|
||||
TotalDistance: 0,
|
||||
TotalTime: 0,
|
||||
AvgPace: "0:00",
|
||||
WorkoutsCount: 0,
|
||||
CurrentStreak: 0,
|
||||
LongestStreak: 0,
|
||||
WeeklyDistance: 0,
|
||||
MonthlyDistance: 0,
|
||||
Best5K: "",
|
||||
Best10K: "",
|
||||
BestHalf: "",
|
||||
BestMarathon: "",
|
||||
LastWorkout: user.CreatedAt, // Используем дату создания как последнюю тренировку
|
||||
}
|
||||
|
||||
if err := userStatsRepo.Create(userStats); err != nil {
|
||||
log.Error("failed to create stats for user",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
log.Info("created stats for user",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email))
|
||||
} else if err != nil {
|
||||
log.Error("error checking stats for user",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("migration completed",
|
||||
zap.Int("successful_creations", successCount),
|
||||
zap.Int("total_users", len(users)))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// service/achievement_service.go (дополнение)
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type AchievementService struct {
|
||||
achievementRepo repository.AchievementRepository
|
||||
}
|
||||
|
||||
func NewAchievementService(achievementRepo repository.AchievementRepository) *AchievementService {
|
||||
return &AchievementService{
|
||||
achievementRepo: achievementRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAchievement создает новое достижение
|
||||
func (s *AchievementService) CreateAchievement(userID uint, req models.AchievementCreateRequest) (*models.Achievement, error) {
|
||||
// Проверяем, нет ли уже достижения с таким названием у пользователя
|
||||
exists, err := s.achievementRepo.ExistsByTitleAndUser(userID, req.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrAchievementAlreadyExists
|
||||
}
|
||||
|
||||
achievement := &models.Achievement{
|
||||
UserID: userID,
|
||||
Type: req.Type,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Result: req.Result,
|
||||
Distance: req.Distance,
|
||||
Date: req.Date,
|
||||
BadgeImage: req.BadgeImage,
|
||||
Verified: false, // По умолчанию не подтверждено
|
||||
}
|
||||
|
||||
if err := s.achievementRepo.Create(achievement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return achievement, nil
|
||||
}
|
||||
|
||||
// GetVerifiedAchievements возвращает только подтвержденные достижения пользователя
|
||||
func (s *AchievementService) GetVerifiedAchievements(userID uint) ([]models.Achievement, error) {
|
||||
return s.achievementRepo.GetVerifiedByUserID(userID)
|
||||
}
|
||||
|
||||
// GetVerifiedRecentAchievements возвращает последние подтвержденные достижения
|
||||
func (s *AchievementService) GetVerifiedRecentAchievements(userID uint, limit int) ([]models.Achievement, error) {
|
||||
achievements, err := s.achievementRepo.GetRecentAchievements(userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Фильтруем только подтвержденные
|
||||
var verified []models.Achievement
|
||||
for _, achievement := range achievements {
|
||||
if achievement.Verified {
|
||||
verified = append(verified, achievement)
|
||||
}
|
||||
}
|
||||
|
||||
return verified, nil
|
||||
}
|
||||
|
||||
// GetUserAchievements возвращает все достижения пользователя
|
||||
func (s *AchievementService) GetUserAchievements(userID uint) ([]models.Achievement, error) {
|
||||
return s.achievementRepo.GetByUserID(userID)
|
||||
}
|
||||
|
||||
// GetUserAchievementsSummary возвращает сводку по достижениям пользователя
|
||||
func (s *AchievementService) GetUserAchievementsSummary(userID uint) (*models.UserAchievementsResponse, error) {
|
||||
return s.achievementRepo.GetUserAchievementsSummary(userID)
|
||||
}
|
||||
|
||||
// VerifyAchievement подтверждает достижение
|
||||
func (s *AchievementService) VerifyAchievement(achievementID uint, userID uint) error {
|
||||
// Проверяем, что достижение принадлежит пользователю
|
||||
achievement, err := s.achievementRepo.GetByID(achievementID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if achievement.UserID != userID {
|
||||
return ErrAchievementNotFound
|
||||
}
|
||||
|
||||
return s.achievementRepo.VerifyAchievement(achievementID)
|
||||
}
|
||||
|
||||
// GetRecentAchievements возвращает последние достижения
|
||||
func (s *AchievementService) GetRecentAchievements(userID uint, limit int) ([]models.Achievement, error) {
|
||||
return s.achievementRepo.GetRecentAchievements(userID, limit)
|
||||
}
|
||||
|
||||
// GetAchievementsByType возвращает достижения по типу
|
||||
func (s *AchievementService) GetAchievementsByType(userID uint, achievementType models.AchievementType) ([]models.Achievement, error) {
|
||||
return s.achievementRepo.GetByUserAndType(userID, achievementType)
|
||||
}
|
||||
|
||||
// DeleteAchievement удаляет достижение
|
||||
func (s *AchievementService) DeleteAchievement(achievementID uint, userID uint) error {
|
||||
// Проверяем, что достижение принадлежит пользователю
|
||||
achievement, err := s.achievementRepo.GetByID(achievementID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if achievement.UserID != userID {
|
||||
return ErrAchievementNotFound
|
||||
}
|
||||
|
||||
return s.achievementRepo.Delete(achievementID)
|
||||
}
|
||||
|
||||
// GetAchievementByID возвращает достижение по ID
|
||||
func (s *AchievementService) GetAchievementByID(achievementID uint, userID uint) (*models.Achievement, error) {
|
||||
achievement, err := s.achievementRepo.GetByID(achievementID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что достижение принадлежит пользователю
|
||||
if achievement.UserID != userID {
|
||||
return nil, ErrAchievementNotFound
|
||||
}
|
||||
|
||||
return achievement, nil
|
||||
}
|
||||
|
||||
// UpdateAchievement обновляет достижение
|
||||
func (s *AchievementService) UpdateAchievement(achievementID uint, userID uint, req models.AchievementCreateRequest) (*models.Achievement, error) {
|
||||
// Проверяем, что достижение принадлежит пользователю
|
||||
existingAchievement, err := s.achievementRepo.GetByID(achievementID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if existingAchievement.UserID != userID {
|
||||
return nil, ErrAchievementNotFound
|
||||
}
|
||||
|
||||
// Проверяем, нет ли другого достижения с таким названием
|
||||
if existingAchievement.Title != req.Title {
|
||||
exists, err := s.achievementRepo.ExistsByTitleAndUser(userID, req.Title)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, ErrAchievementAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем данные
|
||||
existingAchievement.Type = req.Type
|
||||
existingAchievement.Title = req.Title
|
||||
existingAchievement.Description = req.Description
|
||||
existingAchievement.Result = req.Result
|
||||
existingAchievement.Distance = req.Distance
|
||||
existingAchievement.Date = req.Date
|
||||
existingAchievement.BadgeImage = req.BadgeImage
|
||||
|
||||
if err := s.achievementRepo.Update(existingAchievement); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existingAchievement, nil
|
||||
}
|
||||
|
||||
// Ошибки
|
||||
var (
|
||||
ErrAchievementAlreadyExists = errors.New("achievement with this title already exists")
|
||||
ErrAchievementNotFound = errors.New("achievement not found")
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
// service/auth_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
Register(user *models.User) error
|
||||
Login(email, password string) (*models.User, string, error)
|
||||
}
|
||||
|
||||
type authService struct {
|
||||
userRepo repository.UserRepository
|
||||
jwtService JWTService
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewAuthService(userRepo repository.UserRepository, jwtService JWTService, log logger.LoggerInterface) AuthService {
|
||||
// Создаем логгер с контекстом для сервиса
|
||||
serviceLogger := log.With(zap.String("service", "auth"))
|
||||
|
||||
return &authService{
|
||||
userRepo: userRepo,
|
||||
jwtService: jwtService,
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *authService) Register(user *models.User) error {
|
||||
s.logger.Info("Registering new user",
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
|
||||
existingUser, err := s.userRepo.FindByEmail(user.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
s.logger.Warn("Registration failed - email already exists",
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
return errors.New("user with this email already exists")
|
||||
}
|
||||
|
||||
err = s.userRepo.Create(user)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create user in database",
|
||||
zap.String("email", user.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
s.logger.Info("User registered successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *authService) Login(email, password string) (*models.User, string, error) {
|
||||
s.logger.Info("Login attempt",
|
||||
zap.String("email", email),
|
||||
zap.Int("password_length", len(password)),
|
||||
)
|
||||
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil {
|
||||
s.logger.Warn("Login failed - user not found",
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, "", errors.New("invalid email")
|
||||
}
|
||||
|
||||
s.logger.Debug("User found for login",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("stored_hash_prefix", user.Password[:min(10, len(user.Password))]),
|
||||
)
|
||||
|
||||
// Проверяем пароль
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||
if err != nil {
|
||||
s.logger.Warn("Login failed - invalid password",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, "", errors.New("invalid password")
|
||||
}
|
||||
|
||||
s.logger.Info("Login successful",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", email),
|
||||
)
|
||||
|
||||
token, err := s.jwtService.GenerateToken(user.ID, user.Email)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to generate JWT token",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return user, token, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// service/avatar_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AvatarService interface {
|
||||
UploadAvatar(userID uint, file multipart.File, header *multipart.FileHeader) (string, error)
|
||||
DeleteAvatar(userID uint) error
|
||||
GetAvatarPath(userID uint) (string, error)
|
||||
GetAvatarFile(filename string) ([]byte, string, error)
|
||||
ServeAvatarFile(w io.Writer, filename string) (string, error)
|
||||
}
|
||||
|
||||
type avatarService struct {
|
||||
userRepo repository.UserRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewAvatarService(userRepo repository.UserRepository, log logger.LoggerInterface) AvatarService {
|
||||
return &avatarService{
|
||||
userRepo: userRepo,
|
||||
logger: log.With(zap.String("service", "avatar")),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *avatarService) UploadAvatar(userID uint, file multipart.File, header *multipart.FileHeader) (string, error) {
|
||||
// Проверяем пользователя
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Создаем директорию для аватаров если не существует
|
||||
uploadDir := "./uploads/avatars"
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create upload directory: %v", err)
|
||||
}
|
||||
|
||||
// Генерируем уникальное имя файла
|
||||
fileExt := filepath.Ext(header.Filename)
|
||||
fileName := fmt.Sprintf("avatar_%d_%d%s", userID, time.Now().Unix(), fileExt)
|
||||
filePath := filepath.Join(uploadDir, fileName)
|
||||
|
||||
// Создаем файл
|
||||
dst, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
// Копируем содержимое
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
return "", fmt.Errorf("failed to save file: %v", err)
|
||||
}
|
||||
|
||||
// Удаляем старый аватар если существует
|
||||
if user.Avatar != "" {
|
||||
oldPath := strings.TrimPrefix(user.Avatar, "/")
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
os.Remove(oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем путь в БД
|
||||
avatarPath := "/uploads/avatars/" + fileName
|
||||
if err := s.userRepo.UpdateAvatar(userID, avatarPath); err != nil {
|
||||
// Если не удалось сохранить в БД, удаляем загруженный файл
|
||||
os.Remove(filePath)
|
||||
return "", fmt.Errorf("failed to update avatar in database: %v", err)
|
||||
}
|
||||
|
||||
return avatarPath, nil
|
||||
}
|
||||
|
||||
func (s *avatarService) DeleteAvatar(userID uint) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
if user.Avatar == "" {
|
||||
return nil // Аватара нет, ничего не делаем
|
||||
}
|
||||
|
||||
// Удаляем файл
|
||||
filePath := strings.TrimPrefix(user.Avatar, "/")
|
||||
if _, err := os.Stat(filePath); err == nil {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
s.logger.Warn("Failed to delete avatar file", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем поле в БД
|
||||
return s.userRepo.UpdateAvatar(userID, "")
|
||||
}
|
||||
|
||||
func (s *avatarService) GetAvatarPath(userID uint) (string, error) {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return user.Avatar, nil
|
||||
}
|
||||
|
||||
func (s *avatarService) GetAvatarFile(filename string) ([]byte, string, error) {
|
||||
// Валидация имени файла
|
||||
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") {
|
||||
return nil, "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
// Проверяем допустимые расширения
|
||||
allowedExts := map[string]string{
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
fileExt := strings.ToLower(filepath.Ext(filename))
|
||||
contentType, exists := allowedExts[fileExt]
|
||||
if !exists {
|
||||
return nil, "", fmt.Errorf("unsupported file format")
|
||||
}
|
||||
|
||||
// Формируем путь к файлу
|
||||
filePath := filepath.Join("./uploads/avatars", filename)
|
||||
|
||||
// Проверяем существование файла
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, "", fmt.Errorf("avatar file not found")
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to access file: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем размер файла (максимум 10MB)
|
||||
if fileInfo.Size() > 10*1024*1024 {
|
||||
return nil, "", fmt.Errorf("file too large")
|
||||
}
|
||||
|
||||
// Читаем файл
|
||||
fileData, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to read file: %v", err)
|
||||
}
|
||||
|
||||
return fileData, contentType, nil
|
||||
}
|
||||
|
||||
func (s *avatarService) ServeAvatarFile(w io.Writer, filename string) (string, error) {
|
||||
// Валидация имени файла
|
||||
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") {
|
||||
return "", fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
// Проверяем допустимые расширения
|
||||
allowedExts := map[string]string{
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
fileExt := strings.ToLower(filepath.Ext(filename))
|
||||
contentType, exists := allowedExts[fileExt]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("unsupported file format")
|
||||
}
|
||||
|
||||
// Формируем путь к файлу
|
||||
filePath := filepath.Join("./uploads/avatars", filename)
|
||||
|
||||
// Проверяем существование файла
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("avatar file not found")
|
||||
}
|
||||
return "", fmt.Errorf("failed to access file: %v", err)
|
||||
}
|
||||
|
||||
// Проверяем размер файла
|
||||
if fileInfo.Size() > 10*1024*1024 {
|
||||
return "", fmt.Errorf("file too large")
|
||||
}
|
||||
|
||||
// Открываем и копируем файл
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serve file: %v", err)
|
||||
}
|
||||
|
||||
return contentType, nil
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// service/email_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/email"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
emailRepo repository.EmailRepository
|
||||
userRepo repository.UserRepository
|
||||
emailSender email.Service
|
||||
logger *zap.Logger
|
||||
tokenExpiry time.Duration
|
||||
passwordExpiry time.Duration
|
||||
}
|
||||
|
||||
func NewEmailService(
|
||||
emailRepo repository.EmailRepository,
|
||||
userRepo repository.UserRepository,
|
||||
emailSender email.Service,
|
||||
) EmailService {
|
||||
// Создаем логгер с контекстом для сервиса
|
||||
serviceLogger := logger.Get().With(zap.String("service", "email"))
|
||||
|
||||
return EmailService{
|
||||
emailRepo: emailRepo,
|
||||
userRepo: userRepo,
|
||||
emailSender: emailSender,
|
||||
logger: serviceLogger,
|
||||
tokenExpiry: 24 * time.Hour, // 24 часа для верификации
|
||||
passwordExpiry: 1 * time.Hour, // 1 час для сброса пароля
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EmailService) SendVerificationEmail(userID uint, email, userName string) error {
|
||||
s.logger.Info("Sending verification email",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
)
|
||||
|
||||
token := uuid.New().String()
|
||||
|
||||
verification := &models.EmailVerification{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
Email: email,
|
||||
Type: "verification",
|
||||
ExpiresAt: time.Now().Add(s.tokenExpiry),
|
||||
}
|
||||
|
||||
if err := s.emailRepo.CreateVerificationToken(verification); err != nil {
|
||||
s.logger.Error("Failed to create verification token",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to create verification token: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emailSender.SendVerificationEmail(email, userName, token); err != nil {
|
||||
s.logger.Error("Failed to send verification email",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Verification email sent successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", email))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailService) VerifyEmail(token string) error {
|
||||
s.logger.Info("Verifying email token",
|
||||
zap.String("token", token),
|
||||
)
|
||||
|
||||
verification, err := s.emailRepo.GetVerificationToken(token)
|
||||
if err != nil {
|
||||
s.logger.Error("Invalid or expired verification token",
|
||||
zap.String("token", token),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("invalid or expired token: %w", err)
|
||||
}
|
||||
|
||||
if verification.Type != "verification" {
|
||||
s.logger.Error("Invalid token type for email verification",
|
||||
zap.String("token", token),
|
||||
zap.String("type", verification.Type),
|
||||
)
|
||||
return fmt.Errorf("invalid token type")
|
||||
}
|
||||
|
||||
// Обновляем пользователя
|
||||
if err := s.userRepo.MarkEmailAsVerified(verification.UserID); err != nil {
|
||||
s.logger.Error("Failed to verify email in user repository",
|
||||
zap.Uint("user_id", verification.UserID),
|
||||
zap.String("email", verification.Email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to verify email: %w", err)
|
||||
}
|
||||
|
||||
// Помечаем токен как использованный
|
||||
if err := s.emailRepo.MarkTokenAsUsed(token); err != nil {
|
||||
s.logger.Error("Failed to mark token as used",
|
||||
zap.Error(err),
|
||||
zap.String("token", token))
|
||||
}
|
||||
|
||||
s.logger.Info("Email verified successfully",
|
||||
zap.Uint("user_id", verification.UserID),
|
||||
zap.String("email", verification.Email))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailService) SendPasswordResetEmail(email string) error {
|
||||
s.logger.Info("Sending password reset email",
|
||||
zap.String("email", email),
|
||||
)
|
||||
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil {
|
||||
// Для безопасности не сообщаем, существует ли email
|
||||
s.logger.Info("Password reset requested for non-existent email",
|
||||
zap.String("email", email))
|
||||
return nil
|
||||
}
|
||||
|
||||
token := uuid.New().String()
|
||||
|
||||
resetRequest := &models.EmailVerification{
|
||||
UserID: user.ID,
|
||||
Token: token,
|
||||
Email: email,
|
||||
Type: "password_reset",
|
||||
ExpiresAt: time.Now().Add(s.passwordExpiry),
|
||||
}
|
||||
|
||||
if err := s.emailRepo.CreateVerificationToken(resetRequest); err != nil {
|
||||
s.logger.Error("Failed to create password reset token",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to create password reset token: %w", err)
|
||||
}
|
||||
|
||||
if err := s.emailSender.SendPasswordResetEmail(email, user.FirstName, token); err != nil {
|
||||
s.logger.Error("Failed to send password reset email",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", email),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to send password reset email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Password reset email sent successfully",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", email))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailService) ResetPassword(token, newPassword string) error {
|
||||
s.logger.Info("Resetting password with token",
|
||||
zap.String("token", token),
|
||||
)
|
||||
|
||||
verification, err := s.emailRepo.GetVerificationToken(token)
|
||||
if err != nil {
|
||||
s.logger.Error("Invalid or expired password reset token",
|
||||
zap.String("token", token),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("invalid or expired token: %w", err)
|
||||
}
|
||||
|
||||
if verification.Type != "password_reset" {
|
||||
s.logger.Error("Invalid token type for password reset",
|
||||
zap.String("token", token),
|
||||
zap.String("type", verification.Type),
|
||||
)
|
||||
return fmt.Errorf("invalid token type")
|
||||
}
|
||||
|
||||
// Обновляем пароль пользователя
|
||||
if err := s.userRepo.UpdatePassword(verification.UserID, newPassword); err != nil {
|
||||
s.logger.Error("Failed to update password",
|
||||
zap.Uint("user_id", verification.UserID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
// Помечаем токен как использованный
|
||||
if err := s.emailRepo.MarkTokenAsUsed(token); err != nil {
|
||||
s.logger.Error("Failed to mark token as used",
|
||||
zap.Error(err),
|
||||
zap.String("token", token))
|
||||
}
|
||||
|
||||
s.logger.Info("Password reset successfully",
|
||||
zap.Uint("user_id", verification.UserID))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailService) SendNewsletterToSubscribers(subject, content string) error {
|
||||
s.logger.Info("Sending newsletter to subscribers",
|
||||
zap.String("subject", subject),
|
||||
)
|
||||
|
||||
subscribers, err := s.emailRepo.GetUsersWithNewsletter()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get subscribers",
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to get subscribers: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Found subscribers for newsletter",
|
||||
zap.Int("count", len(subscribers)),
|
||||
)
|
||||
|
||||
var errors []error
|
||||
for _, user := range subscribers {
|
||||
if err := s.emailSender.SendNewsletterEmail(user.Email, user.FirstName, subject, content); err != nil {
|
||||
s.logger.Error("Failed to send newsletter to user",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email),
|
||||
zap.Error(err))
|
||||
errors = append(errors, err)
|
||||
continue
|
||||
}
|
||||
s.logger.Debug("Newsletter sent to user",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("email", user.Email))
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
s.logger.Error("Failed to send newsletter to some users",
|
||||
zap.Int("failed_count", len(errors)),
|
||||
zap.Int("total_subscribers", len(subscribers)),
|
||||
)
|
||||
return fmt.Errorf("failed to send newsletter to %d users", len(errors))
|
||||
}
|
||||
|
||||
s.logger.Info("Newsletter sent to all subscribers",
|
||||
zap.Int("total_subscribers", len(subscribers)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailService) CleanupExpiredTokens() error {
|
||||
s.logger.Info("Cleaning up expired tokens")
|
||||
|
||||
if err := s.emailRepo.DeleteExpiredTokens(); err != nil {
|
||||
s.logger.Error("Failed to cleanup expired tokens",
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to cleanup expired tokens: %w", err)
|
||||
}
|
||||
s.logger.Info("Expired tokens cleaned up successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID возвращает пользователя по ID
|
||||
func (s *EmailService) GetUserByID(userID uint) (*models.User, error) {
|
||||
s.logger.Info("Getting user by ID",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
user, err := s.userRepo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get user by ID",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("User retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("email", user.Email),
|
||||
)
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
// service/event_registration_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EventRegistrationService interface {
|
||||
RegisterForEvent(registration *models.EventRegistration) error
|
||||
GetRegistrationByID(id uint) (*models.EventRegistration, error)
|
||||
GetRegistrationsByEventID(eventID uint) ([]models.EventRegistration, error)
|
||||
GetRegistrationsByUserID(userID uint) ([]models.EventRegistration, error)
|
||||
GetRegistrationByEventAndUser(eventID, userID uint) (*models.EventRegistration, error)
|
||||
UpdateRegistration(registration *models.EventRegistration) error
|
||||
CancelRegistration(id uint) error
|
||||
UpdateRegistrationStatus(registrationID uint, status string) error
|
||||
UpdateResultTime(registrationID uint, resultTime string) error
|
||||
CheckEventAvailability(eventID uint) (bool, error)
|
||||
}
|
||||
|
||||
type eventRegistrationService struct {
|
||||
registrationRepo repository.EventRegistrationRepository
|
||||
eventRepo repository.EventRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewEventRegistrationService(
|
||||
registrationRepo repository.EventRegistrationRepository,
|
||||
eventRepo repository.EventRepository,
|
||||
log logger.LoggerInterface,
|
||||
) EventRegistrationService {
|
||||
serviceLogger := log.With(zap.String("service", "event_registration"))
|
||||
|
||||
return &eventRegistrationService{
|
||||
registrationRepo: registrationRepo,
|
||||
eventRepo: eventRepo,
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterForEvent регистрирует пользователя на событие
|
||||
func (s *eventRegistrationService) RegisterForEvent(registration *models.EventRegistration) error {
|
||||
s.logger.Info("Registering user for event",
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
)
|
||||
|
||||
// Проверяем существование события
|
||||
event, err := s.eventRepo.FindByID(registration.EventID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Event not found for registration",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
// Проверяем, открыта ли регистрация
|
||||
if !event.RegistrationOpen {
|
||||
s.logger.Warn("Registration is closed for event",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.String("event_title", event.Title),
|
||||
)
|
||||
return fmt.Errorf("registration is closed for this event")
|
||||
}
|
||||
|
||||
// Проверяем, не зарегистрирован ли пользователь уже
|
||||
existingRegistration, err := s.registrationRepo.FindByEventAndUser(registration.EventID, registration.UserID)
|
||||
if err == nil && existingRegistration != nil {
|
||||
s.logger.Warn("User already registered for event",
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
)
|
||||
return fmt.Errorf("user already registered for this event")
|
||||
}
|
||||
|
||||
// Проверяем доступность мест
|
||||
available, err := s.CheckEventAvailability(registration.EventID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to check event availability",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to check event availability: %w", err)
|
||||
}
|
||||
|
||||
if !available {
|
||||
s.logger.Warn("Event is full",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.String("event_title", event.Title),
|
||||
)
|
||||
return fmt.Errorf("event is full")
|
||||
}
|
||||
|
||||
// Создаем регистрацию
|
||||
if err := s.registrationRepo.Create(registration); err != nil {
|
||||
s.logger.Error("Failed to create registration",
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to register for event: %w", err)
|
||||
}
|
||||
|
||||
// Обновляем счетчик участников
|
||||
if err := s.eventRepo.UpdateParticipantsCount(registration.EventID, event.ParticipantsCount+1); err != nil {
|
||||
s.logger.Error("Failed to update participants count",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
// Не прерываем выполнение, только логируем ошибку
|
||||
}
|
||||
|
||||
s.logger.Info("User registered for event successfully",
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.String("status", registration.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRegistrationByID возвращает регистрацию по ID
|
||||
func (s *eventRegistrationService) GetRegistrationByID(id uint) (*models.EventRegistration, error) {
|
||||
s.logger.Debug("Getting registration by ID", zap.Uint("registration_id", id))
|
||||
|
||||
registration, err := s.registrationRepo.FindByID(id)
|
||||
if err != nil {
|
||||
s.logger.Warn("Registration not found",
|
||||
zap.Uint("registration_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("registration not found: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Registration retrieved successfully",
|
||||
zap.Uint("registration_id", id),
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
)
|
||||
return registration, nil
|
||||
}
|
||||
|
||||
// GetRegistrationsByEventID возвращает все регистрации на событие
|
||||
func (s *eventRegistrationService) GetRegistrationsByEventID(eventID uint) ([]models.EventRegistration, error) {
|
||||
s.logger.Debug("Getting registrations by event ID", zap.Uint("event_id", eventID))
|
||||
|
||||
registrations, err := s.registrationRepo.FindByEventID(eventID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get registrations by event ID",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get registrations: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Registrations by event retrieved successfully",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Int("count", len(registrations)),
|
||||
)
|
||||
return registrations, nil
|
||||
}
|
||||
|
||||
// GetRegistrationsByUserID возвращает все регистрации пользователя
|
||||
func (s *eventRegistrationService) GetRegistrationsByUserID(userID uint) ([]models.EventRegistration, error) {
|
||||
s.logger.Debug("Getting registrations by user ID", zap.Uint("user_id", userID))
|
||||
|
||||
registrations, err := s.registrationRepo.FindByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get registrations by user ID",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get user registrations: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("User registrations retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("count", len(registrations)),
|
||||
)
|
||||
return registrations, nil
|
||||
}
|
||||
|
||||
// GetRegistrationByEventAndUser возвращает регистрацию по событию и пользователю
|
||||
func (s *eventRegistrationService) GetRegistrationByEventAndUser(eventID, userID uint) (*models.EventRegistration, error) {
|
||||
s.logger.Debug("Getting registration by event and user",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
registration, err := s.registrationRepo.FindByEventAndUser(eventID, userID)
|
||||
if err != nil {
|
||||
s.logger.Debug("Registration not found for event and user",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("registration not found: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Registration by event and user retrieved successfully",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
return registration, nil
|
||||
}
|
||||
|
||||
// UpdateRegistration обновляет регистрацию
|
||||
func (s *eventRegistrationService) UpdateRegistration(registration *models.EventRegistration) error {
|
||||
s.logger.Info("Updating registration",
|
||||
zap.Uint("registration_id", registration.ID),
|
||||
zap.Uint("user_id", registration.UserID),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
)
|
||||
|
||||
// Проверяем существование регистрации
|
||||
existingRegistration, err := s.registrationRepo.FindByID(registration.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Registration not found for update",
|
||||
zap.Uint("registration_id", registration.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("registration not found")
|
||||
}
|
||||
|
||||
// Сохраняем неизменяемые поля
|
||||
registration.CreatedAt = existingRegistration.CreatedAt
|
||||
|
||||
if err := s.registrationRepo.Update(registration); err != nil {
|
||||
s.logger.Error("Failed to update registration",
|
||||
zap.Uint("registration_id", registration.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update registration: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Registration updated successfully",
|
||||
zap.Uint("registration_id", registration.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelRegistration отменяет регистрацию
|
||||
func (s *eventRegistrationService) CancelRegistration(id uint) error {
|
||||
s.logger.Info("Canceling registration", zap.Uint("registration_id", id))
|
||||
|
||||
// Получаем регистрацию для получения event_id
|
||||
registration, err := s.registrationRepo.FindByID(id)
|
||||
if err != nil {
|
||||
s.logger.Warn("Registration not found for cancellation",
|
||||
zap.Uint("registration_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("registration not found")
|
||||
}
|
||||
|
||||
if err := s.registrationRepo.Delete(id); err != nil {
|
||||
s.logger.Error("Failed to cancel registration",
|
||||
zap.Uint("registration_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to cancel registration: %w", err)
|
||||
}
|
||||
|
||||
// Обновляем счетчик участников
|
||||
if err := s.eventRepo.UpdateParticipantsCount(registration.EventID, registration.Event.ParticipantsCount-1); err != nil {
|
||||
s.logger.Error("Failed to update participants count after cancellation",
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
// Не прерываем выполнение, только логируем ошибку
|
||||
}
|
||||
|
||||
s.logger.Info("Registration canceled successfully",
|
||||
zap.Uint("registration_id", id),
|
||||
zap.Uint("event_id", registration.EventID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateRegistrationStatus обновляет статус регистрации
|
||||
func (s *eventRegistrationService) UpdateRegistrationStatus(registrationID uint, status string) error {
|
||||
s.logger.Info("Updating registration status",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("status", status),
|
||||
)
|
||||
|
||||
validStatuses := []string{"pending", "confirmed", "cancelled", "completed"}
|
||||
if !contains(validStatuses, status) {
|
||||
s.logger.Warn("Invalid registration status",
|
||||
zap.String("status", status),
|
||||
zap.Strings("valid_statuses", validStatuses),
|
||||
)
|
||||
return fmt.Errorf("invalid status: %s", status)
|
||||
}
|
||||
|
||||
if err := s.registrationRepo.UpdateStatus(registrationID, status); err != nil {
|
||||
s.logger.Error("Failed to update registration status",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("status", status),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update registration status: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Registration status updated successfully",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("status", status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateResultTime обновляет результат забега
|
||||
func (s *eventRegistrationService) UpdateResultTime(registrationID uint, resultTime string) error {
|
||||
s.logger.Info("Updating result time",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("result_time", resultTime),
|
||||
)
|
||||
|
||||
if err := s.registrationRepo.UpdateResultTime(registrationID, resultTime); err != nil {
|
||||
s.logger.Error("Failed to update result time",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("result_time", resultTime),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update result time: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Result time updated successfully",
|
||||
zap.Uint("registration_id", registrationID),
|
||||
zap.String("result_time", resultTime),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckEventAvailability проверяет доступность мест на событии
|
||||
func (s *eventRegistrationService) CheckEventAvailability(eventID uint) (bool, error) {
|
||||
s.logger.Debug("Checking event availability", zap.Uint("event_id", eventID))
|
||||
|
||||
event, err := s.eventRepo.FindByID(eventID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("event not found: %w", err)
|
||||
}
|
||||
|
||||
// Если максимальное количество участников не установлено, считаем доступным
|
||||
if event.MaxParticipants == 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Получаем текущее количество подтвержденных регистраций
|
||||
currentCount, err := s.registrationRepo.CountByEventID(eventID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to count registrations: %w", err)
|
||||
}
|
||||
|
||||
available := int(currentCount) < event.MaxParticipants
|
||||
|
||||
s.logger.Debug("Event availability check completed",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Int64("current_count", currentCount),
|
||||
zap.Int("max_participants", event.MaxParticipants),
|
||||
zap.Bool("available", available),
|
||||
)
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// contains проверяет наличие строки в слайсе
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// service/event_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EventService interface {
|
||||
CreateEvent(event *models.Event) error
|
||||
GetEventByID(id uint) (*models.Event, error)
|
||||
GetAllEvents() ([]models.Event, error)
|
||||
UpdateEvent(event *models.Event) error
|
||||
DeleteEvent(id uint) error
|
||||
GetEventsByType(eventType models.EventType) ([]models.Event, error)
|
||||
GetUpcomingEvents() ([]models.Event, error)
|
||||
GetEventsByDateRange(startDate, endDate time.Time) ([]models.Event, error)
|
||||
UpdateParticipantsCount(eventID uint) error
|
||||
ToggleRegistrationStatus(eventID uint, registrationOpen bool) error
|
||||
}
|
||||
|
||||
type eventService struct {
|
||||
eventRepo repository.EventRepository
|
||||
registrationRepo repository.EventRegistrationRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewEventService(
|
||||
eventRepo repository.EventRepository,
|
||||
registrationRepo repository.EventRegistrationRepository,
|
||||
log logger.LoggerInterface,
|
||||
) EventService {
|
||||
serviceLogger := log.With(zap.String("service", "event"))
|
||||
|
||||
return &eventService{
|
||||
eventRepo: eventRepo,
|
||||
registrationRepo: registrationRepo,
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateEvent создает новое событие
|
||||
func (s *eventService) CreateEvent(event *models.Event) error {
|
||||
s.logger.Info("Creating new event",
|
||||
zap.String("title", event.Title),
|
||||
zap.String("type", string(event.Type)),
|
||||
zap.Time("date", event.Date),
|
||||
)
|
||||
|
||||
if err := s.eventRepo.Create(event); err != nil {
|
||||
s.logger.Error("Failed to create event",
|
||||
zap.String("title", event.Title),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to create event: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Event created successfully",
|
||||
zap.Uint("event_id", event.ID),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEventByID возвращает событие по ID
|
||||
func (s *eventService) GetEventByID(id uint) (*models.Event, error) {
|
||||
s.logger.Debug("Getting event by ID", zap.Uint("event_id", id))
|
||||
|
||||
event, err := s.eventRepo.FindByID(id)
|
||||
if err != nil {
|
||||
s.logger.Warn("Event not found",
|
||||
zap.Uint("event_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("event not found: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Event retrieved successfully",
|
||||
zap.Uint("event_id", id),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
return event, nil
|
||||
}
|
||||
|
||||
// GetAllEvents возвращает все события
|
||||
func (s *eventService) GetAllEvents() ([]models.Event, error) {
|
||||
s.logger.Debug("Getting all events")
|
||||
|
||||
events, err := s.eventRepo.FindAll()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get events", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get events: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Events retrieved successfully",
|
||||
zap.Int("count", len(events)),
|
||||
)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// UpdateEvent обновляет событие
|
||||
func (s *eventService) UpdateEvent(event *models.Event) error {
|
||||
s.logger.Info("Updating event",
|
||||
zap.Uint("event_id", event.ID),
|
||||
zap.String("title", event.Title),
|
||||
)
|
||||
|
||||
// Проверяем существование события
|
||||
existingEvent, err := s.eventRepo.FindByID(event.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Event not found for update",
|
||||
zap.Uint("event_id", event.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
// Сохраняем неизменяемые поля
|
||||
event.CreatedAt = existingEvent.CreatedAt
|
||||
event.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.eventRepo.Update(event); err != nil {
|
||||
s.logger.Error("Failed to update event",
|
||||
zap.Uint("event_id", event.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update event: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Event updated successfully",
|
||||
zap.Uint("event_id", event.ID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEvent удаляет событие
|
||||
func (s *eventService) DeleteEvent(id uint) error {
|
||||
s.logger.Info("Deleting event", zap.Uint("event_id", id))
|
||||
|
||||
// Проверяем существование события
|
||||
_, err := s.eventRepo.FindByID(id)
|
||||
if err != nil {
|
||||
s.logger.Warn("Event not found for deletion",
|
||||
zap.Uint("event_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("event not found")
|
||||
}
|
||||
|
||||
if err := s.eventRepo.Delete(id); err != nil {
|
||||
s.logger.Error("Failed to delete event",
|
||||
zap.Uint("event_id", id),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to delete event: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Event deleted successfully",
|
||||
zap.Uint("event_id", id),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEventsByType возвращает события по типу
|
||||
func (s *eventService) GetEventsByType(eventType models.EventType) ([]models.Event, error) {
|
||||
s.logger.Debug("Getting events by type", zap.String("type", string(eventType)))
|
||||
|
||||
events, err := s.eventRepo.FindByType(eventType)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get events by type",
|
||||
zap.String("type", string(eventType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get events by type: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Events by type retrieved successfully",
|
||||
zap.String("type", string(eventType)),
|
||||
zap.Int("count", len(events)),
|
||||
)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetUpcomingEvents возвращает предстоящие события
|
||||
func (s *eventService) GetUpcomingEvents() ([]models.Event, error) {
|
||||
s.logger.Debug("Getting upcoming events")
|
||||
|
||||
events, err := s.eventRepo.FindUpcoming()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get upcoming events", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to get upcoming events: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Upcoming events retrieved successfully",
|
||||
zap.Int("count", len(events)),
|
||||
)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// GetEventsByDateRange возвращает события в диапазоне дат
|
||||
func (s *eventService) GetEventsByDateRange(startDate, endDate time.Time) ([]models.Event, error) {
|
||||
s.logger.Debug("Getting events by date range",
|
||||
zap.Time("start_date", startDate),
|
||||
zap.Time("end_date", endDate),
|
||||
)
|
||||
|
||||
events, err := s.eventRepo.FindByDateRange(startDate, endDate)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get events by date range",
|
||||
zap.Time("start_date", startDate),
|
||||
zap.Time("end_date", endDate),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get events by date range: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Events by date range retrieved successfully",
|
||||
zap.Time("start_date", startDate),
|
||||
zap.Time("end_date", endDate),
|
||||
zap.Int("count", len(events)),
|
||||
)
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// UpdateParticipantsCount обновляет количество участников события
|
||||
func (s *eventService) UpdateParticipantsCount(eventID uint) error {
|
||||
s.logger.Debug("Updating participants count", zap.Uint("event_id", eventID))
|
||||
|
||||
count, err := s.registrationRepo.CountByEventID(eventID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to count event registrations",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to count registrations: %w", err)
|
||||
}
|
||||
|
||||
if err := s.eventRepo.UpdateParticipantsCount(eventID, int(count)); err != nil {
|
||||
s.logger.Error("Failed to update participants count",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Int64("count", count),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to update participants count: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Participants count updated successfully",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Int64("count", count),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToggleRegistrationStatus переключает статус регистрации на событие
|
||||
func (s *eventService) ToggleRegistrationStatus(eventID uint, registrationOpen bool) error {
|
||||
s.logger.Info("Toggling registration status",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Bool("registration_open", registrationOpen),
|
||||
)
|
||||
|
||||
if err := s.eventRepo.UpdateRegistrationStatus(eventID, registrationOpen); err != nil {
|
||||
s.logger.Error("Failed to toggle registration status",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Bool("registration_open", registrationOpen),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("failed to toggle registration status: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Registration status updated successfully",
|
||||
zap.Uint("event_id", eventID),
|
||||
zap.Bool("registration_open", registrationOpen),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// service/jwt_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTService interface {
|
||||
GenerateToken(userID uint, email string) (string, error)
|
||||
ValidateToken(tokenString string) (*jwt.Token, error)
|
||||
ExtractUserID(token *jwt.Token) (uint, error)
|
||||
}
|
||||
|
||||
type jwtService struct {
|
||||
secretKey string
|
||||
}
|
||||
|
||||
func NewJWTService(secretKey string) JWTService {
|
||||
return &jwtService{secretKey: secretKey}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (j *jwtService) GenerateToken(userID uint, email string) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(j.secretKey))
|
||||
}
|
||||
|
||||
func (j *jwtService) ValidateToken(tokenString string) (*jwt.Token, error) {
|
||||
return jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(j.secretKey), nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *jwtService) ExtractUserID(token *jwt.Token) (uint, error) {
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok {
|
||||
return 0, errors.New("invalid token claims")
|
||||
}
|
||||
return claims.UserID, nil
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type NewsService interface {
|
||||
CreateNews(req models.CreateNewsRequest, authorID uint) (*models.NewsResponse, error)
|
||||
GetNewsByID(id uint) (*models.NewsResponse, error)
|
||||
GetAllNews(limit, offset int, category string) ([]models.NewsResponse, int64, error)
|
||||
UpdateNews(id uint, req models.UpdateNewsRequest, userID uint) (*models.NewsResponse, error)
|
||||
DeleteNews(id uint, userID uint) error
|
||||
IncrementViews(id uint) error
|
||||
CreateComment(newsID uint, req models.CreateCommentRequest, authorID uint) (*models.CommentResponse, error)
|
||||
GetCommentsByNewsID(newsID uint) ([]models.CommentResponse, error)
|
||||
DeleteComment(commentID, userID uint) error
|
||||
GetUserNews(userID uint, limit, offset int) ([]models.NewsResponse, int64, error)
|
||||
}
|
||||
|
||||
type newsService struct {
|
||||
newsRepo repository.NewsRepository
|
||||
commentRepo repository.CommentRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewNewsService(newsRepo repository.NewsRepository, commentRepo repository.CommentRepository, log logger.LoggerInterface) NewsService {
|
||||
|
||||
serviceLogger := log.With(zap.String("service", "news"))
|
||||
|
||||
return &newsService{
|
||||
newsRepo: newsRepo,
|
||||
commentRepo: commentRepo,
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *newsService) CreateNews(req models.CreateNewsRequest, authorID uint) (*models.NewsResponse, error) {
|
||||
news := &models.News{
|
||||
Title: req.Title,
|
||||
Excerpt: req.Excerpt,
|
||||
Content: req.Content,
|
||||
Image: req.Image,
|
||||
Category: req.Category,
|
||||
AuthorID: authorID,
|
||||
}
|
||||
|
||||
if err := s.newsRepo.Create(news); err != nil {
|
||||
s.logger.Error("Failed to create news", zap.Error(err))
|
||||
return nil, errors.New("failed to create news")
|
||||
}
|
||||
|
||||
// Получаем созданную новость с автором
|
||||
createdNews, err := s.newsRepo.GetByID(news.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toNewsResponse(createdNews), nil
|
||||
}
|
||||
|
||||
func (s *newsService) GetNewsByID(id uint) (*models.NewsResponse, error) {
|
||||
news, err := s.newsRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("news not found")
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик просмотров
|
||||
go s.newsRepo.IncrementViews(id)
|
||||
|
||||
return s.toNewsResponse(news), nil
|
||||
}
|
||||
|
||||
func (s *newsService) GetAllNews(limit, offset int, category string) ([]models.NewsResponse, int64, error) {
|
||||
news, total, err := s.newsRepo.GetAll(limit, offset, category)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
responses := make([]models.NewsResponse, len(news))
|
||||
for i, n := range news {
|
||||
responses[i] = *s.toNewsResponse(&n)
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
}
|
||||
|
||||
func (s *newsService) UpdateNews(id uint, req models.UpdateNewsRequest, userID uint) (*models.NewsResponse, error) {
|
||||
news, err := s.newsRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("news not found")
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if news.AuthorID != userID {
|
||||
return nil, errors.New("access denied")
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
if req.Title != "" {
|
||||
news.Title = req.Title
|
||||
}
|
||||
if req.Excerpt != "" {
|
||||
news.Excerpt = req.Excerpt
|
||||
}
|
||||
if req.Content != "" {
|
||||
news.Content = req.Content
|
||||
}
|
||||
if req.Image != "" {
|
||||
news.Image = req.Image
|
||||
}
|
||||
if req.Category != "" {
|
||||
news.Category = req.Category
|
||||
}
|
||||
|
||||
if err := s.newsRepo.Update(news); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toNewsResponse(news), nil
|
||||
}
|
||||
|
||||
func (s *newsService) DeleteNews(id uint, userID uint) error {
|
||||
news, err := s.newsRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("news not found")
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if news.AuthorID != userID {
|
||||
return errors.New("access denied")
|
||||
}
|
||||
|
||||
return s.newsRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *newsService) IncrementViews(id uint) error {
|
||||
return s.newsRepo.IncrementViews(id)
|
||||
}
|
||||
|
||||
func (s *newsService) CreateComment(newsID uint, req models.CreateCommentRequest, authorID uint) (*models.CommentResponse, error) {
|
||||
// Проверяем существование новости
|
||||
_, err := s.newsRepo.GetByID(newsID)
|
||||
if err != nil {
|
||||
return nil, errors.New("news not found")
|
||||
}
|
||||
|
||||
comment := &models.Comment{
|
||||
Content: req.Content,
|
||||
NewsID: newsID,
|
||||
AuthorID: authorID,
|
||||
}
|
||||
|
||||
if err := s.commentRepo.Create(comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем созданный комментарий с автором
|
||||
createdComment, err := s.commentRepo.GetByID(comment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toCommentResponse(createdComment), nil
|
||||
}
|
||||
|
||||
func (s *newsService) GetCommentsByNewsID(newsID uint) ([]models.CommentResponse, error) {
|
||||
comments, err := s.commentRepo.GetByNewsID(newsID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responses := make([]models.CommentResponse, len(comments))
|
||||
for i, c := range comments {
|
||||
responses[i] = *s.toCommentResponse(&c)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func (s *newsService) DeleteComment(commentID, userID uint) error {
|
||||
comment, err := s.commentRepo.GetByID(commentID)
|
||||
if err != nil {
|
||||
return errors.New("comment not found")
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if comment.AuthorID != userID {
|
||||
return errors.New("access denied")
|
||||
}
|
||||
|
||||
return s.commentRepo.Delete(commentID)
|
||||
}
|
||||
|
||||
func (s *newsService) GetUserNews(userID uint, limit, offset int) ([]models.NewsResponse, int64, error) {
|
||||
news, total, err := s.newsRepo.GetByAuthor(userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
responses := make([]models.NewsResponse, len(news))
|
||||
for i, n := range news {
|
||||
responses[i] = *s.toNewsResponse(&n)
|
||||
}
|
||||
|
||||
return responses, total, nil
|
||||
}
|
||||
|
||||
// Вспомогательные методы для преобразования
|
||||
func (s *newsService) toNewsResponse(news *models.News) *models.NewsResponse {
|
||||
return &models.NewsResponse{
|
||||
ID: news.ID,
|
||||
CreatedAt: news.CreatedAt,
|
||||
UpdatedAt: news.UpdatedAt,
|
||||
Title: news.Title,
|
||||
Excerpt: news.Excerpt,
|
||||
Content: news.Content,
|
||||
Image: news.Image,
|
||||
Category: news.Category,
|
||||
Views: news.Views,
|
||||
Author: models.AuthorInfo{
|
||||
ID: news.Author.ID,
|
||||
FirstName: news.Author.FirstName,
|
||||
LastName: news.Author.LastName,
|
||||
},
|
||||
Comments: len(news.Comments),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *newsService) toCommentResponse(comment *models.Comment) *models.CommentResponse {
|
||||
return &models.CommentResponse{
|
||||
ID: comment.ID,
|
||||
CreatedAt: comment.CreatedAt,
|
||||
Content: comment.Content,
|
||||
Author: models.AuthorInfo{
|
||||
ID: comment.Author.ID,
|
||||
FirstName: comment.Author.FirstName,
|
||||
LastName: comment.Author.LastName,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// services/personal_best_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PersonalBestService struct {
|
||||
pbRepo repository.PersonalBestRepository
|
||||
userStatsService UserStatsService
|
||||
}
|
||||
|
||||
func NewPersonalBestService(pbRepo repository.PersonalBestRepository, userStatsService UserStatsService) *PersonalBestService {
|
||||
return &PersonalBestService{
|
||||
pbRepo: pbRepo,
|
||||
userStatsService: userStatsService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreatePersonalBest создает новый личный рекорд
|
||||
func (s *PersonalBestService) CreatePersonalBest(userID uint, req models.PersonalBestCreateRequest) (*models.PersonalBest, error) {
|
||||
// Вычисляем темп, если не предоставлен
|
||||
pace := req.Pace
|
||||
if pace == "" {
|
||||
calculatedPace, err := s.pbRepo.CalculatePace(req.Time, req.DistanceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pace = calculatedPace
|
||||
}
|
||||
|
||||
// Проверяем, является ли это личным рекордом
|
||||
isBest, err := s.pbRepo.ExistsBetterTime(userID, req.DistanceType, req.Time)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
personalBest := &models.PersonalBest{
|
||||
UserID: userID,
|
||||
DistanceType: req.DistanceType,
|
||||
Time: req.Time,
|
||||
Pace: pace,
|
||||
Date: req.Date,
|
||||
EventName: req.EventName,
|
||||
Location: req.Location,
|
||||
Verified: false, // По умолчанию не подтвержден
|
||||
}
|
||||
|
||||
if err := s.pbRepo.Create(personalBest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !isBest {
|
||||
if err := s.userStatsService.UpdatePersonalBest(userID, string(req.DistanceType), req.Time); err != nil {
|
||||
// Логируем ошибку, но не прерываем выполнение
|
||||
fmt.Printf("Failed to update user stats: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return personalBest, nil
|
||||
}
|
||||
|
||||
// GetPersonalBestByID возвращает личный рекорд по ID
|
||||
func (s *PersonalBestService) GetPersonalBestByID(id uint) (*models.PersonalBest, error) {
|
||||
return s.pbRepo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetUserPersonalBests возвращает все личные рекорды пользователя
|
||||
func (s *PersonalBestService) GetUserPersonalBests(userID uint) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetByUserID(userID)
|
||||
}
|
||||
|
||||
// GetPersonalBestsByDistance возвращает личные рекорды по дистанции
|
||||
func (s *PersonalBestService) GetPersonalBestsByDistance(userID uint, distanceType models.DistanceType) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetByUserAndDistance(userID, distanceType)
|
||||
}
|
||||
|
||||
// GetBestByDistance возвращает лучший результат на дистанции
|
||||
func (s *PersonalBestService) GetBestByDistance(userID uint, distanceType models.DistanceType) (*models.PersonalBest, error) {
|
||||
return s.pbRepo.GetBestByDistance(userID, distanceType)
|
||||
}
|
||||
|
||||
// UpdatePersonalBest обновляет личный рекорд
|
||||
func (s *PersonalBestService) UpdatePersonalBest(id uint, userID uint, req models.PersonalBestUpdateRequest) (*models.PersonalBest, error) {
|
||||
// Получаем существующий рекорд
|
||||
pb, err := s.pbRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что рекорд принадлежит пользователю
|
||||
if pb.UserID != userID {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
if req.DistanceType != "" {
|
||||
pb.DistanceType = req.DistanceType
|
||||
}
|
||||
if req.Time != "" {
|
||||
pb.Time = req.Time
|
||||
// Пересчитываем темп при изменении времени
|
||||
if req.Pace == "" {
|
||||
calculatedPace, err := s.pbRepo.CalculatePace(req.Time, pb.DistanceType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pb.Pace = calculatedPace
|
||||
}
|
||||
}
|
||||
if req.Pace != "" {
|
||||
pb.Pace = req.Pace
|
||||
}
|
||||
if !req.Date.IsZero() {
|
||||
pb.Date = req.Date
|
||||
}
|
||||
if req.EventName != "" {
|
||||
pb.EventName = req.EventName
|
||||
}
|
||||
if req.Location != "" {
|
||||
pb.Location = req.Location
|
||||
}
|
||||
pb.Verified = req.Verified
|
||||
|
||||
if err := s.pbRepo.Update(pb); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pb, nil
|
||||
}
|
||||
|
||||
// DeletePersonalBest удаляет личный рекорд
|
||||
func (s *PersonalBestService) DeletePersonalBest(id uint, userID uint) error {
|
||||
// Проверяем, что рекорд принадлежит пользователю
|
||||
pb, err := s.pbRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pb.UserID != userID {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return s.pbRepo.Delete(id)
|
||||
}
|
||||
|
||||
// GetVerifiedPersonalBests возвращает подтвержденные личные рекорды
|
||||
func (s *PersonalBestService) GetVerifiedPersonalBests(userID uint) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetVerifiedByUserID(userID)
|
||||
}
|
||||
|
||||
// GetPersonalBestsByDateRange возвращает личные рекорды за период
|
||||
func (s *PersonalBestService) GetPersonalBestsByDateRange(userID uint, startDate, endDate time.Time) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetByDateRange(userID, startDate, endDate)
|
||||
}
|
||||
|
||||
// GetRecentPersonalBests возвращает последние личные рекорды
|
||||
func (s *PersonalBestService) GetRecentPersonalBests(userID uint, limit int) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetRecentPersonalBests(userID, limit)
|
||||
}
|
||||
|
||||
// GetPersonalBestsByEvent возвращает личные рекорды по названию события
|
||||
func (s *PersonalBestService) GetPersonalBestsByEvent(userID uint, eventName string) ([]models.PersonalBest, error) {
|
||||
return s.pbRepo.GetByEventName(userID, eventName)
|
||||
}
|
||||
|
||||
// GetPersonalBestsSummary возвращает сводку лучших результатов
|
||||
func (s *PersonalBestService) GetPersonalBestsSummary(userID uint) (*models.PersonalBestsSummary, error) {
|
||||
return s.pbRepo.GetPersonalBestsSummary(userID)
|
||||
}
|
||||
|
||||
// VerifyPersonalBest подтверждает личный рекорд
|
||||
func (s *PersonalBestService) VerifyPersonalBest(id uint, userID uint) error {
|
||||
pb, err := s.pbRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Проверяем, что рекорд принадлежит пользователю
|
||||
if pb.UserID != userID {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
pb.Verified = true
|
||||
return s.pbRepo.Update(pb)
|
||||
}
|
||||
|
||||
// CalculatePace вычисляет темп для времени и дистанции
|
||||
func (s *PersonalBestService) CalculatePace(timeStr string, distanceType models.DistanceType) (string, error) {
|
||||
return s.pbRepo.CalculatePace(timeStr, distanceType)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// service/review_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"errors"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ReviewService interface {
|
||||
CreateReview(req *models.CreateReviewRequest, authorID uint) (*models.ReviewResponse, error)
|
||||
GetReviewByID(id uint) (*models.ReviewResponse, error)
|
||||
GetAllReviews(page, limit int, sortBy, filter string) ([]models.ReviewResponse, int, error)
|
||||
GetUserReviews(userID uint) ([]models.ReviewResponse, error)
|
||||
UpdateReview(id uint, req *models.UpdateReviewRequest, userID uint, isAdmin bool) (*models.ReviewResponse, error)
|
||||
DeleteReview(id uint, userID uint, isAdmin bool) error
|
||||
GetReviewsStats() (*models.ReviewsStatsResponse, error)
|
||||
}
|
||||
|
||||
type reviewService struct {
|
||||
reviewRepo repository.ReviewRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewReviewService(reviewRepo repository.ReviewRepository, logger logger.LoggerInterface) ReviewService {
|
||||
return &reviewService{
|
||||
reviewRepo: reviewRepo,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *reviewService) CreateReview(req *models.CreateReviewRequest, authorID uint) (*models.ReviewResponse, error) {
|
||||
review := &models.Review{
|
||||
Rating: req.Rating,
|
||||
Text: req.Text,
|
||||
Achievement: req.Achievement,
|
||||
Distance: req.Distance,
|
||||
Improvement: req.Improvement,
|
||||
Trainings: req.Trainings,
|
||||
AuthorID: authorID,
|
||||
Verified: false, // По умолчанию непроверенный
|
||||
}
|
||||
|
||||
if err := s.reviewRepo.Create(review); err != nil {
|
||||
s.logger.Error("Failed to create review", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем созданный отзыв с информацией об авторе
|
||||
createdReview, err := s.reviewRepo.GetByID(review.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get created review", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toReviewResponse(createdReview), nil
|
||||
}
|
||||
|
||||
func (s *reviewService) GetReviewByID(id uint) (*models.ReviewResponse, error) {
|
||||
review, err := s.reviewRepo.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review by ID", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toReviewResponse(review), nil
|
||||
}
|
||||
|
||||
func (s *reviewService) GetAllReviews(page, limit int, sortBy, filter string) ([]models.ReviewResponse, int, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
reviews, total, err := s.reviewRepo.GetAll(page, limit, sortBy, filter)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to get all reviews", zap.Error(err))
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
responses := make([]models.ReviewResponse, len(reviews))
|
||||
for i, review := range reviews {
|
||||
responses[i] = *s.toReviewResponse(&review)
|
||||
}
|
||||
|
||||
totalPages := (int(total) + limit - 1) / limit
|
||||
|
||||
return responses, totalPages, nil
|
||||
}
|
||||
|
||||
func (s *reviewService) GetUserReviews(userID uint) ([]models.ReviewResponse, error) {
|
||||
reviews, err := s.reviewRepo.GetByAuthorID(userID)
|
||||
if err != nil {
|
||||
s.logger.With(zap.Int("userID", int(userID))).Error("Failed to get user reviews", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responses := make([]models.ReviewResponse, len(reviews))
|
||||
for i, review := range reviews {
|
||||
responses[i] = *s.toReviewResponse(&review)
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func (s *reviewService) UpdateReview(id uint, req *models.UpdateReviewRequest, userID uint, isAdmin bool) (*models.ReviewResponse, error) {
|
||||
review, err := s.reviewRepo.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review for update", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if review.AuthorID != userID && !isAdmin {
|
||||
s.logger.With(zap.Int("userID", int(userID))).With(zap.Int("reviewAuthorID", int(review.AuthorID))).Error("Unauthorized attempt to update review", zap.Error(err))
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
if req.Rating != 0 {
|
||||
review.Rating = req.Rating
|
||||
}
|
||||
if req.Text != "" {
|
||||
review.Text = req.Text
|
||||
}
|
||||
if req.Achievement != "" {
|
||||
review.Achievement = req.Achievement
|
||||
}
|
||||
if req.Distance != "" {
|
||||
review.Distance = req.Distance
|
||||
}
|
||||
if req.Improvement != "" {
|
||||
review.Improvement = req.Improvement
|
||||
}
|
||||
if req.Trainings != 0 {
|
||||
review.Trainings = req.Trainings
|
||||
}
|
||||
|
||||
if err := s.reviewRepo.Update(review); err != nil {
|
||||
s.logger.With(zap.Int("id", int(id))).Error("Failed to update review", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Получаем обновленный отзыв
|
||||
updatedReview, err := s.reviewRepo.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.With(zap.Int("id", int(id))).Error("Failed to get updated review", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.toReviewResponse(updatedReview), nil
|
||||
}
|
||||
|
||||
func (s *reviewService) DeleteReview(id uint, userID uint, isAdmin bool) error {
|
||||
review, err := s.reviewRepo.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.With(zap.Int("id", int(id))).Error("Failed to get review for deletion", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Проверяем права доступа
|
||||
if review.AuthorID != userID && !isAdmin {
|
||||
s.logger.With(zap.Int("userID", int(userID))).With(zap.Int("reviewAuthorID", int(review.AuthorID))).Error("Unauthorized attempt to delete review", zap.Error(err))
|
||||
return errors.New("unauthorized")
|
||||
}
|
||||
|
||||
return s.reviewRepo.Delete(id)
|
||||
}
|
||||
|
||||
func (s *reviewService) GetReviewsStats() (*models.ReviewsStatsResponse, error) {
|
||||
return s.reviewRepo.GetStats()
|
||||
}
|
||||
|
||||
func (s *reviewService) toReviewResponse(review *models.Review) *models.ReviewResponse {
|
||||
return &models.ReviewResponse{
|
||||
ID: review.ID,
|
||||
CreatedAt: review.CreatedAt,
|
||||
Rating: review.Rating,
|
||||
Text: review.Text,
|
||||
Achievement: review.Achievement,
|
||||
Distance: review.Distance,
|
||||
Improvement: review.Improvement,
|
||||
Trainings: review.Trainings,
|
||||
Verified: review.Verified,
|
||||
Author: models.AuthorInfo{
|
||||
ID: review.Author.ID,
|
||||
FirstName: review.Author.FirstName,
|
||||
LastName: review.Author.LastName,
|
||||
Email: review.Author.Email,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
// service/training_plan_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type TrainingPlanService interface {
|
||||
CreateTrainingPlan(userID uint, req *models.TrainingPlanCreateRequest) (*models.TrainingPlan, error)
|
||||
GetTrainingPlansByUserID(userID uint) ([]models.TrainingPlan, error)
|
||||
GetTrainingPlanByID(userID uint, planID uint) (*models.TrainingPlan, error)
|
||||
UpdateTrainingPlan(userID uint, planID uint, req *models.TrainingPlanUpdateRequest) (*models.TrainingPlan, error)
|
||||
DeleteTrainingPlan(userID uint, planID uint) error
|
||||
GetActiveTrainingPlan(userID uint) (*models.TrainingPlan, error)
|
||||
MarkTrainingPlanAsCompleted(userID uint, planID uint) error
|
||||
UpdateCurrentWeek(userID uint, planID uint, currentWeek int) error
|
||||
}
|
||||
|
||||
type trainingPlanService struct {
|
||||
trainingPlanRepo repository.TrainingPlanRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewTrainingPlanService(trainingPlanRepo repository.TrainingPlanRepository) TrainingPlanService {
|
||||
return &trainingPlanService{
|
||||
trainingPlanRepo: trainingPlanRepo,
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("service", "training_plan"))),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTrainingPlan создает новый план тренировок
|
||||
func (s *trainingPlanService) CreateTrainingPlan(userID uint, req *models.TrainingPlanCreateRequest) (*models.TrainingPlan, error) {
|
||||
s.logger.Debug("creating training plan",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("title", req.Title),
|
||||
)
|
||||
|
||||
plan := &models.TrainingPlan{
|
||||
UserID: userID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Weeks: req.Weeks,
|
||||
WorkoutsPerWeek: req.WorkoutsPerWeek,
|
||||
TargetDistance: req.TargetDistance,
|
||||
TargetDate: req.TargetDate,
|
||||
CurrentWeek: 1,
|
||||
Completed: false,
|
||||
}
|
||||
|
||||
if err := s.trainingPlanRepo.Create(plan); err != nil {
|
||||
s.logger.Error("failed to create training plan in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("training plan created successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", plan.ID),
|
||||
)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// GetTrainingPlansByUserID возвращает все планы тренировок пользователя
|
||||
func (s *trainingPlanService) GetTrainingPlansByUserID(userID uint) ([]models.TrainingPlan, error) {
|
||||
s.logger.Debug("getting training plans for user", zap.Uint("user_id", userID))
|
||||
|
||||
plans, err := s.trainingPlanRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get training plans from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("training plans retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("count", len(plans)),
|
||||
)
|
||||
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
// GetTrainingPlanByID возвращает план тренировок по ID
|
||||
func (s *trainingPlanService) GetTrainingPlanByID(userID uint, planID uint) (*models.TrainingPlan, error) {
|
||||
s.logger.Debug("getting training plan by ID",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
plan, err := s.trainingPlanRepo.GetByID(planID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get training plan from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что план принадлежит пользователю
|
||||
if plan.UserID != userID {
|
||||
s.logger.Warn("training plan access denied - user mismatch",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_user_id", plan.UserID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
s.logger.Debug("training plan retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// UpdateTrainingPlan обновляет план тренировок
|
||||
func (s *trainingPlanService) UpdateTrainingPlan(userID uint, planID uint, req *models.TrainingPlanUpdateRequest) (*models.TrainingPlan, error) {
|
||||
s.logger.Debug("updating training plan",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
// Сначала получаем существующий план
|
||||
plan, err := s.GetTrainingPlanByID(userID, planID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Обновляем только переданные поля
|
||||
if req.Title != "" {
|
||||
plan.Title = req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
plan.Description = req.Description
|
||||
}
|
||||
if req.Weeks > 0 {
|
||||
plan.Weeks = req.Weeks
|
||||
}
|
||||
if req.WorkoutsPerWeek > 0 {
|
||||
plan.WorkoutsPerWeek = req.WorkoutsPerWeek
|
||||
}
|
||||
if req.TargetDistance != "" {
|
||||
plan.TargetDistance = req.TargetDistance
|
||||
}
|
||||
if !req.TargetDate.IsZero() {
|
||||
plan.TargetDate = req.TargetDate
|
||||
}
|
||||
|
||||
// Сохраняем обновления
|
||||
if err := s.trainingPlanRepo.Update(plan); err != nil {
|
||||
s.logger.Error("failed to update training plan in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("training plan updated successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// DeleteTrainingPlan удаляет план тренировок
|
||||
func (s *trainingPlanService) DeleteTrainingPlan(userID uint, planID uint) error {
|
||||
s.logger.Debug("deleting training plan",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
// Проверяем, что план существует и принадлежит пользователю
|
||||
_, err := s.GetTrainingPlanByID(userID, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Удаляем план
|
||||
if err := s.trainingPlanRepo.Delete(planID); err != nil {
|
||||
s.logger.Error("failed to delete training plan from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Debug("training plan deleted successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveTrainingPlan возвращает активный план тренировок пользователя
|
||||
func (s *trainingPlanService) GetActiveTrainingPlan(userID uint) (*models.TrainingPlan, error) {
|
||||
s.logger.Debug("getting active training plan for user", zap.Uint("user_id", userID))
|
||||
|
||||
plan, err := s.trainingPlanRepo.GetActivePlan(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get active training plan from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("active training plan retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", plan.ID),
|
||||
)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// MarkTrainingPlanAsCompleted помечает план тренировок как завершенный
|
||||
func (s *trainingPlanService) MarkTrainingPlanAsCompleted(userID uint, planID uint) error {
|
||||
s.logger.Debug("marking training plan as completed",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
// Проверяем, что план существует и принадлежит пользователю
|
||||
_, err := s.GetTrainingPlanByID(userID, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Помечаем как завершенный
|
||||
if err := s.trainingPlanRepo.MarkAsCompleted(planID); err != nil {
|
||||
s.logger.Error("failed to mark training plan as completed in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Debug("training plan marked as completed successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCurrentWeek обновляет текущую неделю плана тренировок
|
||||
func (s *trainingPlanService) UpdateCurrentWeek(userID uint, planID uint, currentWeek int) error {
|
||||
s.logger.Debug("updating current week for training plan",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Int("current_week", currentWeek),
|
||||
)
|
||||
|
||||
// Проверяем, что план существует и принадлежит пользователю
|
||||
_, err := s.GetTrainingPlanByID(userID, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем текущую неделю
|
||||
if err := s.trainingPlanRepo.UpdateCurrentWeek(planID, currentWeek); err != nil {
|
||||
s.logger.Error("failed to update current week in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Debug("current week updated successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("plan_id", planID),
|
||||
zap.Int("current_week", currentWeek),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
GetUserProfile(userID uint) (*models.User, error)
|
||||
UpdateProfile(user *models.User) error
|
||||
GetAllUsers() ([]models.User, error)
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
jwtService JWTService
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
// Обновление профиля
|
||||
func (s *userService) UpdateProfile(user *models.User) error {
|
||||
s.logger.Info("Updating user profile",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
// Проверяем, что пользователь существует
|
||||
existingUser, err := s.userRepo.FindByID(user.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("User not found for profile update",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Убеждаемся, что email не меняется
|
||||
user.Email = existingUser.Email
|
||||
user.Avatar = existingUser.Avatar
|
||||
|
||||
updateData := &models.User{
|
||||
ID: user.ID,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Avatar: user.Avatar,
|
||||
Phone: user.Phone,
|
||||
Experience: user.Experience,
|
||||
Goals: user.Goals,
|
||||
Newsletter: user.Newsletter,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return s.userRepo.UpdateExcludeEmail(updateData)
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepository, jwtService JWTService, log logger.LoggerInterface) userService {
|
||||
// Создаем логгер с контекстом для сервиса
|
||||
serviceLogger := log.With(zap.String("service", "user"))
|
||||
|
||||
return userService{
|
||||
userRepo: userRepo,
|
||||
jwtService: jwtService,
|
||||
logger: serviceLogger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userService) GetAllUsers() ([]models.User, error) {
|
||||
s.logger.Info("Fetching all users")
|
||||
|
||||
users, err := s.userRepo.FindAll()
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to fetch users",
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("failed to get users: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Successfully fetched users",
|
||||
zap.Int("count", len(users)),
|
||||
)
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (s *authService) UpdateProfile(user *models.User) error {
|
||||
s.logger.Info("Updating user profile",
|
||||
zap.Uint("user_id", user.ID),
|
||||
)
|
||||
|
||||
// Проверяем, что пользователь существует
|
||||
existingUser, err := s.userRepo.FindByID(user.ID)
|
||||
if err != nil {
|
||||
s.logger.Error("User not found for profile update",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Убеждаемся, что email не меняется
|
||||
user.Email = existingUser.Email
|
||||
user.Avatar = existingUser.Avatar
|
||||
|
||||
updateData := &models.User{
|
||||
ID: user.ID,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Avatar: user.Avatar,
|
||||
Phone: user.Phone,
|
||||
Experience: user.Experience,
|
||||
Goals: user.Goals,
|
||||
Newsletter: user.Newsletter,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
return s.userRepo.UpdateExcludeEmail(updateData)
|
||||
}
|
||||
|
||||
func (s *userService) GetUserProfile(userID uint) (*models.User, error) {
|
||||
s.logger.Debug("Getting user profile",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to get user profile",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// service/user_stats_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserStatsService interface {
|
||||
GetUserStats(userID uint) (*models.UserStatsResponse, error)
|
||||
UpdatePersonalBest(userID uint, distanceType string, time string) error
|
||||
IncrementWorkout(userID uint, distance float64, duration int) error
|
||||
ResetWeeklyDistance(userID uint) error
|
||||
ResetMonthlyDistance(userID uint) error
|
||||
CreateUserStats(userID uint) error
|
||||
}
|
||||
|
||||
type userStatsService struct {
|
||||
logger logger.LoggerInterface
|
||||
userStatsRepo repository.UserStatsRepository
|
||||
}
|
||||
|
||||
func NewUserStatsService(userStatsRepo repository.UserStatsRepository) UserStatsService {
|
||||
return &userStatsService{
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("service", "user_stats"))),
|
||||
userStatsRepo: userStatsRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserStats возвращает статистику пользователя в формате DTO
|
||||
func (s *userStatsService) GetUserStats(userID uint) (*models.UserStatsResponse, error) {
|
||||
s.logger.Info("getting user stats",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
stats, err := s.userStatsRepo.GetUserStatsResponse(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user stats from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("user stats retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Float64("total_distance", stats.TotalDistance),
|
||||
zap.Int("workouts_count", stats.WorkoutsCount),
|
||||
)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// UpdatePersonalBest обновляет личный рекорд пользователя
|
||||
func (s *userStatsService) UpdatePersonalBest(userID uint, distanceType string, time string) error {
|
||||
s.logger.Info("updating personal best",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("distance_type", distanceType),
|
||||
zap.String("time", time),
|
||||
)
|
||||
|
||||
// Используем GetByUserIDOrCreate вместо проверки существования
|
||||
_, err := s.userStatsRepo.GetByUserIDOrCreate(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get or create user stats",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.userStatsRepo.UpdatePersonalBest(userID, distanceType, time); err != nil {
|
||||
s.logger.Error("failed to update personal best in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("distance_type", distanceType),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("personal best updated successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("distance_type", distanceType),
|
||||
zap.String("time", time),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementWorkout увеличивает счетчик тренировок и обновляет статистику
|
||||
func (s *userStatsService) IncrementWorkout(userID uint, distance float64, duration int) error {
|
||||
s.logger.Info("incrementing workout stats",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Float64("distance", distance),
|
||||
zap.Int("duration", duration),
|
||||
)
|
||||
|
||||
// Используем GetByUserIDOrCreate для гарантии существования статистики
|
||||
_, err := s.userStatsRepo.GetByUserIDOrCreate(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get or create user stats",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем серии тренировок
|
||||
currentTime := time.Now()
|
||||
if err := s.userStatsRepo.UpdateStreaks(userID, currentTime); err != nil {
|
||||
s.logger.Error("failed to update streaks in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем недельный и месячный пробег
|
||||
if err := s.userStatsRepo.UpdateWeeklyDistance(userID, distance); err != nil {
|
||||
s.logger.Error("failed to update weekly distance in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.userStatsRepo.UpdateMonthlyDistance(userID, distance); err != nil {
|
||||
s.logger.Error("failed to update monthly distance in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Увеличиваем счетчик тренировок и обновляем общие показатели
|
||||
if err := s.userStatsRepo.IncrementWorkouts(userID, distance, duration); err != nil {
|
||||
s.logger.Error("failed to increment workouts in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("workout stats incremented successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Float64("distance", distance),
|
||||
zap.Int("duration", duration),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetWeeklyDistance сбрасывает недельный пробег
|
||||
func (s *userStatsService) ResetWeeklyDistance(userID uint) error {
|
||||
s.logger.Info("resetting weekly distance",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
userStats, err := s.userStatsRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user stats for weekly reset",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
userStats.WeeklyDistance = 0
|
||||
if err := s.userStatsRepo.Update(userStats); err != nil {
|
||||
s.logger.Error("failed to reset weekly distance in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("weekly distance reset successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetMonthlyDistance сбрасывает месячный пробег
|
||||
func (s *userStatsService) ResetMonthlyDistance(userID uint) error {
|
||||
s.logger.Info("resetting monthly distance",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
userStats, err := s.userStatsRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user stats for monthly reset",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
userStats.MonthlyDistance = 0
|
||||
if err := s.userStatsRepo.Update(userStats); err != nil {
|
||||
s.logger.Error("failed to reset monthly distance in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("monthly distance reset successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserStats создает начальную статистику для пользователя
|
||||
func (s *userStatsService) CreateUserStats(userID uint) error {
|
||||
s.logger.Info("creating user stats",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
userStats := &models.UserStats{
|
||||
UserID: userID,
|
||||
TotalDistance: 0,
|
||||
TotalTime: 0,
|
||||
AvgPace: "0:00",
|
||||
WorkoutsCount: 0,
|
||||
CurrentStreak: 0,
|
||||
LongestStreak: 0,
|
||||
WeeklyDistance: 0,
|
||||
MonthlyDistance: 0,
|
||||
Best5K: "",
|
||||
Best10K: "",
|
||||
BestHalf: "",
|
||||
BestMarathon: "",
|
||||
LastWorkout: time.Time{},
|
||||
}
|
||||
|
||||
if err := s.userStatsRepo.Create(userStats); err != nil {
|
||||
s.logger.Error("failed to create user stats in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("user stats created successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// service/user_workout_service.go
|
||||
package service
|
||||
|
||||
import (
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type WorkoutService interface {
|
||||
CreateWorkout(userID uint, req *models.WorkoutCreateRequest) (*models.Workout, error)
|
||||
GetUserWorkouts(userID uint) ([]models.Workout, error)
|
||||
GetWorkoutByID(userID uint, workoutID uint) (*models.Workout, error)
|
||||
UpdateWorkout(userID uint, workoutID uint, req *models.WorkoutUpdateRequest) (*models.Workout, error)
|
||||
DeleteWorkout(userID uint, workoutID uint) error
|
||||
GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error)
|
||||
GetWorkoutsByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error)
|
||||
GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error)
|
||||
}
|
||||
|
||||
type workoutService struct {
|
||||
workoutRepo repository.WorkoutRepository
|
||||
logger logger.LoggerInterface
|
||||
}
|
||||
|
||||
func NewWorkoutService(workoutRepo repository.WorkoutRepository) WorkoutService {
|
||||
return &workoutService{
|
||||
workoutRepo: workoutRepo,
|
||||
logger: logger.NewWrapper(logger.Get().With(zap.String("service", "workout"))),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateWorkout создает новую тренировку
|
||||
func (s *workoutService) CreateWorkout(userID uint, req *models.WorkoutCreateRequest) (*models.Workout, error) {
|
||||
s.logger.Info("creating new workout",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("type", string(req.Type)),
|
||||
zap.Float64("distance", req.Distance),
|
||||
)
|
||||
|
||||
// Создаем модель тренировки
|
||||
workout := &models.Workout{
|
||||
UserID: userID,
|
||||
Type: req.Type,
|
||||
Distance: req.Distance,
|
||||
Duration: req.Duration,
|
||||
Pace: req.Pace,
|
||||
Calories: req.Calories,
|
||||
Notes: req.Notes,
|
||||
Date: req.Date,
|
||||
}
|
||||
|
||||
// Сохраняем в репозитории
|
||||
if err := s.workoutRepo.Create(workout); err != nil {
|
||||
s.logger.Error("failed to create workout in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("workout created successfully",
|
||||
zap.Uint("workout_id", workout.ID),
|
||||
zap.Uint("user_id", userID),
|
||||
)
|
||||
|
||||
return workout, nil
|
||||
}
|
||||
|
||||
// GetUserWorkouts возвращает все тренировки пользователя
|
||||
func (s *workoutService) GetUserWorkouts(userID uint) ([]models.Workout, error) {
|
||||
s.logger.Debug("getting user workouts", zap.Uint("user_id", userID))
|
||||
|
||||
workouts, err := s.workoutRepo.FindByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user workouts from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("retrieved user workouts",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("count", len(workouts)),
|
||||
)
|
||||
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// GetWorkoutByID возвращает тренировку по ID
|
||||
func (s *workoutService) GetWorkoutByID(userID uint, workoutID uint) (*models.Workout, error) {
|
||||
s.logger.Debug("getting workout by ID",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
workout, err := s.workoutRepo.FindByID(workoutID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get workout from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Проверяем, что тренировка принадлежит пользователю
|
||||
if workout.UserID != userID {
|
||||
s.logger.Warn("workout access denied - user mismatch",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_user_id", workout.UserID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
return nil, repository.ErrNotFound
|
||||
}
|
||||
|
||||
s.logger.Debug("workout retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
return workout, nil
|
||||
}
|
||||
|
||||
// UpdateWorkout обновляет тренировку
|
||||
func (s *workoutService) UpdateWorkout(userID uint, workoutID uint, req *models.WorkoutUpdateRequest) (*models.Workout, error) {
|
||||
s.logger.Info("updating workout",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
// Сначала получаем существующую тренировку
|
||||
workout, err := s.GetWorkoutByID(userID, workoutID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Обновляем только переданные поля
|
||||
if req.Type != "" {
|
||||
workout.Type = req.Type
|
||||
}
|
||||
if req.Distance > 0 {
|
||||
workout.Distance = req.Distance
|
||||
}
|
||||
if req.Duration > 0 {
|
||||
workout.Duration = req.Duration
|
||||
}
|
||||
if req.Pace != "" {
|
||||
workout.Pace = req.Pace
|
||||
}
|
||||
if req.Calories > 0 {
|
||||
workout.Calories = req.Calories
|
||||
}
|
||||
if req.Notes != "" {
|
||||
workout.Notes = req.Notes
|
||||
}
|
||||
if !req.Date.IsZero() {
|
||||
workout.Date = req.Date
|
||||
}
|
||||
|
||||
// Сохраняем обновления
|
||||
if err := s.workoutRepo.Update(workout); err != nil {
|
||||
s.logger.Error("failed to update workout in repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("workout updated successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
return workout, nil
|
||||
}
|
||||
|
||||
// DeleteWorkout удаляет тренировку
|
||||
func (s *workoutService) DeleteWorkout(userID uint, workoutID uint) error {
|
||||
s.logger.Info("deleting workout",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
// Проверяем, что тренировка существует и принадлежит пользователю
|
||||
workout, err := s.GetWorkoutByID(userID, workoutID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Удаляем тренировку
|
||||
if err := s.workoutRepo.Delete(workout.ID); err != nil {
|
||||
s.logger.Error("failed to delete workout from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("workout deleted successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Uint("workout_id", workoutID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkoutStats возвращает статистику тренировок
|
||||
func (s *workoutService) GetWorkoutStats(userID uint) (*models.WorkoutStatsResponse, error) {
|
||||
s.logger.Debug("getting workout stats", zap.Uint("user_id", userID))
|
||||
|
||||
stats, err := s.workoutRepo.GetWorkoutStats(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get workout stats from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("workout stats retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("total_workouts", stats.TotalWorkouts),
|
||||
zap.Float64("total_distance", stats.TotalDistance),
|
||||
)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetWorkoutsByType возвращает тренировки по типу
|
||||
func (s *workoutService) GetWorkoutsByType(userID uint, workoutType models.WorkoutType) ([]models.Workout, error) {
|
||||
s.logger.Debug("getting workouts by type",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("type", string(workoutType)),
|
||||
)
|
||||
|
||||
workouts, err := s.workoutRepo.GetByType(userID, workoutType)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get workouts by type from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("type", string(workoutType)),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("workouts by type retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("type", string(workoutType)),
|
||||
zap.Int("count", len(workouts)),
|
||||
)
|
||||
|
||||
return workouts, nil
|
||||
}
|
||||
|
||||
// GetLatestWorkouts возвращает последние тренировки
|
||||
func (s *workoutService) GetLatestWorkouts(userID uint, limit int) ([]models.Workout, error) {
|
||||
s.logger.Debug("getting latest workouts",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("limit", limit),
|
||||
)
|
||||
|
||||
workouts, err := s.workoutRepo.GetLatestWorkouts(userID, limit)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get latest workouts from repository",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("limit", limit),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("latest workouts retrieved successfully",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Int("limit", limit),
|
||||
zap.Int("count", len(workouts)),
|
||||
)
|
||||
|
||||
return workouts, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func InitDB(dsn string) (*gorm.DB, error) {
|
||||
// Используем PostgreSQL драйвер
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Получаем underlying sql.DB для настройки пула соединений
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database instance: %w", err)
|
||||
}
|
||||
|
||||
// Настраиваем пул соединений
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetMaxOpenConns(100)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
// Проверяем соединение
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("database ping failed: %w", err)
|
||||
}
|
||||
|
||||
log.Println("PostgreSQL connection established successfully")
|
||||
|
||||
// Auto migrate models
|
||||
err = db.AutoMigrate(
|
||||
&models.User{},
|
||||
// Добавьте другие модели здесь по мере расширения
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to auto-migrate models: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Database migration completed successfully")
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// pkg/email/email.go
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"api_bb/internal/config"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"github.com/wneessen/go-mail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service представляет сервис для отправки email
|
||||
type Service struct {
|
||||
client *mail.Client
|
||||
config *config.Config
|
||||
logger *zap.Logger
|
||||
tmpl *template.Template
|
||||
fromAddr string
|
||||
isActive bool
|
||||
}
|
||||
|
||||
// NewService создает новый экземпляр email сервиса
|
||||
func NewService(cfg *config.Config) (*Service, error) {
|
||||
log := logger.Get()
|
||||
log.Info("Initializing email service")
|
||||
|
||||
// Проверяем обязательные параметры конфигурации
|
||||
if err := validateConfig(cfg); err != nil {
|
||||
log.Warn("Email service configuration is invalid, service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Создаем SMTP клиент с правильными настройками
|
||||
client, err := createSMTPClient(cfg)
|
||||
if err != nil {
|
||||
log.Warn("Failed to create SMTP client, email service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Загружаем шаблоны писем
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
log.Warn("Failed to load email templates, email service will be disabled", zap.Error(err))
|
||||
return &Service{
|
||||
logger: log,
|
||||
isActive: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
service := &Service{
|
||||
client: client,
|
||||
config: cfg,
|
||||
logger: log,
|
||||
tmpl: tmpl,
|
||||
fromAddr: cfg.FromEmail,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
log.Info("Email service initialized successfully",
|
||||
zap.String("host", cfg.SMTPHost),
|
||||
zap.Int("port", cfg.SMTPPort),
|
||||
zap.String("from", cfg.FromEmail))
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// validateConfig проверяет корректность конфигурации email
|
||||
func validateConfig(cfg *config.Config) error {
|
||||
if cfg.SMTPHost == "" {
|
||||
return fmt.Errorf("SMTP host is required")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort <= 0 || cfg.SMTPPort > 65535 {
|
||||
return fmt.Errorf("invalid SMTP port: %d", cfg.SMTPPort)
|
||||
}
|
||||
|
||||
if cfg.SMTPUsername == "" {
|
||||
return fmt.Errorf("SMTP username is required")
|
||||
}
|
||||
|
||||
if cfg.SMTPPassword == "" {
|
||||
return fmt.Errorf("SMTP password is required")
|
||||
}
|
||||
|
||||
if cfg.FromEmail == "" {
|
||||
return fmt.Errorf("from email is required")
|
||||
}
|
||||
|
||||
if cfg.FrontendURL == "" {
|
||||
return fmt.Errorf("frontend URL is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createSMTPClient создает SMTP клиент с правильными настройками
|
||||
func createSMTPClient(cfg *config.Config) (*mail.Client, error) {
|
||||
opts := []mail.Option{
|
||||
mail.WithPort(cfg.SMTPPort),
|
||||
mail.WithSMTPAuth(mail.SMTPAuthPlain),
|
||||
mail.WithUsername(cfg.SMTPUsername),
|
||||
mail.WithPassword(cfg.SMTPPassword),
|
||||
}
|
||||
|
||||
// Настраиваем TLS в зависимости от порта
|
||||
switch cfg.SMTPPort {
|
||||
case 587:
|
||||
// STARTTLS для порта 587
|
||||
opts = append(opts, mail.WithTLSPolicy(mail.TLSMandatory))
|
||||
case 465:
|
||||
// SSL/TLS для порта 465
|
||||
opts = append(opts, mail.WithSSL())
|
||||
default:
|
||||
// Opportunistic TLS для других портов
|
||||
opts = append(opts, mail.WithTLSPolicy(mail.TLSOpportunistic))
|
||||
}
|
||||
|
||||
return mail.NewClient(cfg.SMTPHost, opts...)
|
||||
}
|
||||
|
||||
// loadTemplates загружает HTML шаблоны для писем
|
||||
func loadTemplates() (*template.Template, error) {
|
||||
tmpl := template.New("email")
|
||||
|
||||
templates := map[string]string{
|
||||
"verification": verificationTemplate,
|
||||
"password_reset": passwordResetTemplate,
|
||||
"newsletter": newsletterTemplate,
|
||||
}
|
||||
|
||||
for name, content := range templates {
|
||||
var err error
|
||||
tmpl, err = tmpl.New(name).Parse(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse template %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
// IsActive возвращает статус сервиса
|
||||
func (s *Service) IsActive() bool {
|
||||
return s.isActive
|
||||
}
|
||||
|
||||
// EmailData содержит данные для шаблонов писем
|
||||
type EmailData struct {
|
||||
UserName string
|
||||
AppName string
|
||||
FrontendURL string
|
||||
Token string
|
||||
Subject string
|
||||
Content string
|
||||
Year int
|
||||
}
|
||||
|
||||
// SendVerificationEmail отправляет email для подтверждения адреса
|
||||
func (s *Service) SendVerificationEmail(to, userName, token string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping verification email",
|
||||
zap.String("to", to), zap.String("user", userName))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending verification email", zap.String("to", to), zap.String("user", userName))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Token: token,
|
||||
Subject: "Подтверждение email",
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
return s.sendEmail(to, "Подтверждение email - Бегущий Башкир", "verification", data)
|
||||
}
|
||||
|
||||
// SendPasswordResetEmail отправляет email для сброса пароля
|
||||
func (s *Service) SendPasswordResetEmail(to, userName, token string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping password reset email",
|
||||
zap.String("to", to), zap.String("user", userName))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending password reset email", zap.String("to", to), zap.String("user", userName))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Token: token,
|
||||
Subject: "Восстановление пароля",
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
return s.sendEmail(to, "Восстановление пароля - Бегущий Башкир", "password_reset", data)
|
||||
}
|
||||
|
||||
// SendNewsletterEmail отправляет email рассылку
|
||||
func (s *Service) SendNewsletterEmail(to, userName, subject, content string) error {
|
||||
if !s.isActive {
|
||||
s.logger.Warn("Email service is disabled, skipping newsletter",
|
||||
zap.String("to", to), zap.String("user", userName), zap.String("subject", subject))
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info("Sending newsletter email",
|
||||
zap.String("to", to),
|
||||
zap.String("user", userName),
|
||||
zap.String("subject", subject))
|
||||
|
||||
data := EmailData{
|
||||
UserName: userName,
|
||||
AppName: "Бегущий Башкир",
|
||||
FrontendURL: s.config.FrontendURL,
|
||||
Subject: subject,
|
||||
Content: content,
|
||||
Year: time.Now().Year(),
|
||||
}
|
||||
|
||||
// Для новостей используем специальный шаблон
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, "newsletter", data); err != nil {
|
||||
s.logger.Error("Failed to execute newsletter template", zap.Error(err))
|
||||
return fmt.Errorf("failed to execute newsletter template: %w", err)
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(to); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject(subject)
|
||||
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
||||
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
s.logger.Error("Failed to send newsletter email", zap.Error(err))
|
||||
return fmt.Errorf("failed to send newsletter email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Newsletter email sent successfully", zap.String("to", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendEmail общий метод для отправки email
|
||||
func (s *Service) sendEmail(to, subject, templateName string, data EmailData) error {
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, templateName, data); err != nil {
|
||||
s.logger.Error("Failed to execute email template",
|
||||
zap.String("template", templateName),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to execute template %s: %w", templateName, err)
|
||||
}
|
||||
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(to); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject(subject)
|
||||
msg.SetBodyString(mail.TypeTextHTML, body.String())
|
||||
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
s.logger.Error("Failed to send email",
|
||||
zap.String("type", templateName),
|
||||
zap.String("to", to),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("Email sent successfully",
|
||||
zap.String("type", templateName),
|
||||
zap.String("to", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestConnection тестирует подключение к SMTP серверу
|
||||
func (s *Service) TestConnection() error {
|
||||
if !s.isActive {
|
||||
return fmt.Errorf("email service is disabled")
|
||||
}
|
||||
|
||||
// Создаем тестовое сообщение
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.From(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set from address: %w", err)
|
||||
}
|
||||
if err := msg.To(s.fromAddr); err != nil {
|
||||
return fmt.Errorf("failed to set to address: %w", err)
|
||||
}
|
||||
|
||||
msg.Subject("Тестовое письмо - Бегущий Башкир")
|
||||
msg.SetBodyString(mail.TypeTextPlain, "Это тестовое письмо для проверки подключения.")
|
||||
|
||||
// Пытаемся отправить тестовое письмо
|
||||
if err := s.client.DialAndSend(msg); err != nil {
|
||||
return fmt.Errorf("failed to send test email: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("SMTP connection test successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Шаблоны писем остаются без изменений...
|
||||
const verificationTemplate = `
|
||||
{{define "verification"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Подтверждение email адреса</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<p>Благодарим за регистрацию в приложении "Бегущий Башкир". Для завершения регистрации подтвердите ваш email адрес.</p>
|
||||
|
||||
<a href="{{.FrontendURL}}/verify-email?token={{.Token}}" class="cta-button">
|
||||
Подтвердить Email
|
||||
</a>
|
||||
|
||||
<p>Или скопируйте эту ссылку в браузер:</p>
|
||||
<div class="token">{{.FrontendURL}}/verify-email?token={{.Token}}</div>
|
||||
|
||||
<p>Ссылка действительна в течение 24 часов.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
const passwordResetTemplate = `
|
||||
{{define "password_reset"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #dc3545, #e35d6a); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #dc3545; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
.token { background: #e9ecef; padding: 10px; border-radius: 5px; font-family: monospace; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Восстановление пароля</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<p>Мы получили запрос на восстановление пароля для вашего аккаунта.</p>
|
||||
|
||||
<a href="{{.FrontendURL}}/reset-password?token={{.Token}}" class="cta-button">
|
||||
Восстановить пароль
|
||||
</a>
|
||||
|
||||
<p>Или скопируйте эту ссылку в браузер:</p>
|
||||
<div class="token">{{.FrontendURL}}/reset-password?token={{.Token}}</div>
|
||||
|
||||
<p>Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо.</p>
|
||||
<p>Ссылка действительна в течение 1 часа.</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
|
||||
const newsletterTemplate = `
|
||||
{{define "newsletter"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #2e8b57, #3cb371); color: white; padding: 2rem; text-align: center; border-radius: 10px 10px 0 0; }
|
||||
.content { padding: 2rem; background: #f8f9fa; }
|
||||
.footer { padding: 1rem; text-align: center; color: #666; font-size: 0.9rem; }
|
||||
.newsletter-content { line-height: 1.8; }
|
||||
.cta-button { display: inline-block; padding: 12px 24px; background: #2e8b57; color: white; text-decoration: none; border-radius: 5px; margin: 1rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🏃 Бегущий Башкир</h1>
|
||||
<p>Новости и обновления</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Привет, {{.UserName}}!</h2>
|
||||
<div class="newsletter-content">
|
||||
{{.Content}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© {{.Year}} Бегущий Башкир. Все права защищены.</p>
|
||||
<p><a href="{{.FrontendURL}}/unsubscribe" style="color: #666;">Отписаться от рассылки</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
`
|
||||
@@ -0,0 +1,35 @@
|
||||
// pkg/logger/helpers.go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LogApplicationStart логирует запуск приложения
|
||||
func LogApplicationStart(version, environment, port string) {
|
||||
Get().Info("application starting",
|
||||
zap.String("version", version),
|
||||
zap.String("environment", environment),
|
||||
zap.String("port", port),
|
||||
zap.Time("start_time", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
// LogApplicationShutdown логирует graceful shutdown
|
||||
func LogApplicationShutdown(reason string) {
|
||||
Get().Info("application shutting down",
|
||||
zap.String("reason", reason),
|
||||
zap.Time("shutdown_time", time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
// LogDatabaseStats логирует статистику базы данных
|
||||
func LogDatabaseStats(stats map[string]interface{}) {
|
||||
fields := make([]zap.Field, 0, len(stats))
|
||||
for key, value := range stats {
|
||||
fields = append(fields, zap.Any(key, value))
|
||||
}
|
||||
Get().Info("database statistics", fields...)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// pkg/logger/interface.go
|
||||
package logger
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// LoggerInterface определяет контракт для логгера
|
||||
type LoggerInterface interface {
|
||||
Debug(msg string, fields ...zap.Field)
|
||||
Info(msg string, fields ...zap.Field)
|
||||
Warn(msg string, fields ...zap.Field)
|
||||
Error(msg string, fields ...zap.Field)
|
||||
Fatal(msg string, fields ...zap.Field)
|
||||
|
||||
Debugf(template string, args ...interface{})
|
||||
Infof(template string, args ...interface{})
|
||||
Warnf(template string, args ...interface{})
|
||||
Errorf(template string, args ...interface{})
|
||||
Fatalf(template string, args ...interface{})
|
||||
|
||||
With(fields ...zap.Field) LoggerInterface
|
||||
}
|
||||
|
||||
// wrapper обертка для zap.Logger
|
||||
type wrapper struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewWrapper создает новую обертку
|
||||
func NewWrapper(logger *zap.Logger) LoggerInterface {
|
||||
return &wrapper{logger: logger}
|
||||
}
|
||||
|
||||
func (w *wrapper) Debug(msg string, fields ...zap.Field) {
|
||||
w.logger.Debug(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Info(msg string, fields ...zap.Field) {
|
||||
w.logger.Info(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Warn(msg string, fields ...zap.Field) {
|
||||
w.logger.Warn(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Error(msg string, fields ...zap.Field) {
|
||||
w.logger.Error(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Fatal(msg string, fields ...zap.Field) {
|
||||
w.logger.Fatal(msg, fields...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Debugf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Debugf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Infof(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Infof(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Warnf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Warnf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Errorf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Errorf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) Fatalf(template string, args ...interface{}) {
|
||||
w.logger.Sugar().Fatalf(template, args...)
|
||||
}
|
||||
|
||||
func (w *wrapper) With(fields ...zap.Field) LoggerInterface {
|
||||
return &wrapper{logger: w.logger.With(fields...)}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// pkg/logger/logger.go
|
||||
package logger
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
)
|
||||
|
||||
var globalLogger *zap.Logger
|
||||
|
||||
// Init инициализирует глобальный логгер
|
||||
func Init(level string, environment string) error {
|
||||
var config zap.Config
|
||||
|
||||
if environment == "production" {
|
||||
config = zap.NewProductionConfig()
|
||||
} else {
|
||||
config = zap.NewDevelopmentConfig()
|
||||
config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
}
|
||||
|
||||
// Устанавливаем уровень логирования
|
||||
switch level {
|
||||
case "debug":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||
case "info":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
case "warn":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.WarnLevel)
|
||||
case "error":
|
||||
config.Level = zap.NewAtomicLevelAt(zap.ErrorLevel)
|
||||
default:
|
||||
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
|
||||
}
|
||||
|
||||
logger, err := config.Build()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
globalLogger = logger
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get возвращает глобальный логгер
|
||||
func Get() *zap.Logger {
|
||||
if globalLogger == nil {
|
||||
// Fallback на стандартный логгер если не инициализирован
|
||||
logger, _ := zap.NewProduction()
|
||||
return logger
|
||||
}
|
||||
return globalLogger
|
||||
}
|
||||
|
||||
// Sync синхронизирует буферы логгера
|
||||
func Sync() {
|
||||
if globalLogger != nil {
|
||||
globalLogger.Sync()
|
||||
}
|
||||
}
|
||||
|
||||
// Sugar возвращает SugaredLogger
|
||||
func Sugar() *zap.SugaredLogger {
|
||||
return Get().Sugar()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type RouteLogger struct {
|
||||
logger LoggerInterface
|
||||
}
|
||||
|
||||
func NewRouteLogger(log LoggerInterface) *RouteLogger {
|
||||
return &RouteLogger{
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) LogRoutes(router *chi.Mux) {
|
||||
routes := rl.extractRoutes(router)
|
||||
rl.printFormattedRoutes(routes)
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) extractRoutes(router *chi.Mux) []RouteInfo {
|
||||
var routes []RouteInfo
|
||||
|
||||
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
|
||||
if route != "" {
|
||||
routes = append(routes, RouteInfo{
|
||||
Method: method,
|
||||
Path: route,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := chi.Walk(router, walkFunc); err != nil {
|
||||
rl.logger.Error("Failed to walk routes", zap.Error(err))
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
func (rl *RouteLogger) printFormattedRoutes(routes []RouteInfo) {
|
||||
if len(routes) == 0 {
|
||||
rl.logger.Info("No routes found")
|
||||
return
|
||||
}
|
||||
|
||||
// Группируем по пути
|
||||
routesByPath := make(map[string][]string)
|
||||
for _, route := range routes {
|
||||
routesByPath[route.Path] = append(routesByPath[route.Path], route.Method)
|
||||
}
|
||||
|
||||
// Сортируем пути
|
||||
var paths []string
|
||||
for path := range routesByPath {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
|
||||
rl.logger.Info("📋 Registered API Routes:")
|
||||
rl.logger.Info("┌──────────────────────────────────────────────────────────────┐")
|
||||
|
||||
for _, path := range paths {
|
||||
methods := routesByPath[path]
|
||||
sort.Strings(methods)
|
||||
methodsStr := strings.Join(methods, ", ")
|
||||
|
||||
if len(methodsStr) > 12 {
|
||||
methodsStr = methodsStr[:9] + "..."
|
||||
}
|
||||
|
||||
methodField := methodsStr
|
||||
if len(methodField) < 12 {
|
||||
methodField = methodField + strings.Repeat(" ", 12-len(methodField))
|
||||
}
|
||||
|
||||
pathField := path
|
||||
if len(pathField) > 45 {
|
||||
pathField = pathField[:42] + "..."
|
||||
} else {
|
||||
pathField = pathField + strings.Repeat(" ", 45-len(pathField))
|
||||
}
|
||||
|
||||
rl.logger.Info("│ " + methodField + " " + pathField + " │")
|
||||
}
|
||||
|
||||
rl.logger.Info("└──────────────────────────────────────────────────────────────┘")
|
||||
rl.logger.Info("Total routes registered: %d", zap.Int("count", len(routes)))
|
||||
}
|
||||
|
||||
type RouteInfo struct {
|
||||
Method string
|
||||
Path string
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// pkg/middleware/admin_middleware.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"api_bb/pkg/logger"
|
||||
"api_bb/pkg/utils"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AdminMiddleware проверяет, что пользователь имеет роль администратора
|
||||
func AdminMiddleware(next http.Handler) http.Handler {
|
||||
logger := logger.NewWrapper(logger.Get().With(zap.String("middleware", "admin")))
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger.Info("admin middleware check",
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
)
|
||||
|
||||
// Получаем пользователя из контекста
|
||||
user, ok := GetUserFromContext(r.Context())
|
||||
if !ok {
|
||||
logger.Warn("admin middleware failed - user not found in context")
|
||||
utils.RespondWithError(w, http.StatusUnauthorized, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем роль пользователя
|
||||
if user.Role != "admin" {
|
||||
logger.Warn("admin middleware failed - insufficient permissions",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_role", user.Role),
|
||||
zap.String("required_role", "admin"),
|
||||
)
|
||||
utils.RespondWithError(w, http.StatusForbidden, "Insufficient permissions: admin role required")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("admin middleware passed",
|
||||
zap.Uint("user_id", user.ID),
|
||||
zap.String("user_email", user.Email),
|
||||
)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// middleware/auth.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/internal/repository"
|
||||
"api_bb/internal/service"
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "userID"
|
||||
UserKey contextKey = "user"
|
||||
)
|
||||
|
||||
func AuthMiddleware(jwtService service.JWTService, userRepo repository.UserRepository) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var tokenString string
|
||||
logger := logger.Get()
|
||||
logger.Debug("authMiddleware Start")
|
||||
|
||||
// Пробуем получить токен из заголовка Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
logger.Debug("Token found in Authorization header")
|
||||
}
|
||||
|
||||
// Если нет в заголовке, пробуем из куки
|
||||
if tokenString == "" {
|
||||
cookie, err := r.Cookie("auth_token")
|
||||
if err == nil {
|
||||
tokenString = cookie.Value
|
||||
logger.Debug("Token found in auth_token cookie")
|
||||
} else {
|
||||
logger.Debug("No auth_token cookie found", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
logger.Debug("No token found in request")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwtService.ValidateToken(tokenString)
|
||||
if err != nil || !token.Valid {
|
||||
logger.Warn("Invalid token",
|
||||
zap.Error(err),
|
||||
zap.Bool("token_valid", token != nil && token.Valid))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := jwtService.ExtractUserID(token)
|
||||
if err != nil {
|
||||
logger.Error("Failed to extract user ID from token",
|
||||
zap.Error(err))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", userID))
|
||||
|
||||
user, err := userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
logger.Error("Failed to find user by ID",
|
||||
zap.Any("user_id", userID),
|
||||
zap.Error(err))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Добавляем пользователя в контекст
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||
ctx = context.WithValue(ctx, UserKey, user)
|
||||
|
||||
logger.Debug("User authenticated successfully",
|
||||
zap.Any("user_id", userID),
|
||||
zap.String("username", user.FirstName))
|
||||
|
||||
|
||||
logger.Debug("authMiddleware End")
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth middleware требует аутентификации
|
||||
func RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
logger := logger.Get()
|
||||
userID := r.Context().Value(UserIDKey)
|
||||
logger.Debug("RequireAuth method start")
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", userID))
|
||||
|
||||
if userID == nil {
|
||||
logger.Warn("Authentication required but no user ID in context")
|
||||
http.Error(w, `{"error": "Authentication required"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("User authenticated", zap.Any("user_id", userID))
|
||||
logger.Debug("authMiddleware End")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserFromContext получает пользователя из контекста
|
||||
func GetUserFromContext(ctx context.Context) (*models.User, bool) {
|
||||
logger := logger.Get()
|
||||
user, ok := ctx.Value(UserKey).(*models.User)
|
||||
logger.Debug("GetUserFromContext method")
|
||||
logger.Debug("Extracted user ID from token",
|
||||
zap.Any("user_id", user.ID))
|
||||
|
||||
if !ok {
|
||||
logger.Debug("No user found in context")
|
||||
} else {
|
||||
logger.Debug("User retrieved from context",
|
||||
zap.Any("user_id", user.ID),
|
||||
zap.String("username", user.FirstName))
|
||||
}
|
||||
|
||||
return user, ok
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
// pkg/middleware/cors.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CORS() func(http.Handler) http.Handler {
|
||||
return cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"http://localhost:3001", "https://begushiybashkir.ru"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
||||
ExposedHeaders: []string{"Link", "Content-Length"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// pkg/middleware/logger.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"api_bb/pkg/logger"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Logger middleware для логирования HTTP запросов
|
||||
func ZapLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Получаем request ID
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
|
||||
// Создаем логгер с контекстом запроса
|
||||
requestLogger := logger.Get().With(
|
||||
zap.String("method", r.Method),
|
||||
zap.String("path", r.URL.Path),
|
||||
zap.String("remote_addr", r.RemoteAddr),
|
||||
zap.String("user_agent", r.UserAgent()),
|
||||
zap.String("request_id", reqID),
|
||||
)
|
||||
|
||||
// Обертываем ResponseWriter для получения статуса
|
||||
wrappedWriter := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
// Обрабатываем запрос
|
||||
next.ServeHTTP(wrappedWriter, r)
|
||||
|
||||
// Логируем результат
|
||||
duration := time.Since(start)
|
||||
|
||||
requestLogger.Info("request completed",
|
||||
zap.Int("status", wrappedWriter.Status()),
|
||||
zap.Int("bytes", wrappedWriter.BytesWritten()),
|
||||
zap.Duration("duration", duration),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CommonMiddleware() []func(http.Handler) http.Handler {
|
||||
return []func(http.Handler) http.Handler{
|
||||
HandleOptions,
|
||||
CORS(),
|
||||
ZapLogger,
|
||||
middleware.Recoverer,
|
||||
middleware.RequestID,
|
||||
cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{
|
||||
"https://xn--80abahjtcfl5d0a8di.xn--p1ai",
|
||||
"https://begushiybashkir.ru",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3001",
|
||||
"http://localhost:5173"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
||||
ExposedHeaders: []string{
|
||||
"Link",
|
||||
"Content-Length",
|
||||
"Set-Cookie",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// pkg/middleware/options.go
|
||||
package middleware
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HandleOptions автоматически обрабатывает OPTIONS запросы
|
||||
func HandleOptions(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package utils
|
||||
|
||||
// formatPace форматирует темп в строку "MM:SS"
|
||||
func FormatPace(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
|
||||
// formatTwoDigits форматирует число в двузначную строку
|
||||
func FormatTwoDigits(num int) string {
|
||||
if num < 10 {
|
||||
return "0" + string(rune(num+'0'))
|
||||
}
|
||||
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
|
||||
}
|
||||
|
||||
// formatTime форматирует время в строку "MM:SS"
|
||||
func FormatTime(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// pkg/utils/response.go (дополнение)
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RespondWithValidationError отправляет ответ с ошибками валидации
|
||||
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": "Validation failed",
|
||||
"details": GetValidationErrors(validationError),
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
|
||||
RespondWithJSON(w, statusCode, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// DecodeJSONBody декодирует JSON тело запроса
|
||||
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
return errors.New("Content-Type header is not application/json")
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
err := dec.Decode(dst)
|
||||
if err != nil {
|
||||
var syntaxError *json.SyntaxError
|
||||
var unmarshalTypeError *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("request body contains badly-formed JSON")
|
||||
|
||||
case errors.As(err, &unmarshalTypeError):
|
||||
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
|
||||
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
return fmt.Errorf("request body contains unknown field %s", fieldName)
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("request body must not be empty")
|
||||
|
||||
case err.Error() == "http: request body too large":
|
||||
return errors.New("request body must not be larger than 1MB")
|
||||
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = dec.Decode(&struct{}{})
|
||||
if err != io.EOF {
|
||||
return errors.New("request body must only contain a single JSON object")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserIDFromContext извлекает userID из контекста
|
||||
func GetUserIDFromContext(r *http.Request) (uint, bool) {
|
||||
userID, ok := r.Context().Value("userID").(uint)
|
||||
return userID, ok
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// pkg/utils/validation.go
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ValidationError представляет ошибку валидации
|
||||
type ValidationError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ValidationResult содержит результат валидации
|
||||
type ValidationResult struct {
|
||||
IsValid bool
|
||||
Errors []ValidationError
|
||||
}
|
||||
|
||||
// TagOptions содержит опции из тега validate
|
||||
type TagOptions struct {
|
||||
Required bool
|
||||
Min *float64
|
||||
Max *float64
|
||||
MinInt *int64
|
||||
MaxInt *int64
|
||||
OneOf []string
|
||||
Email bool
|
||||
MaxLength *int
|
||||
MinLength *int
|
||||
Custom string
|
||||
}
|
||||
|
||||
// ValidateStruct валидирует структуру на основе тегов validate
|
||||
func ValidateStruct(s interface{}) error {
|
||||
val := reflect.ValueOf(s)
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("ValidateStruct expects a struct, got %T", s)
|
||||
}
|
||||
|
||||
var errors []ValidationError
|
||||
typ := val.Type()
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
// Пропускаем неэкспортируемые поля
|
||||
if !field.CanInterface() {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := fieldType.Tag.Get("validate")
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
options := parseTagOptions(tag)
|
||||
fieldName := getFieldName(fieldType)
|
||||
|
||||
// Валидация поля
|
||||
if err := validateField(field, fieldName, options); err != nil {
|
||||
errors = append(errors, err...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return &ValidationResult{
|
||||
IsValid: false,
|
||||
Errors: errors,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTagOptions парсит тег validate и возвращает опции
|
||||
func parseTagOptions(tag string) TagOptions {
|
||||
options := TagOptions{}
|
||||
parts := strings.Split(tag, ",")
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
|
||||
switch {
|
||||
case part == "required":
|
||||
options.Required = true
|
||||
case part == "email":
|
||||
options.Email = true
|
||||
case strings.HasPrefix(part, "min="):
|
||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
||||
options.Min = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "max="):
|
||||
if val, err := strconv.ParseFloat(part[4:], 64); err == nil {
|
||||
options.Max = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "minint="):
|
||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
||||
options.MinInt = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "maxint="):
|
||||
if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil {
|
||||
options.MaxInt = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "oneof="):
|
||||
options.OneOf = strings.Split(part[6:], " ")
|
||||
case strings.HasPrefix(part, "maxlen="):
|
||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
||||
options.MaxLength = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "minlen="):
|
||||
if val, err := strconv.Atoi(part[7:]); err == nil {
|
||||
options.MinLength = &val
|
||||
}
|
||||
case strings.HasPrefix(part, "custom="):
|
||||
options.Custom = part[7:]
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// getFieldName возвращает имя поля для сообщений об ошибках
|
||||
func getFieldName(field reflect.StructField) string {
|
||||
jsonTag := field.Tag.Get("json")
|
||||
if jsonTag != "" {
|
||||
parts := strings.Split(jsonTag, ",")
|
||||
if parts[0] != "" {
|
||||
return parts[0]
|
||||
}
|
||||
}
|
||||
return field.Name
|
||||
}
|
||||
|
||||
// validateField валидирует отдельное поле
|
||||
func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка required
|
||||
if options.Required {
|
||||
if isEmptyValue(field) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "field is required",
|
||||
})
|
||||
return errors // Если поле обязательно и пустое, дальше не проверяем
|
||||
}
|
||||
}
|
||||
|
||||
// Если поле пустое и не обязательное, дальше не проверяем
|
||||
if isEmptyValue(field) {
|
||||
return errors
|
||||
}
|
||||
|
||||
// Валидация в зависимости от типа поля
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
errors = append(errors, validateString(field.String(), fieldName, options)...)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
errors = append(errors, validateInt(field.Int(), fieldName, options)...)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
errors = append(errors, validateFloat(field.Float(), fieldName, options)...)
|
||||
case reflect.Struct:
|
||||
// Для time.Time и других структур
|
||||
if field.Type().String() == "time.Time" {
|
||||
errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateString валидирует строковые поля
|
||||
func validateString(value, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка email
|
||||
if options.Email {
|
||||
if !isValidEmail(value) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "invalid email format",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка длины строки
|
||||
if options.MinLength != nil && len(value) < *options.MinLength {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength),
|
||||
})
|
||||
}
|
||||
|
||||
if options.MaxLength != nil && len(value) > *options.MaxLength {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength),
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка oneof
|
||||
if len(options.OneOf) > 0 {
|
||||
valid := false
|
||||
for _, allowed := range options.OneOf {
|
||||
if value == allowed {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateInt валидирует целочисленные поля
|
||||
func validateInt(value int64, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
if options.MinInt != nil && value < *options.MinInt {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum value is %d", *options.MinInt),
|
||||
})
|
||||
}
|
||||
|
||||
if options.MaxInt != nil && value > *options.MaxInt {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum value is %d", *options.MaxInt),
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateFloat валидирует поля с плавающей точкой
|
||||
func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
if options.Min != nil && value < *options.Min {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("minimum value is %.2f", *options.Min),
|
||||
})
|
||||
}
|
||||
|
||||
if options.Max != nil && value > *options.Max {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: fmt.Sprintf("maximum value is %.2f", *options.Max),
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// validateTime валидирует временные поля
|
||||
func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Проверка, что дата не нулевая
|
||||
if value.IsZero() && options.Required {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "date is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Проверка, что дата не в будущем (пример кастомной валидации)
|
||||
if options.Custom == "not_future" && value.After(time.Now()) {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Message: "date cannot be in the future",
|
||||
})
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// isEmptyValue проверяет, является ли значение пустым
|
||||
func isEmptyValue(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.String() == ""
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
case reflect.Struct:
|
||||
if v.Type().String() == "time.Time" {
|
||||
return v.Interface().(time.Time).IsZero()
|
||||
}
|
||||
case reflect.Ptr, reflect.Interface:
|
||||
return v.IsNil()
|
||||
case reflect.Slice, reflect.Map, reflect.Array:
|
||||
return v.Len() == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidEmail проверяет валидность email
|
||||
func isValidEmail(email string) bool {
|
||||
emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
|
||||
matched, _ := regexp.MatchString(emailRegex, email)
|
||||
return matched
|
||||
}
|
||||
|
||||
// Error возвращает строковое представление ошибок валидации
|
||||
func (vr *ValidationResult) Error() string {
|
||||
var errorMessages []string
|
||||
for _, err := range vr.Errors {
|
||||
errorMessages = append(errorMessages, err.Error())
|
||||
}
|
||||
return strings.Join(errorMessages, "; ")
|
||||
}
|
||||
|
||||
// GetValidationErrors возвращает ошибки валидации в структурированном виде
|
||||
func GetValidationErrors(err error) []ValidationError {
|
||||
if vr, ok := err.(*ValidationResult); ok {
|
||||
return vr.Errors
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogValidationErrors логирует ошибки валидации
|
||||
func LogValidationErrors(logger *zap.Logger, err error, context string) {
|
||||
if vr, ok := err.(*ValidationResult); ok {
|
||||
for _, validationErr := range vr.Errors {
|
||||
logger.Warn("validation error",
|
||||
zap.String("context", context),
|
||||
zap.String("field", validationErr.Field),
|
||||
zap.String("error", validationErr.Message),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ParseUintFromQuery парсит uint из query параметра
|
||||
func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) {
|
||||
if queryParam == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
value, err := strconv.ParseUint(queryParam, 10, 32)
|
||||
if err != nil {
|
||||
return defaultValue, err
|
||||
}
|
||||
|
||||
return uint(value), nil
|
||||
}
|
||||
|
||||
// ParseIntFromQuery парсит int из query параметра
|
||||
func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) {
|
||||
if queryParam == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(queryParam)
|
||||
if err != nil {
|
||||
return defaultValue, err
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// ParseBoolFromQuery парсит bool из query параметра
|
||||
func ParseBoolFromQuery(queryParam string, defaultValue bool) bool {
|
||||
if queryParam == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return strings.ToLower(queryParam) == "true" || queryParam == "1"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
go-rest-api/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go
|
||||
│ ├── handlers/
|
||||
│ │ ├── health.go
|
||||
│ │ ├── auth.go
|
||||
│ │ └── handlers.go
|
||||
│ ├── models/
|
||||
│ │ └── user.go
|
||||
│ ├── repository/
|
||||
│ │ └── user_repository.go
|
||||
│ ├── service/
|
||||
│ │ └── auth_service.go
|
||||
│ └── routes/
|
||||
│ └── routes.go
|
||||
├── pkg/
|
||||
│ ├── database/
|
||||
│ │ └── database.go
|
||||
│ └── middleware/
|
||||
│ └── middleware.go
|
||||
├── go.mod
|
||||
└── go.sum
|
||||
@@ -0,0 +1,7 @@
|
||||
# DB environment variabels
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=mydb
|
||||
APP_PORT=8080
|
||||
@@ -0,0 +1,33 @@
|
||||
# Билд стадия
|
||||
FROM golang:1.25.1-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Собираем приложение
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api
|
||||
|
||||
# Финальная стадия
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Копируем бинарник из builder стадии
|
||||
COPY --from=builder /app/main .
|
||||
|
||||
# Копируем миграции
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
|
||||
# Экспозим порт
|
||||
EXPOSE 8080
|
||||
|
||||
# Запускаем приложение
|
||||
CMD ["./main"]
|
||||
@@ -0,0 +1,35 @@
|
||||
.PHONY: build run test clean migrate
|
||||
|
||||
# Переменные
|
||||
APP_NAME=serv_golang_rest_api
|
||||
DOCKER_COMPOSE=docker compose
|
||||
|
||||
# Сборка и запуск
|
||||
build:
|
||||
$(DOCKER_COMPOSE) build
|
||||
|
||||
up:
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
|
||||
down:
|
||||
$(DOCKER_COMPOSE) down
|
||||
|
||||
logs:
|
||||
$(DOCKER_COMPOSE) logs -f api
|
||||
|
||||
# Разработка
|
||||
dev:
|
||||
$(DOCKER_COMPOSE) up db -d
|
||||
go run ./cmd/api
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# Миграции
|
||||
migrate:
|
||||
$(DOCKER_COMPOSE) exec api ./main migrate
|
||||
|
||||
# Очистка
|
||||
clean:
|
||||
$(DOCKER_COMPOSE) down -v
|
||||
docker system prune -f
|
||||
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"api_tp/internal/config"
|
||||
"api_tp/internal/server"
|
||||
"api_tp/pkg/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Загрузка конфигурации
|
||||
cfg := config.Load()
|
||||
|
||||
// Подключение к БД
|
||||
db, err := database.NewPostgresConnection(cfg)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
// Создание и запуск сервера
|
||||
srv := server.New(db)
|
||||
|
||||
log.Printf("Server starting on port %s", cfg.AppPort)
|
||||
if err := srv.Run(cfg.AppPort); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module api_tp
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
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/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
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=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
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/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,30 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
AppPort string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", "postgres"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "postgres"),
|
||||
DBName: getEnv("DB_NAME", "mydb"),
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// config/oauth.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"golang.org/x/oauth2/yandex"
|
||||
"golang.org/x/oauth2/vk"
|
||||
)
|
||||
|
||||
|
||||
var (
|
||||
GoogleOAuthConfig = &oauth2.Config{
|
||||
ClientID: "your-google-client-id",
|
||||
ClientSecret: "your-google-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/google/callback",
|
||||
Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"},
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
|
||||
YandexOAuthConfig = &oauth2.Config{
|
||||
ClientID: "your-yandex-client-id",
|
||||
ClientSecret: "your-yandex-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/yandex/callback",
|
||||
Scopes: []string{"login:email", "login:info", "login:avatar"},
|
||||
Endpoint: yandex.Endpoint,
|
||||
}
|
||||
|
||||
VKOAuthConfig = &oauth2.Config{
|
||||
ClientID: "your-vk-client-id",
|
||||
ClientSecret: "your-vk-client-secret",
|
||||
RedirectURL: "http://localhost:8080/auth/vk/callback",
|
||||
Scopes: []string{"email", "photos"},
|
||||
Endpoint: vk.Endpoint,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
// handlers/auth.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"api_tp/internal/models"
|
||||
"api_tp/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req RegisterRequest
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
var existingUser models.User
|
||||
if err := h.DB.Where("email = ?", req.Email).First(&existingUser).Error; err == nil {
|
||||
utils.WriteError(w, http.StatusConflict, "User already exists")
|
||||
return
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error creating user")
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем пользователя
|
||||
user := models.User{
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Name: req.Name,
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error creating user")
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем JWT токен
|
||||
token, err := utils.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := utils.DecodeJSON(r, &req); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// Ищем пользователя
|
||||
var user models.User
|
||||
if err := h.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем пароль
|
||||
if !utils.CheckPasswordHash(req.Password, user.Password) {
|
||||
utils.WriteError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем JWT токен
|
||||
token, err := utils.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
|
||||
return
|
||||
}
|
||||
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
func CommonMiddleware() []func(http.Handler) http.Handler {
|
||||
return []func(http.Handler) http.Handler{
|
||||
middleware.Logger,
|
||||
middleware.Recoverer,
|
||||
middleware.Timeout(60 * time.Second),
|
||||
cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"https://*", "http://*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: false,
|
||||
MaxAge: 300,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// handlers/oauth.go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"api_tp/internal/config"
|
||||
"api_tp/internal/models"
|
||||
"api_tp/internal/utils"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OAuthHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
type GoogleUserInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
|
||||
func (h *OAuthHandler) GoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
url := config.GoogleOAuthConfig.AuthCodeURL("state")
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (h *OAuthHandler) GoogleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
token, err := config.GoogleOAuthConfig.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to exchange token")
|
||||
return
|
||||
}
|
||||
|
||||
client := config.GoogleOAuthConfig.Client(r.Context(), token)
|
||||
resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to get user info")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var userInfo GoogleUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to decode user info")
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем или находим пользователя
|
||||
user, err := h.findOrCreateOAuthUser("google", userInfo.ID, userInfo.Email, userInfo.Name, token)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error processing user")
|
||||
return
|
||||
}
|
||||
|
||||
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error generating token")
|
||||
return
|
||||
}
|
||||
|
||||
// Редирект или возврат токена
|
||||
utils.WriteJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": jwtToken,
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// Аналогичные методы для Yandex и VK...
|
||||
|
||||
func (h *OAuthHandler) findOrCreateOAuthUser(provider, providerID, email, name string, token *oauth2.Token) (*models.User, error) {
|
||||
var oauthProvider models.OAuthProvider
|
||||
|
||||
err := h.DB.Where("provider = ? AND provider_id = ?", provider, providerID).
|
||||
Preload("User").
|
||||
First(&oauthProvider).Error
|
||||
|
||||
if err == nil {
|
||||
// Обновляем токены существующей привязки
|
||||
oauthProvider.AccessToken = token.AccessToken
|
||||
oauthProvider.RefreshToken = token.RefreshToken
|
||||
oauthProvider.ExpiresAt = token.Expiry
|
||||
if err := h.DB.Save(&oauthProvider).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.DB.First(&user, oauthProvider.UserID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// Ищем пользователя по email
|
||||
var user models.User
|
||||
err = h.DB.Where("email = ?", email).First(&user).Error
|
||||
|
||||
if err != nil {
|
||||
// Создаем нового пользователя
|
||||
user = models.User{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Password: utils.GenerateRandomPassword(),
|
||||
}
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новую привязку OAuth с токенами
|
||||
oauthProvider = models.OAuthProvider{
|
||||
UserID: user.ID,
|
||||
Provider: provider,
|
||||
ProviderID: providerID,
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
ExpiresAt: token.Expiry,
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&oauthProvider).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"api_tp/internal/config"
|
||||
"api_tp/internal/utils"
|
||||
)
|
||||
|
||||
// VKUserInfo представляет данные пользователя от VK
|
||||
type VKUserInfo struct {
|
||||
Response []struct {
|
||||
ID int `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Photo string `json:"photo_200"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
// VKEmailResponse представляет ответ с email от VK
|
||||
type VKEmailResponse struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// VKLogin initiates VK OAuth flow
|
||||
func (h *OAuthHandler) VKLogin(w http.ResponseWriter, r *http.Request) {
|
||||
url := config.VKOAuthConfig.AuthCodeURL("state")
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// VKCallback handles VK OAuth callback
|
||||
func (h *OAuthHandler) VKCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
|
||||
token, err := config.VKOAuthConfig.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to exchange token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// VK не возвращает email в основном токене, нужно получить его отдельно
|
||||
email, err := h.getVKEmail(token.AccessToken)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to get email from VK: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
client := config.VKOAuthConfig.Client(r.Context(), token)
|
||||
|
||||
// Получаем основную информацию о пользователе
|
||||
userInfoURL := fmt.Sprintf("https://api.vk.com/method/users.get?fields=photo_200,email&v=5.131&access_token=%s", token.AccessToken)
|
||||
resp, err := client.Get(userInfoURL)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to get user info: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var vkUserInfo VKUserInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vkUserInfo); err != nil {
|
||||
utils.WriteError(w, http.StatusBadRequest, "Failed to decode user info: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if len(vkUserInfo.Response) == 0 {
|
||||
utils.WriteError(w, http.StatusBadRequest, "No user data received from VK")
|
||||
return
|
||||
}
|
||||
|
||||
vkUser := vkUserInfo.Response[0]
|
||||
userID := fmt.Sprintf("%d", vkUser.ID)
|
||||
name := vkUser.FirstName + " " + vkUser.LastName
|
||||
|
||||
// Используем email из отдельного запроса
|
||||
if email == "" && vkUser.Email != "" {
|
||||
email = vkUser.Email
|
||||
}
|
||||
|
||||
// Если email все еще пустой, создаем временный
|
||||
if email == "" {
|
||||
email = fmt.Sprintf("vk_%s@temp.vk", userID)
|
||||
}
|
||||
|
||||
// Создаем или находим пользователя
|
||||
user, err := h.findOrCreateOAuthUser("vk", userID, email, name, token)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error processing user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
jwtToken, err := utils.GenerateJWT(user.ID, user.Email)
|
||||
if err != nil {
|
||||
utils.WriteError(w, http.StatusInternalServerError, "Error generating token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.handleOAuthSuccess(w, r, jwtToken, user)
|
||||
}
|
||||
|
||||
// getVKEmail получает email из VK OAuth
|
||||
func (h *OAuthHandler) getVKEmail(accessToken string) (string, error) {
|
||||
// VK возвращает email в ответе на запрос токена, но если его нет,
|
||||
// можно попробовать получить через API
|
||||
emailURL := fmt.Sprintf("https://api.vk.com/method/account.getProfileInfo?v=5.131&access_token=%s", accessToken)
|
||||
|
||||
resp, err := http.Get(emailURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var emailResp struct {
|
||||
Response struct {
|
||||
Email string `json:"email"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&emailResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return emailResp.Response.Email, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user