diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..4e50596
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,42 @@
+# Database Configuration
+DB_NAME=volontery_db
+DB_USER=volontery
+DB_PASSWORD=change_me_in_production
+DB_HOST=localhost
+DB_PORT=5432
+DB_AUTH_METHOD=scram-sha-256
+DB_SSLMODE=disable
+
+# Database Connection String
+DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}
+
+# Application Configuration
+APP_ENV=development
+APP_PORT=8080
+APP_HOST=0.0.0.0
+
+# JWT Configuration
+JWT_SECRET=change_me_to_secure_random_string_min_32_chars
+JWT_ACCESS_TOKEN_EXPIRY=15m
+JWT_REFRESH_TOKEN_EXPIRY=7d
+
+# OpenStreetMap Nominatim API
+OSM_NOMINATIM_URL=https://nominatim.openstreetmap.org
+OSM_USER_AGENT=VolonteryPlatform/1.0
+
+# Matching Algorithm Configuration
+MATCHING_DEFAULT_RADIUS_METERS=10000
+MATCHING_DEFAULT_LIMIT=20
+
+# Logging
+LOG_LEVEL=info
+LOG_FORMAT=json
+
+# CORS Configuration
+CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
+CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,PATCH,OPTIONS
+CORS_ALLOWED_HEADERS=Content-Type,Authorization
+
+# Rate Limiting
+RATE_LIMIT_REQUESTS_PER_MINUTE=60
+RATE_LIMIT_BURST=10
diff --git a/.gitignore b/.gitignore
index 5b90e79..5e3492c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ go.work.sum
# env file
.env
+.idea
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/backend.iml b/.idea/backend.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/backend.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..5237a32
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ postgresql
+ true
+ org.postgresql.Driver
+ jdbc:postgresql://localhost:5432/volontery_db
+
+
+
+
+
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
new file mode 100644
index 0000000..2948fec
--- /dev/null
+++ b/.idea/data_source_mapping.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml
new file mode 100644
index 0000000..d7202f0
--- /dev/null
+++ b/.idea/go.imports.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..1b33956
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ Gitlab CI inspections
+
+
+
+
+ User defined
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..e066844
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..ba088ee
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,170 @@
+.PHONY: help db-up db-down db-restart db-logs db-ps migrate-up migrate-down migrate-status migrate-reset migrate-create db-shell db-reset build clean install-goose
+
+# Load environment variables from .env file if it exists
+ifneq (,$(wildcard .env))
+ include .env
+ export
+endif
+
+# Default values if not set in .env
+DB_USER ?= volontery
+DB_NAME ?= volontery_db
+DB_HOST ?= localhost
+DB_PORT ?= 5432
+DB_SSLMODE ?= disable
+
+# Database connection string for goose
+DB_STRING := "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=$(DB_SSLMODE)"
+
+help: ## Show this help message
+ @echo 'Usage: make [target]'
+ @echo ''
+ @echo 'Available targets:'
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+##@ Database Operations
+
+db-up: ## Start PostgreSQL database container
+ @echo "Starting PostgreSQL with PostGIS..."
+ docker-compose up -d postgres
+ @echo "Waiting for database to be ready..."
+ @sleep 5
+ @echo "Database is ready!"
+
+db-down: ## Stop PostgreSQL database container
+ @echo "Stopping PostgreSQL..."
+ docker-compose down
+
+db-restart: db-down db-up ## Restart PostgreSQL database container
+
+db-logs: ## Show PostgreSQL container logs
+ docker-compose logs -f postgres
+
+db-ps: ## Show running database containers
+ docker-compose ps
+
+db-shell: ## Connect to PostgreSQL shell
+ docker-compose exec postgres psql -U $(DB_USER) -d $(DB_NAME)
+
+db-reset: ## Reset database (WARNING: destroys all data!)
+ @echo "WARNING: This will destroy all data in the database!"
+ @read -p "Are you sure? [y/N]: " confirm && [ "$$confirm" = "y" ] || exit 1
+ @echo "Resetting database..."
+ docker-compose down -v
+ docker-compose up -d postgres
+ @sleep 5
+ @$(MAKE) migrate-up
+ @echo "Database reset complete!"
+
+##@ Migration Operations
+
+install-goose: ## Install goose migration tool
+ @echo "Installing goose..."
+ go install github.com/pressly/goose/v3/cmd/goose@latest
+ @echo "goose installed successfully!"
+
+migrate-up: ## Apply all pending migrations
+ @echo "Applying migrations..."
+ goose -dir migrations postgres $(DB_STRING) up
+ @echo "Migrations applied successfully!"
+
+migrate-down: ## Rollback the last migration
+ @echo "Rolling back last migration..."
+ goose -dir migrations postgres $(DB_STRING) down
+ @echo "Migration rolled back successfully!"
+
+migrate-status: ## Show migration status
+ @echo "Migration status:"
+ goose -dir migrations postgres $(DB_STRING) status
+
+migrate-reset: ## Reset all migrations (WARNING: destroys all data!)
+ @echo "WARNING: This will destroy all data in the database!"
+ @read -p "Are you sure? [y/N]: " confirm && [ "$$confirm" = "y" ] || exit 1
+ @echo "Resetting migrations..."
+ goose -dir migrations postgres $(DB_STRING) reset
+ @echo "Migrations reset complete!"
+
+migrate-create: ## Create a new migration file (usage: make migrate-create NAME=migration_name)
+ @if [ -z "$(NAME)" ]; then \
+ echo "Error: NAME is required. Usage: make migrate-create NAME=migration_name"; \
+ exit 1; \
+ fi
+ @echo "Creating migration: $(NAME)"
+ goose -dir migrations create $(NAME) sql
+ @echo "Migration file created!"
+
+##@ sqlc Operations
+
+install-sqlc: ## Install sqlc code generation tool
+ @echo "Installing sqlc..."
+ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
+ @echo "sqlc installed successfully!"
+
+sqlc-generate: ## Generate Go code from SQL queries
+ @echo "Generating code from SQL..."
+ sqlc generate
+ @echo "Code generation complete!"
+
+sqlc-vet: ## Validate SQL queries
+ @echo "Validating SQL queries..."
+ sqlc vet
+ @echo "SQL validation complete!"
+
+##@ Build Operations
+
+build: ## Build the Go application
+ @echo "Building application..."
+ go build -o bin/volontery-api ./cmd/api
+ @echo "Build complete! Binary: bin/volontery-api"
+
+clean: ## Clean build artifacts
+ @echo "Cleaning build artifacts..."
+ rm -rf bin/
+ @echo "Clean complete!"
+
+##@ Development
+
+dev: db-up ## Start development environment
+ @echo "Development environment ready!"
+ @echo "Database: postgres://$(DB_USER):***@$(DB_HOST):$(DB_PORT)/$(DB_NAME)"
+ @echo ""
+ @echo "Next steps:"
+ @echo " 1. Copy .env.example to .env and configure"
+ @echo " 2. Run 'make migrate-up' to apply migrations"
+ @echo " 3. Run 'go run cmd/api/main.go' to start the API"
+
+setup: ## Initial project setup
+ @echo "Setting up project..."
+ @if [ ! -f .env ]; then \
+ echo "Creating .env from .env.example..."; \
+ cp .env.example .env; \
+ echo "Please edit .env and set DB_PASSWORD!"; \
+ fi
+ @$(MAKE) install-goose
+ @mkdir -p bin
+ @echo "Setup complete!"
+
+test-db: ## Run database tests
+ @echo "Testing database connection..."
+ @docker-compose exec postgres pg_isready -U $(DB_USER) -d $(DB_NAME) && \
+ echo "Database connection: OK" || \
+ echo "Database connection: FAILED"
+
+##@ Quality
+
+fmt: ## Format Go code
+ @echo "Formatting code..."
+ go fmt ./...
+
+lint: ## Run linter
+ @echo "Running linter..."
+ golangci-lint run
+
+test: ## Run tests
+ @echo "Running tests..."
+ go test -v -race -coverprofile=coverage.out ./...
+
+coverage: test ## Show test coverage
+ @echo "Generating coverage report..."
+ go tool cover -html=coverage.out -o coverage.html
+ @echo "Coverage report: coverage.html"
diff --git a/bin/volontery-api b/bin/volontery-api
new file mode 100755
index 0000000..f57a151
Binary files /dev/null and b/bin/volontery-api differ
diff --git a/cmd/api/main.go b/cmd/api/main.go
new file mode 100644
index 0000000..8584beb
--- /dev/null
+++ b/cmd/api/main.go
@@ -0,0 +1,125 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "git.kirlllll.ru/volontery/backend/internal/api"
+ "git.kirlllll.ru/volontery/backend/internal/config"
+ "git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
+ "git.kirlllll.ru/volontery/backend/internal/repository"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+)
+
+func main() {
+ if err := run(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func run() error {
+ // Загрузка конфигурации
+ cfg, err := config.Load()
+ if err != nil {
+ return fmt.Errorf("failed to load config: %w", err)
+ }
+
+ log.Printf("Starting server in %s mode", cfg.AppEnv)
+
+ // Подключение к базе данных
+ ctx := context.Background()
+ pool, err := config.NewDBPool(ctx, cfg.DatabaseURL)
+ if err != nil {
+ return fmt.Errorf("failed to connect to database: %w", err)
+ }
+ defer pool.Close()
+
+ log.Println("Connected to database")
+
+ // Инициализация репозиториев
+ repos := repository.New(pool)
+
+ // Инициализация JWT менеджера
+ jwtManager := jwt.NewManager(
+ cfg.JWTSecret,
+ cfg.JWTAccessTokenTTL,
+ cfg.JWTRefreshTokenTTL,
+ )
+
+ // Инициализация сервисов
+ authService := service.NewAuthService(
+ repos.User,
+ repos.Auth,
+ repos.RBAC,
+ jwtManager,
+ )
+
+ userService := service.NewUserService(
+ repos.User,
+ repos.RBAC,
+ )
+
+ requestService := service.NewRequestService(
+ repos.Request,
+ )
+
+ // Создание HTTP сервера
+ server := api.NewServer(
+ cfg,
+ authService,
+ userService,
+ requestService,
+ jwtManager,
+ )
+
+ // Настройка HTTP сервера
+ addr := fmt.Sprintf("%s:%s", cfg.ServerHost, cfg.ServerPort)
+ httpServer := &http.Server{
+ Addr: addr,
+ Handler: server,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ // Канал для обработки ошибок сервера
+ serverErrors := make(chan error, 1)
+
+ // Запуск HTTP сервера в горутине
+ go func() {
+ log.Printf("Server listening on %s", addr)
+ serverErrors <- httpServer.ListenAndServe()
+ }()
+
+ // Канал для обработки сигналов завершения
+ shutdown := make(chan os.Signal, 1)
+ signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM)
+
+ // Блокируемся до получения сигнала завершения или ошибки сервера
+ select {
+ case err := <-serverErrors:
+ return fmt.Errorf("server error: %w", err)
+
+ case sig := <-shutdown:
+ log.Printf("Received signal %v, starting graceful shutdown", sig)
+
+ // Даем 30 секунд на завершение активных запросов
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := httpServer.Shutdown(ctx); err != nil {
+ httpServer.Close()
+ return fmt.Errorf("failed to gracefully shutdown server: %w", err)
+ }
+
+ log.Println("Server stopped gracefully")
+ }
+
+ return nil
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..efebe41
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,25 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: imresamu/postgis
+ container_name: volontery_postgres
+ environment:
+ POSTGRES_DB: volontery_db
+ POSTGRES_USER: volontery
+ POSTGRES_PASSWORD: volontery
+ POSTGRES_HOST_AUTH_METHOD: ${DB_AUTH_METHOD:-scram-sha-256}
+ ports:
+ - "${DB_PORT:-5432}:5432"
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U ${DB_USER:-volontery} -d ${DB_NAME:-volontery_db}" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+ networks:
+ - volontery_network
+
+networks:
+ volontery_network:
+ driver: bridge
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
new file mode 100644
index 0000000..4a430fd
--- /dev/null
+++ b/docs/openapi.yaml
@@ -0,0 +1,1469 @@
+openapi: 3.1.0
+info:
+ title: Volunteer Coordination API
+ description: |
+ API для системы координации волонтёрской помощи маломобильным людям.
+
+ Система позволяет:
+ - Маломобильным гражданам создавать заявки на помощь
+ - Волонтёрам находить заявки рядом с собой и откликаться на них
+ - Модераторам управлять заявками и пользователями
+ - Администраторам управлять пользователями и ролями
+ version: 1.0.1
+ contact:
+ name: API Support
+ email: api@volontery.example.com
+ license:
+ name: MIT
+ url: https://opensource.org/licenses/MIT
+
+servers:
+ - url: http://localhost:8080/api/v1
+ description: Development server
+ - url: https://api.volontery.example.com/api/v1
+ description: Production server
+
+tags:
+ - name: health
+ description: Проверка здоровья сервиса
+ - name: auth
+ description: Аутентификация и авторизация
+ - name: users
+ description: Управление пользователями
+ - name: requests
+ description: Управление заявками на помощь
+ - name: responses
+ description: Отклики волонтёров на заявки
+ - name: moderation
+ description: Модерация заявок (требуется роль moderator или admin)
+ - name: admin
+ description: Административные функции (требуется роль admin)
+
+security:
+ - BearerAuth: []
+
+paths:
+ /health:
+ get:
+ tags: [health]
+ summary: Проверка здоровья сервиса
+ security: []
+ responses:
+ '200':
+ description: Сервис работает
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: OK
+
+ # ==================== AUTH ====================
+ /auth/register:
+ post:
+ tags: [auth]
+ summary: Регистрация нового пользователя
+ description: Создает нового пользователя в системе
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterRequest'
+ responses:
+ '201':
+ description: Пользователь успешно зарегистрирован
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthResponse'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /auth/login:
+ post:
+ tags: [auth]
+ summary: Вход в систему
+ description: Аутентификация пользователя по email и паролю
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ responses:
+ '200':
+ description: Успешная аутентификация
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /auth/refresh:
+ post:
+ tags: [auth]
+ summary: Обновление токенов
+ description: Получение новой пары access/refresh токенов
+ security: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [refresh_token]
+ properties:
+ refresh_token:
+ type: string
+ description: Refresh токен
+ responses:
+ '200':
+ description: Токены успешно обновлены
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /auth/me:
+ get:
+ tags: [auth]
+ summary: Информация о текущем пользователе
+ description: Возвращает базовую информацию об аутентифицированном пользователе
+ responses:
+ '200':
+ description: Информация о пользователе
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ email:
+ type: string
+ format: email
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /auth/logout:
+ post:
+ tags: [auth]
+ summary: Выход из системы
+ description: Отзывает все refresh токены пользователя
+ responses:
+ '200':
+ description: Успешный выход
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ example: logged out successfully
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ # ==================== USERS ====================
+ /users/me:
+ get:
+ tags: [users]
+ summary: Получение профиля текущего пользователя
+ description: Возвращает детальную информацию о профиле
+ responses:
+ '200':
+ description: Профиль пользователя
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserProfile'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ patch:
+ tags: [users]
+ summary: Обновление профиля
+ description: Обновляет информацию в профиле пользователя
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateProfileInput'
+ responses:
+ '200':
+ description: Профиль успешно обновлен
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ example: profile updated successfully
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/me/location:
+ post:
+ tags: [users]
+ summary: Обновление местоположения
+ description: Обновляет координаты домашнего адреса пользователя
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [latitude, longitude]
+ properties:
+ latitude:
+ type: number
+ format: double
+ minimum: -90
+ maximum: 90
+ example: 55.751244
+ longitude:
+ type: number
+ format: double
+ minimum: -180
+ maximum: 180
+ example: 37.618423
+ responses:
+ '200':
+ description: Местоположение обновлено
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ example: location updated successfully
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/me/verify-email:
+ post:
+ tags: [users]
+ summary: Подтверждение email адреса
+ description: |
+ Подтверждает email адрес текущего пользователя.
+ В полноценной системе должен принимать токен подтверждения из письма.
+ responses:
+ '200':
+ description: Email успешно подтвержден
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ example: email verified successfully
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '500':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/me/roles:
+ get:
+ tags: [users]
+ summary: Получение ролей пользователя
+ description: Возвращает список ролей текущего пользователя
+ responses:
+ '200':
+ description: Список ролей
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Role'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/me/permissions:
+ get:
+ tags: [users]
+ summary: Получение разрешений пользователя
+ description: Возвращает список всех разрешений текущего пользователя
+ responses:
+ '200':
+ description: Список разрешений
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Permission'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/me/permissions/{permission_name}/check:
+ get:
+ tags: [users]
+ summary: Проверка наличия разрешения
+ description: Проверяет, есть ли у текущего пользователя указанное разрешение
+ parameters:
+ - name: permission_name
+ in: path
+ required: true
+ description: Название разрешения для проверки
+ schema:
+ type: string
+ example: create_request
+ examples:
+ create_request:
+ value: create_request
+ summary: Разрешение на создание заявок
+ moderate_request:
+ value: moderate_request
+ summary: Разрешение на модерацию заявок
+ manage_users:
+ value: manage_users
+ summary: Разрешение на управление пользователями
+ responses:
+ '200':
+ description: Результат проверки разрешения
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PermissionCheckResult'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '500':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /users/{id}:
+ get:
+ tags: [users]
+ summary: Получение профиля пользователя по ID
+ description: Возвращает публичную информацию о пользователе
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Профиль пользователя
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UserProfile'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ # ==================== REQUEST TYPES ====================
+ /request-types:
+ get:
+ tags: [requests]
+ summary: Получение списка типов заявок
+ description: Возвращает справочник типов помощи
+ security: []
+ responses:
+ '200':
+ description: Список типов заявок
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestType'
+
+ # ==================== REQUESTS ====================
+ /requests:
+ post:
+ tags: [requests]
+ summary: Создание новой заявки
+ description: Создает заявку на помощь от имени текущего пользователя
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateRequestInput'
+ responses:
+ '201':
+ description: Заявка успешно создана
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Request'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/my:
+ get:
+ tags: [requests]
+ summary: Получение заявок пользователя
+ description: Возвращает список заявок, созданных текущим пользователем
+ parameters:
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
+ responses:
+ '200':
+ description: Список заявок
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestListItem'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/nearby:
+ get:
+ tags: [requests]
+ summary: Поиск заявок рядом с точкой
+ description: Геопространственный поиск заявок в радиусе от указанной точки
+ parameters:
+ - name: lat
+ in: query
+ required: true
+ description: Широта
+ schema:
+ type: number
+ format: double
+ minimum: -90
+ maximum: 90
+ - name: lon
+ in: query
+ required: true
+ description: Долгота
+ schema:
+ type: number
+ format: double
+ minimum: -180
+ maximum: 180
+ - name: radius
+ in: query
+ required: false
+ description: Радиус поиска в метрах (по умолчанию 5000)
+ schema:
+ type: number
+ format: double
+ default: 5000
+ minimum: 100
+ maximum: 50000
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
+ responses:
+ '200':
+ description: Список найденных заявок
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestWithDistance'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/bounds:
+ get:
+ tags: [requests]
+ summary: Поиск заявок в прямоугольной области
+ description: Возвращает заявки в заданной прямоугольной области (для отображения на карте)
+ parameters:
+ - name: min_lon
+ in: query
+ required: true
+ description: Минимальная долгота (левый нижний угол)
+ schema:
+ type: number
+ format: double
+ - name: min_lat
+ in: query
+ required: true
+ description: Минимальная широта (левый нижний угол)
+ schema:
+ type: number
+ format: double
+ - name: max_lon
+ in: query
+ required: true
+ description: Максимальная долгота (правый верхний угол)
+ schema:
+ type: number
+ format: double
+ - name: max_lat
+ in: query
+ required: true
+ description: Максимальная широта (правый верхний угол)
+ schema:
+ type: number
+ format: double
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
+ responses:
+ '200':
+ description: Список заявок в области
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestListItem'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/{id}:
+ get:
+ tags: [requests]
+ summary: Получение заявки по ID
+ description: Возвращает детальную информацию о заявке
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Детальная информация о заявке
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RequestDetail'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/{id}/complete:
+ post:
+ tags: [requests]
+ summary: Завершение заявки с оценкой
+ description: Создатель заявки завершает выполненную заявку и оценивает волонтёра
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - rating
+ properties:
+ rating:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 5
+ description: Оценка работы волонтёра (от 1 до 5)
+ example: 5
+ comment:
+ type: string
+ nullable: true
+ description: Комментарий к оценке
+ example: Отличная работа, спасибо!
+ responses:
+ '200':
+ description: Заявка завершена успешно
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ message:
+ type: string
+ example: request completed successfully
+ rating_id:
+ type: integer
+ format: int64
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ # ==================== RESPONSES ====================
+ /requests/{id}/responses:
+ get:
+ tags: [responses]
+ summary: Получение откликов на заявку
+ description: Возвращает список откликов волонтёров на заявку
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Список откликов
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/VolunteerResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ post:
+ tags: [responses]
+ summary: Создание отклика на заявку
+ description: Волонтёр откликается на заявку о помощи
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ message:
+ type: string
+ description: Сообщение волонтёра
+ example: Готов помочь завтра после 15:00
+ responses:
+ '201':
+ description: Отклик успешно создан
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VolunteerResponse'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /requests/{id}/responses/{response_id}/accept:
+ post:
+ tags: [responses]
+ summary: Принятие отклика волонтёра
+ description: Создатель заявки принимает отклик волонтёра и назначает его на заявку
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ - name: response_id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Отклик принят, волонтёр назначен
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ message:
+ type: string
+ example: volunteer assigned successfully
+ request_id:
+ type: integer
+ format: int64
+ volunteer_id:
+ type: integer
+ format: int64
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ # ==================== MODERATION ====================
+ /moderation/requests/pending:
+ get:
+ tags: [moderation]
+ summary: Получение заявок на модерацию
+ description: Возвращает заявки со статусом pending_moderation (требуется роль moderator или admin)
+ parameters:
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
+ responses:
+ '200':
+ description: Список заявок на модерацию
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestListItem'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /moderation/requests/my:
+ get:
+ tags: [moderation]
+ summary: Получение моих промодерированных заявок
+ description: Возвращает заявки, которые были промодерированы текущим пользователем
+ parameters:
+ - $ref: '#/components/parameters/Limit'
+ - $ref: '#/components/parameters/Offset'
+ responses:
+ '200':
+ description: Список промодерированных заявок
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RequestListItem'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /moderation/requests/{id}/approve:
+ post:
+ tags: [moderation]
+ summary: Одобрение заявки
+ description: Модератор одобряет заявку, меняет её статус на approved
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ comment:
+ type: string
+ nullable: true
+ description: Комментарий модератора (необязательно)
+ example: Заявка соответствует требованиям
+ responses:
+ '200':
+ description: Заявка одобрена
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: approved
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /moderation/requests/{id}/reject:
+ post:
+ tags: [moderation]
+ summary: Отклонение заявки
+ description: Модератор отклоняет заявку, меняет её статус на rejected
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - comment
+ properties:
+ comment:
+ type: string
+ description: Причина отклонения (обязательно)
+ example: Недостаточно информации для выполнения заявки
+ responses:
+ '200':
+ description: Заявка отклонена
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status:
+ type: string
+ example: rejected
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ /moderation/requests/{id}/moderate:
+ post:
+ tags: [moderation]
+ summary: Модерация заявки (альтернативный метод)
+ description: |
+ Модерирует заявку через stored procedure в БД.
+ Альтернатива отдельным эндпоинтам approve/reject.
+ Выполняет approve или reject в одной транзакции через БД процедуру.
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ModerationAction'
+ responses:
+ '200':
+ description: Заявка успешно промодерирована
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ModerationResult'
+ '400':
+ description: Некорректные данные запроса
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ examples:
+ - value: action must be 'approve' or 'reject'
+ - value: comment is required when rejecting
+ - value: request is not in pending_moderation status
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ $ref: '#/components/responses/ErrorResponse'
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+ # ==================== ADMIN ====================
+ /admin/users/{user_id}/roles/{role_id}:
+ post:
+ tags: [admin]
+ summary: Назначение роли пользователю
+ description: |
+ Назначает роль пользователю (требуется роль admin или разрешение manage_users).
+ Только администраторы могут управлять ролями пользователей.
+ parameters:
+ - name: user_id
+ in: path
+ required: true
+ description: ID пользователя
+ schema:
+ type: integer
+ format: int64
+ - name: role_id
+ in: path
+ required: true
+ description: ID роли для назначения
+ schema:
+ type: integer
+ format: int64
+ responses:
+ '200':
+ description: Роль успешно назначена
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RoleAssignmentResult'
+ '400':
+ $ref: '#/components/responses/ErrorResponse'
+ '401':
+ $ref: '#/components/responses/ErrorResponse'
+ '403':
+ description: Недостаточно прав доступа
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ example: admin role required
+ '404':
+ $ref: '#/components/responses/ErrorResponse'
+
+components:
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: 'JWT токен в формате: Bearer {token}'
+
+ parameters:
+ Limit:
+ name: limit
+ in: query
+ description: Количество результатов на странице
+ schema:
+ type: integer
+ format: int32
+ minimum: 1
+ maximum: 100
+ default: 20
+
+ Offset:
+ name: offset
+ in: query
+ description: Смещение для пагинации
+ schema:
+ type: integer
+ format: int32
+ minimum: 0
+ default: 0
+
+ schemas:
+ # ==================== AUTH SCHEMAS ====================
+ RegisterRequest:
+ type: object
+ required:
+ - email
+ - password
+ - first_name
+ - last_name
+ properties:
+ email:
+ type: string
+ format: email
+ example: user@example.com
+ password:
+ type: string
+ format: password
+ minLength: 8
+ example: SecureP@ssw0rd
+ first_name:
+ type: string
+ minLength: 1
+ maxLength: 100
+ example: Иван
+ last_name:
+ type: string
+ minLength: 1
+ maxLength: 100
+ example: Иванов
+ phone:
+ type: string
+ pattern: '^\+?[1-9]\d{1,14}$'
+ example: '+79001234567'
+ latitude:
+ type: number
+ format: double
+ minimum: -90
+ maximum: 90
+ example: 55.751244
+ longitude:
+ type: number
+ format: double
+ minimum: -180
+ maximum: 180
+ example: 37.618423
+ address:
+ type: string
+ example: Красная площадь, 1
+ city:
+ type: string
+ example: Москва
+ bio:
+ type: string
+ maxLength: 500
+ example: Рад помочь пожилым людям с покупками
+
+ LoginRequest:
+ type: object
+ required:
+ - email
+ - password
+ properties:
+ email:
+ type: string
+ format: email
+ example: user@example.com
+ password:
+ type: string
+ format: password
+ example: SecureP@ssw0rd
+
+ AuthResponse:
+ type: object
+ properties:
+ access_token:
+ type: string
+ description: JWT access токен
+ refresh_token:
+ type: string
+ description: Refresh токен
+ expires_in:
+ type: integer
+ format: int64
+ description: Время жизни access токена в секундах
+ user:
+ $ref: '#/components/schemas/UserInfo'
+
+ UserInfo:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ email:
+ type: string
+ format: email
+ first_name:
+ type: string
+ last_name:
+ type: string
+ email_verified:
+ type: boolean
+
+ # ==================== USER SCHEMAS ====================
+ UserProfile:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ email:
+ type: string
+ format: email
+ first_name:
+ type: string
+ last_name:
+ type: string
+ phone:
+ type: string
+ nullable: true
+ bio:
+ type: string
+ nullable: true
+ avatar_url:
+ type: string
+ nullable: true
+ address:
+ type: string
+ nullable: true
+ city:
+ type: string
+ nullable: true
+ volunteer_rating:
+ type: number
+ format: double
+ nullable: true
+ completed_requests_count:
+ type: integer
+ nullable: true
+ is_verified:
+ type: boolean
+ is_blocked:
+ type: boolean
+ email_verified:
+ type: boolean
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+
+ UpdateProfileInput:
+ type: object
+ properties:
+ first_name:
+ type: string
+ minLength: 1
+ maxLength: 100
+ last_name:
+ type: string
+ minLength: 1
+ maxLength: 100
+ phone:
+ type: string
+ bio:
+ type: string
+ maxLength: 500
+ address:
+ type: string
+ city:
+ type: string
+
+ Role:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ example: volunteer
+ description:
+ type: string
+ nullable: true
+
+ Permission:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ example: create_request
+ resource:
+ type: string
+ example: request
+ action:
+ type: string
+ example: create
+ description:
+ type: string
+ nullable: true
+
+ PermissionCheckResult:
+ type: object
+ properties:
+ has_permission:
+ type: boolean
+ description: Есть ли у пользователя это разрешение
+ permission_name:
+ type: string
+ description: Название проверяемого разрешения
+
+ # ==================== REQUEST SCHEMAS ====================
+ RequestType:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ name:
+ type: string
+ example: Покупка продуктов
+ description:
+ type: string
+ nullable: true
+ icon:
+ type: string
+ nullable: true
+ is_active:
+ type: boolean
+
+ CreateRequestInput:
+ type: object
+ required:
+ - request_type_id
+ - title
+ - description
+ - latitude
+ - longitude
+ - address
+ - urgency
+ properties:
+ request_type_id:
+ type: integer
+ format: int64
+ title:
+ type: string
+ minLength: 5
+ maxLength: 200
+ example: Нужна помощь с покупкой продуктов
+ description:
+ type: string
+ minLength: 10
+ maxLength: 2000
+ example: Прошу помочь купить продукты в ближайшем магазине
+ latitude:
+ type: number
+ format: double
+ minimum: -90
+ maximum: 90
+ longitude:
+ type: number
+ format: double
+ minimum: -180
+ maximum: 180
+ address:
+ type: string
+ example: ул. Ленина, д. 10, кв. 5
+ city:
+ type: string
+ example: Москва
+ desired_completion_date:
+ type: string
+ format: date-time
+ nullable: true
+ urgency:
+ type: string
+ enum: [low, medium, high, urgent]
+ example: high
+ contact_phone:
+ type: string
+ example: '+79001234567'
+ contact_notes:
+ type: string
+ example: Код домофона 123, 3 этаж
+
+ Request:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ requester_id:
+ type: integer
+ format: int64
+ request_type_id:
+ type: integer
+ format: int64
+ assigned_volunteer_id:
+ type: integer
+ format: int64
+ nullable: true
+ title:
+ type: string
+ description:
+ type: string
+ address:
+ type: string
+ city:
+ type: string
+ nullable: true
+ desired_completion_date:
+ type: string
+ format: date-time
+ nullable: true
+ urgency:
+ type: string
+ nullable: true
+ status:
+ $ref: '#/components/schemas/RequestStatus'
+ contact_phone:
+ type: string
+ nullable: true
+ contact_notes:
+ type: string
+ nullable: true
+ created_at:
+ type: string
+ format: date-time
+ updated_at:
+ type: string
+ format: date-time
+
+ RequestListItem:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ title:
+ type: string
+ description:
+ type: string
+ address:
+ type: string
+ city:
+ type: string
+ urgency:
+ type: string
+ status:
+ $ref: '#/components/schemas/RequestStatus'
+ requester_name:
+ type: string
+ request_type_name:
+ type: string
+ created_at:
+ type: string
+ format: date-time
+
+ RequestWithDistance:
+ allOf:
+ - $ref: '#/components/schemas/RequestListItem'
+ - type: object
+ properties:
+ distance_meters:
+ type: number
+ format: double
+ description: Расстояние до заявки в метрах
+
+ RequestDetail:
+ allOf:
+ - $ref: '#/components/schemas/Request'
+ - type: object
+ properties:
+ requester:
+ $ref: '#/components/schemas/UserInfo'
+ request_type:
+ $ref: '#/components/schemas/RequestType'
+ assigned_volunteer:
+ $ref: '#/components/schemas/UserInfo'
+ nullable: true
+
+ RequestStatus:
+ type: string
+ enum:
+ - pending_moderation
+ - approved
+ - in_progress
+ - completed
+ - cancelled
+ - rejected
+
+ # ==================== RESPONSE SCHEMAS ====================
+ VolunteerResponse:
+ type: object
+ properties:
+ id:
+ type: integer
+ format: int64
+ request_id:
+ type: integer
+ format: int64
+ volunteer_id:
+ type: integer
+ format: int64
+ volunteer_name:
+ type: string
+ status:
+ $ref: '#/components/schemas/ResponseStatus'
+ message:
+ type: string
+ nullable: true
+ responded_at:
+ type: string
+ format: date-time
+ accepted_at:
+ type: string
+ format: date-time
+ nullable: true
+ rejected_at:
+ type: string
+ format: date-time
+ nullable: true
+
+ ResponseStatus:
+ type: string
+ enum:
+ - pending
+ - accepted
+ - rejected
+ - cancelled
+
+ # ==================== MODERATION SCHEMAS ====================
+ ModerationAction:
+ type: object
+ required:
+ - action
+ properties:
+ action:
+ type: string
+ enum: [approve, reject]
+ description: Действие модерации
+ example: approve
+ comment:
+ type: string
+ nullable: true
+ description: Комментарий модератора (обязателен при reject)
+ example: Заявка соответствует требованиям
+
+ ModerationResult:
+ type: object
+ properties:
+ success:
+ type: boolean
+ example: true
+ message:
+ type: string
+ example: request moderated successfully
+ new_status:
+ type: string
+ enum: [approved, rejected]
+ example: approved
+
+ # ==================== ADMIN SCHEMAS ====================
+ RoleAssignmentResult:
+ type: object
+ properties:
+ message:
+ type: string
+ example: role assigned successfully
+ user_id:
+ type: integer
+ format: int64
+ role_id:
+ type: integer
+ format: int64
+
+ # ==================== ERROR SCHEMAS ====================
+ responses:
+ ErrorResponse:
+ description: Ошибка
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ error:
+ type: string
+ description: Описание ошибки
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..1281200
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,50 @@
+module git.kirlllll.ru/volontery/backend
+
+go 1.25.1
+
+require (
+ github.com/go-chi/chi/v5 v5.2.3
+ github.com/go-faster/errors v0.7.1
+ github.com/go-faster/jx v1.2.0
+ github.com/golang-jwt/jwt/v5 v5.3.0
+ github.com/jackc/pgx/v5 v5.7.6
+ github.com/joho/godotenv v1.5.1
+ github.com/ogen-go/ogen v1.18.0
+ github.com/stretchr/testify v1.11.1
+ github.com/swaggest/swgui v1.8.5
+ go.opentelemetry.io/otel v1.38.0
+ go.opentelemetry.io/otel/metric v1.38.0
+ go.opentelemetry.io/otel/trace v1.38.0
+ golang.org/x/crypto v0.45.0
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dlclark/regexp2 v1.11.5 // indirect
+ github.com/fatih/color v1.18.0 // indirect
+ github.com/ghodss/yaml v1.0.0 // indirect
+ github.com/go-faster/yaml v0.4.6 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/vearutop/statigz v1.4.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.1 // indirect
+ golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
+ golang.org/x/net v0.47.0 // indirect
+ golang.org/x/sync v0.18.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..28141b8
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,106 @@
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ=
+github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
+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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
+github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+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-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
+github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
+github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
+github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
+github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
+github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+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.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
+github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ogen-go/ogen v1.18.0 h1:6RQ7lFBjOeNaUWu4getfqIh4GJbEY4hqKuzDtec/g60=
+github.com/ogen-go/ogen v1.18.0/go.mod h1:dHFr2Wf6cA7tSxMI+zPC21UR5hAlDw8ZYUkK3PziURY=
+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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+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/swaggest/swgui v1.8.5 h1:nceK5OJcpXpkfjmPNH6wtubbd8ZYwxy043xmx0SK18g=
+github.com/swaggest/swgui v1.8.5/go.mod h1:kvSzLC7+wK4l9n/YcQlb2AMeQtkno9i3C6imADv/fLQ=
+github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
+github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
+go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
+go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
+go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
+go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
+go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
+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.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
+golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go
new file mode 100644
index 0000000..0d94c44
--- /dev/null
+++ b/internal/api/handlers/auth.go
@@ -0,0 +1,117 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "git.kirlllll.ru/volontery/backend/internal/api/middleware"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+)
+
+// AuthHandler обрабатывает запросы аутентификации
+type AuthHandler struct {
+ authService *service.AuthService
+}
+
+// NewAuthHandler создает новый AuthHandler
+func NewAuthHandler(authService *service.AuthService) *AuthHandler {
+ return &AuthHandler{
+ authService: authService,
+ }
+}
+
+// Register обрабатывает регистрацию пользователя
+// POST /api/v1/auth/register
+func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
+ var req service.RegisterRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ resp, err := h.authService.Register(r.Context(), req)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusCreated, resp)
+}
+
+// Login обрабатывает вход пользователя
+// POST /api/v1/auth/login
+func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+ var req service.LoginRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ resp, err := h.authService.Login(r.Context(), req)
+ if err != nil {
+ respondError(w, http.StatusUnauthorized, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, resp)
+}
+
+// RefreshToken обрабатывает обновление токенов
+// POST /api/v1/auth/refresh
+func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ RefreshToken string `json:"refresh_token"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if req.RefreshToken == "" {
+ respondError(w, http.StatusBadRequest, "refresh_token is required")
+ return
+ }
+
+ resp, err := h.authService.RefreshTokens(r.Context(), req.RefreshToken)
+ if err != nil {
+ respondError(w, http.StatusUnauthorized, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, resp)
+}
+
+// Logout обрабатывает выход пользователя
+// POST /api/v1/auth/logout
+func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ if err := h.authService.Logout(r.Context(), userID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to logout")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"message": "logged out successfully"})
+}
+
+// Me возвращает информацию о текущем пользователе
+// GET /api/v1/auth/me
+func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ email, _ := middleware.GetUserEmailFromContext(r.Context())
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "id": userID,
+ "email": email,
+ })
+}
diff --git a/internal/api/handlers/helpers.go b/internal/api/handlers/helpers.go
new file mode 100644
index 0000000..29553dd
--- /dev/null
+++ b/internal/api/handlers/helpers.go
@@ -0,0 +1,28 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// ErrorResponse представляет ответ с ошибкой
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
+
+// respondJSON отправляет JSON ответ
+func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+
+ if data != nil {
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ http.Error(w, "failed to encode response", http.StatusInternalServerError)
+ }
+ }
+}
+
+// respondError отправляет ошибку в формате JSON
+func respondError(w http.ResponseWriter, statusCode int, message string) {
+ respondJSON(w, statusCode, ErrorResponse{Error: message})
+}
diff --git a/internal/api/handlers/requests.go b/internal/api/handlers/requests.go
new file mode 100644
index 0000000..c9985f2
--- /dev/null
+++ b/internal/api/handlers/requests.go
@@ -0,0 +1,460 @@
+package handlers
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "git.kirlllll.ru/volontery/backend/internal/api/middleware"
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+ "github.com/go-chi/chi/v5"
+)
+
+// RequestHandler обрабатывает запросы заявок
+type RequestHandler struct {
+ requestService *service.RequestService
+}
+
+// NewRequestHandler создает новый RequestHandler
+func NewRequestHandler(requestService *service.RequestService) *RequestHandler {
+ return &RequestHandler{
+ requestService: requestService,
+ }
+}
+
+// CreateRequest создает новую заявку
+// POST /api/v1/requests
+func (h *RequestHandler) CreateRequest(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ var input service.CreateRequestInput
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ input.RequesterID = userID
+
+ request, err := h.requestService.CreateRequest(r.Context(), input)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusCreated, request)
+}
+
+// GetRequest получает заявку по ID
+// GET /api/v1/requests/{id}
+func (h *RequestHandler) GetRequest(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ request, err := h.requestService.GetRequest(r.Context(), requestID)
+ if err != nil {
+ respondError(w, http.StatusNotFound, "request not found")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, request)
+}
+
+// GetMyRequests получает заявки текущего пользователя
+// GET /api/v1/requests/my
+func (h *RequestHandler) GetMyRequests(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ limit, offset := parsePagination(r)
+
+ requests, err := h.requestService.GetUserRequests(r.Context(), userID, limit, offset)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get requests")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, requests)
+}
+
+// FindNearbyRequests ищет заявки рядом с точкой
+// GET /api/v1/requests/nearby
+func (h *RequestHandler) FindNearbyRequests(w http.ResponseWriter, r *http.Request) {
+ latStr := r.URL.Query().Get("lat")
+ lonStr := r.URL.Query().Get("lon")
+ radiusStr := r.URL.Query().Get("radius")
+
+ if latStr == "" || lonStr == "" {
+ respondError(w, http.StatusBadRequest, "lat and lon are required")
+ return
+ }
+
+ lat, err := strconv.ParseFloat(latStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid latitude")
+ return
+ }
+
+ lon, err := strconv.ParseFloat(lonStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid longitude")
+ return
+ }
+
+ radius := 5000.0 // default 5km
+ if radiusStr != "" {
+ radius, err = strconv.ParseFloat(radiusStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid radius")
+ return
+ }
+ }
+
+ limit, offset := parsePagination(r)
+
+ // По умолчанию ищем только активные заявки
+ statuses := []database.RequestStatus{
+ database.RequestStatusPendingModeration,
+ database.RequestStatusApproved,
+ database.RequestStatusInProgress,
+ }
+
+ requests, err := h.requestService.FindNearbyRequests(r.Context(), lat, lon, radius, statuses, limit, offset)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to find requests: %v", err))
+ return
+ }
+
+ respondJSON(w, http.StatusOK, requests)
+}
+
+// FindRequestsInBounds ищет заявки в прямоугольной области
+// GET /api/v1/requests/bounds
+func (h *RequestHandler) FindRequestsInBounds(w http.ResponseWriter, r *http.Request) {
+ minLonStr := r.URL.Query().Get("min_lon")
+ minLatStr := r.URL.Query().Get("min_lat")
+ maxLonStr := r.URL.Query().Get("max_lon")
+ maxLatStr := r.URL.Query().Get("max_lat")
+
+ if minLonStr == "" || minLatStr == "" || maxLonStr == "" || maxLatStr == "" {
+ respondError(w, http.StatusBadRequest, "min_lon, min_lat, max_lon, max_lat are required")
+ return
+ }
+
+ minLon, err := strconv.ParseFloat(minLonStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid min_lon")
+ return
+ }
+
+ minLat, err := strconv.ParseFloat(minLatStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid min_lat")
+ return
+ }
+
+ maxLon, err := strconv.ParseFloat(maxLonStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid max_lon")
+ return
+ }
+
+ maxLat, err := strconv.ParseFloat(maxLatStr, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid max_lat")
+ return
+ }
+
+ // По умолчанию ищем только активные заявки
+ statuses := []database.RequestStatus{
+ database.RequestStatusPendingModeration,
+ database.RequestStatusApproved,
+ database.RequestStatusInProgress,
+ }
+
+ requests, err := h.requestService.FindRequestsInBounds(r.Context(), statuses, minLon, minLat, maxLon, maxLat)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, fmt.Sprintf("failed to find requests: %v", err))
+ return
+ }
+
+ respondJSON(w, http.StatusOK, requests)
+}
+
+// CreateVolunteerResponse создает отклик волонтера на заявку
+// POST /api/v1/requests/{id}/responses
+func (h *RequestHandler) CreateVolunteerResponse(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ var input struct {
+ Message string `json:"message"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ response, err := h.requestService.CreateVolunteerResponse(r.Context(), requestID, userID, input.Message)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusCreated, response)
+}
+
+// GetRequestResponses получает отклики на заявку
+// GET /api/v1/requests/{id}/responses
+func (h *RequestHandler) GetRequestResponses(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ responses, err := h.requestService.GetRequestResponses(r.Context(), requestID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get responses")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, responses)
+}
+
+// ListRequestTypes получает список типов заявок
+// GET /api/v1/request-types
+func (h *RequestHandler) ListRequestTypes(w http.ResponseWriter, r *http.Request) {
+ types, err := h.requestService.ListRequestTypes(r.Context())
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get request types")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, types)
+}
+
+// GetPendingModerationRequests получает заявки на модерации
+// GET /api/v1/moderation/requests/pending
+func (h *RequestHandler) GetPendingModerationRequests(w http.ResponseWriter, r *http.Request) {
+ limit, offset := parsePagination(r)
+
+ requests, err := h.requestService.GetPendingModerationRequests(r.Context(), limit, offset)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get pending requests")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, requests)
+}
+
+// ApproveRequest одобряет заявку
+// POST /api/v1/moderation/requests/{id}/approve
+func (h *RequestHandler) ApproveRequest(w http.ResponseWriter, r *http.Request) {
+ moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ var input struct {
+ Comment *string `json:"comment"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if err := h.requestService.ApproveRequest(r.Context(), requestID, moderatorID, input.Comment); err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"status": "approved"})
+}
+
+// RejectRequest отклоняет заявку
+// POST /api/v1/moderation/requests/{id}/reject
+func (h *RequestHandler) RejectRequest(w http.ResponseWriter, r *http.Request) {
+ moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ var input struct {
+ Comment string `json:"comment"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if err := h.requestService.RejectRequest(r.Context(), requestID, moderatorID, input.Comment); err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"status": "rejected"})
+}
+
+// GetMyModeratedRequests получает заявки, модерированные текущим модератором
+// GET /api/v1/moderation/requests/my
+func (h *RequestHandler) GetMyModeratedRequests(w http.ResponseWriter, r *http.Request) {
+ moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ limit, offset := parsePagination(r)
+
+ requests, err := h.requestService.GetModeratedRequests(r.Context(), moderatorID, limit, offset)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get moderated requests")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, requests)
+}
+
+// AcceptVolunteerResponseHandler принимает отклик волонтера
+// POST /api/v1/requests/{id}/responses/{response_id}/accept
+func (h *RequestHandler) AcceptVolunteerResponseHandler(w http.ResponseWriter, r *http.Request) {
+ requesterID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ responseIDStr := chi.URLParam(r, "response_id")
+ responseID, err := strconv.ParseInt(responseIDStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid response id")
+ return
+ }
+
+ result, err := h.requestService.AcceptVolunteerResponse(r.Context(), responseID, requesterID)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ if !result.Success {
+ respondError(w, http.StatusBadRequest, result.Message)
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": result.Success,
+ "message": result.Message,
+ "request_id": result.RequestID,
+ "volunteer_id": result.VolunteerID,
+ })
+}
+
+// CompleteRequestWithRatingHandler завершает заявку с рейтингом
+// POST /api/v1/requests/{id}/complete
+func (h *RequestHandler) CompleteRequestWithRatingHandler(w http.ResponseWriter, r *http.Request) {
+ requesterID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ var input struct {
+ Rating int32 `json:"rating"`
+ Comment *string `json:"comment"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ result, err := h.requestService.CompleteRequestWithRating(r.Context(), requestID, requesterID, input.Rating, input.Comment)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ if !result.Success {
+ respondError(w, http.StatusBadRequest, result.Message)
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": result.Success,
+ "message": result.Message,
+ "rating_id": result.RatingID,
+ })
+}
+
+// parsePagination парсит параметры пагинации из запроса
+func parsePagination(r *http.Request) (limit, offset int32) {
+ limitStr := r.URL.Query().Get("limit")
+ offsetStr := r.URL.Query().Get("offset")
+
+ limit = 20 // default
+ if limitStr != "" {
+ if l, err := strconv.ParseInt(limitStr, 10, 32); err == nil && l > 0 && l <= 100 {
+ limit = int32(l)
+ }
+ }
+
+ offset = 0
+ if offsetStr != "" {
+ if o, err := strconv.ParseInt(offsetStr, 10, 32); err == nil && o >= 0 {
+ offset = int32(o)
+ }
+ }
+
+ return limit, offset
+}
diff --git a/internal/api/handlers/users.go b/internal/api/handlers/users.go
new file mode 100644
index 0000000..c7e27c8
--- /dev/null
+++ b/internal/api/handlers/users.go
@@ -0,0 +1,308 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "git.kirlllll.ru/volontery/backend/internal/api/middleware"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+ "github.com/go-chi/chi/v5"
+)
+
+// UserHandler обрабатывает запросы пользователей
+type UserHandler struct {
+ userService *service.UserService
+}
+
+// NewUserHandler создает новый UserHandler
+func NewUserHandler(userService *service.UserService) *UserHandler {
+ return &UserHandler{
+ userService: userService,
+ }
+}
+
+// GetProfile возвращает профиль пользователя
+// GET /api/v1/users/{id}
+func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ userID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid user id")
+ return
+ }
+
+ profile, err := h.userService.GetUserProfile(r.Context(), userID)
+ if err != nil {
+ respondError(w, http.StatusNotFound, "user not found")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, profile)
+}
+
+// GetMyProfile возвращает профиль текущего пользователя
+// GET /api/v1/users/me
+func (h *UserHandler) GetMyProfile(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ profile, err := h.userService.GetUserProfile(r.Context(), userID)
+ if err != nil {
+ respondError(w, http.StatusNotFound, "user not found")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, profile)
+}
+
+// UpdateProfile обновляет профиль текущего пользователя
+// PATCH /api/v1/users/me
+func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ var input service.UpdateProfileInput
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if err := h.userService.UpdateUserProfile(r.Context(), userID, input); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to update profile")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"message": "profile updated successfully"})
+}
+
+// UpdateLocation обновляет местоположение пользователя
+// POST /api/v1/users/me/location
+func (h *UserHandler) UpdateLocation(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ var input struct {
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if err := h.userService.UpdateUserLocation(r.Context(), userID, input.Latitude, input.Longitude); err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"message": "location updated successfully"})
+}
+
+// GetMyRoles возвращает роли текущего пользователя
+// GET /api/v1/users/me/roles
+func (h *UserHandler) GetMyRoles(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ roles, err := h.userService.GetUserRoles(r.Context(), userID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get roles")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, roles)
+}
+
+// GetMyPermissions возвращает разрешения текущего пользователя
+// GET /api/v1/users/me/permissions
+func (h *UserHandler) GetMyPermissions(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ permissions, err := h.userService.GetUserPermissions(r.Context(), userID)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to get permissions")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, permissions)
+}
+
+// VerifyEmail подтверждает email пользователя
+// POST /api/v1/users/me/verify-email
+func (h *UserHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ // В реальной системе здесь должна быть проверка токена из письма
+ // Сейчас просто подтверждаем email для текущего пользователя
+ if err := h.userService.VerifyEmail(r.Context(), userID); err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to verify email")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]string{"message": "email verified successfully"})
+}
+
+// CheckPermission проверяет наличие разрешения у пользователя
+// GET /api/v1/users/me/permissions/{permission_name}/check
+func (h *UserHandler) CheckPermission(w http.ResponseWriter, r *http.Request) {
+ userID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ permissionName := chi.URLParam(r, "permission_name")
+ if permissionName == "" {
+ respondError(w, http.StatusBadRequest, "permission_name is required")
+ return
+ }
+
+ hasPermission, err := h.userService.HasPermission(r.Context(), userID, permissionName)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to check permission")
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "has_permission": hasPermission,
+ "permission_name": permissionName,
+ })
+}
+
+// ========== AdminHandler - новый хендлер для административных функций ==========
+
+// AdminHandler обрабатывает административные запросы
+type AdminHandler struct {
+ userService *service.UserService
+}
+
+// NewAdminHandler создает новый AdminHandler
+func NewAdminHandler(userService *service.UserService) *AdminHandler {
+ return &AdminHandler{
+ userService: userService,
+ }
+}
+
+// AssignRole назначает роль пользователю
+// POST /api/v1/admin/users/{user_id}/roles/{role_id}
+func (h *AdminHandler) AssignRole(w http.ResponseWriter, r *http.Request) {
+ adminID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ // Проверка, что текущий пользователь является администратором
+ // В реальной системе это должно быть в middleware
+ isAdmin, err := h.userService.HasPermission(r.Context(), adminID, "manage_users")
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, "failed to check permissions")
+ return
+ }
+ if !isAdmin {
+ respondError(w, http.StatusForbidden, "admin role required")
+ return
+ }
+
+ userIDStr := chi.URLParam(r, "user_id")
+ userID, err := strconv.ParseInt(userIDStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid user_id")
+ return
+ }
+
+ roleIDStr := chi.URLParam(r, "role_id")
+ roleID, err := strconv.ParseInt(roleIDStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid role_id")
+ return
+ }
+
+ if err := h.userService.AssignRole(r.Context(), userID, roleID, adminID); err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "message": "role assigned successfully",
+ "user_id": userID,
+ "role_id": roleID,
+ })
+}
+
+// ========== RequestHandler - дополнительный метод ==========
+
+// ModerateRequestProcedure модерирует заявку через stored procedure
+// POST /api/v1/moderation/requests/{id}/moderate
+func (h *RequestHandler) ModerateRequestProcedure(w http.ResponseWriter, r *http.Request) {
+ moderatorID, ok := middleware.GetUserIDFromContext(r.Context())
+ if !ok {
+ respondError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+
+ idStr := chi.URLParam(r, "id")
+ requestID, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request id")
+ return
+ }
+
+ var input struct {
+ Action string `json:"action"`
+ Comment *string `json:"comment"`
+ }
+
+ if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ if input.Action != "approve" && input.Action != "reject" {
+ respondError(w, http.StatusBadRequest, "action must be 'approve' or 'reject'")
+ return
+ }
+
+ if input.Action == "reject" && (input.Comment == nil || *input.Comment == "") {
+ respondError(w, http.StatusBadRequest, "comment is required when rejecting")
+ return
+ }
+
+ result, err := h.requestService.ModerateRequestProcedure(r.Context(), requestID, moderatorID, input.Action, input.Comment)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ if !result.Success {
+ respondError(w, http.StatusBadRequest, result.Message)
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": result.Success,
+ "message": result.Message,
+ })
+}
diff --git a/internal/api/helpers.go b/internal/api/helpers.go
new file mode 100644
index 0000000..ae4df6c
--- /dev/null
+++ b/internal/api/helpers.go
@@ -0,0 +1,25 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+// ErrorResponse represents a JSON error response
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
+
+// JSONError sends a JSON error response
+func JSONError(w http.ResponseWriter, message string, statusCode int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{Error: message})
+}
+
+// JSONResponse sends a JSON response
+func JSONResponse(w http.ResponseWriter, data interface{}, statusCode int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(data)
+}
diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go
new file mode 100644
index 0000000..a5eaf31
--- /dev/null
+++ b/internal/api/middleware/auth.go
@@ -0,0 +1,107 @@
+package middleware
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
+)
+
+type contextKey string
+
+const (
+ UserIDKey contextKey = "user_id"
+ UserEmailKey contextKey = "user_email"
+)
+
+// ErrorResponse represents a JSON error response
+type ErrorResponse struct {
+ Error string `json:"error"`
+}
+
+// JSONError sends a JSON error response
+func JSONError(w http.ResponseWriter, message string, statusCode int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{Error: message})
+}
+
+// AuthMiddleware создает middleware для проверки JWT токена
+func AuthMiddleware(jwtManager *jwt.Manager) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Получение токена из заголовка Authorization
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ JSONError(w, "authorization header required", http.StatusUnauthorized)
+ return
+ }
+
+ // Проверка формата "Bearer "
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ JSONError(w, "invalid authorization header format", http.StatusUnauthorized)
+ return
+ }
+
+ tokenString := parts[1]
+
+ // Валидация токена
+ claims, err := jwtManager.ValidateToken(tokenString)
+ if err != nil {
+ JSONError(w, "invalid or expired token", http.StatusUnauthorized)
+ return
+ }
+
+ // Добавление данных пользователя в контекст
+ ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
+ ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// GetUserIDFromContext извлекает ID пользователя из контекста
+func GetUserIDFromContext(ctx context.Context) (int64, bool) {
+ userID, ok := ctx.Value(UserIDKey).(int64)
+ return userID, ok
+}
+
+// GetUserEmailFromContext извлекает email пользователя из контекста
+func GetUserEmailFromContext(ctx context.Context) (string, bool) {
+ email, ok := ctx.Value(UserEmailKey).(string)
+ return email, ok
+}
+
+// OptionalAuthMiddleware делает аутентификацию опциональной (не возвращает ошибку если токена нет)
+func OptionalAuthMiddleware(jwtManager *jwt.Manager) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authHeader := r.Header.Get("Authorization")
+ if authHeader == "" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ parts := strings.SplitN(authHeader, " ", 2)
+ if len(parts) != 2 || parts[0] != "Bearer" {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ claims, err := jwtManager.ValidateToken(parts[1])
+ if err != nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
+ ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/internal/api/middleware/common.go b/internal/api/middleware/common.go
new file mode 100644
index 0000000..cfe1800
--- /dev/null
+++ b/internal/api/middleware/common.go
@@ -0,0 +1,94 @@
+package middleware
+
+import (
+ "log"
+ "net/http"
+ "runtime/debug"
+ "time"
+)
+
+// Logger middleware для логирования HTTP запросов
+func Logger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+
+ // Обертка для захвата статус кода
+ wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
+
+ next.ServeHTTP(wrapped, r)
+
+ log.Printf(
+ "%s %s %d %s",
+ r.Method,
+ r.RequestURI,
+ wrapped.statusCode,
+ time.Since(start),
+ )
+ })
+}
+
+// responseWriter обертка для захвата статус кода
+type responseWriter struct {
+ http.ResponseWriter
+ statusCode int
+}
+
+func (rw *responseWriter) WriteHeader(code int) {
+ rw.statusCode = code
+ rw.ResponseWriter.WriteHeader(code)
+}
+
+// Recovery middleware для восстановления после паник
+func Recovery(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Printf("panic: %v\n%s", err, debug.Stack())
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+ }()
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+// CORS middleware для настройки CORS заголовков
+func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+
+ // Проверка разрешенных источников
+ allowed := false
+ for _, allowedOrigin := range allowedOrigins {
+ if allowedOrigin == "*" || allowedOrigin == origin {
+ allowed = true
+ break
+ }
+ }
+
+ if allowed {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
+ w.Header().Set("Access-Control-Max-Age", "86400")
+ }
+
+ // Обработка preflight запросов
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// ContentTypeJSON устанавливает Content-Type: application/json
+func ContentTypeJSON(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/api/middleware/rbac.go b/internal/api/middleware/rbac.go
new file mode 100644
index 0000000..cbe7196
--- /dev/null
+++ b/internal/api/middleware/rbac.go
@@ -0,0 +1,106 @@
+package middleware
+
+import (
+ "net/http"
+
+ "git.kirlllll.ru/volontery/backend/internal/service"
+)
+
+// RequirePermission создает middleware для проверки разрешения
+func RequirePermission(userService *service.UserService, permission string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userID, ok := GetUserIDFromContext(r.Context())
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ hasPermission, err := userService.HasPermission(r.Context(), userID, permission)
+ if err != nil {
+ http.Error(w, "failed to check permissions", http.StatusInternalServerError)
+ return
+ }
+
+ if !hasPermission {
+ http.Error(w, "forbidden: insufficient permissions", http.StatusForbidden)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequireRole создает middleware для проверки роли
+func RequireRole(userService *service.UserService, roleName string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userID, ok := GetUserIDFromContext(r.Context())
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles, err := userService.GetUserRoles(r.Context(), userID)
+ if err != nil {
+ http.Error(w, "failed to check roles", http.StatusInternalServerError)
+ return
+ }
+
+ hasRole := false
+ for _, role := range roles {
+ if role.Name == roleName {
+ hasRole = true
+ break
+ }
+ }
+
+ if !hasRole {
+ http.Error(w, "forbidden: required role not assigned", http.StatusForbidden)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequireAnyRole создает middleware для проверки наличия хотя бы одной из ролей
+func RequireAnyRole(userService *service.UserService, roleNames ...string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ userID, ok := GetUserIDFromContext(r.Context())
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ roles, err := userService.GetUserRoles(r.Context(), userID)
+ if err != nil {
+ http.Error(w, "failed to check roles", http.StatusInternalServerError)
+ return
+ }
+
+ hasAnyRole := false
+ for _, role := range roles {
+ for _, requiredRole := range roleNames {
+ if role.Name == requiredRole {
+ hasAnyRole = true
+ break
+ }
+ }
+ if hasAnyRole {
+ break
+ }
+ }
+
+ if !hasAnyRole {
+ http.Error(w, "forbidden: none of the required roles assigned", http.StatusForbidden)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..fb12cea
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,140 @@
+package api
+
+import (
+ "net/http"
+
+ "git.kirlllll.ru/volontery/backend/internal/api/handlers"
+ "git.kirlllll.ru/volontery/backend/internal/api/middleware"
+ "git.kirlllll.ru/volontery/backend/internal/config"
+ "git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+ "github.com/go-chi/chi/v5"
+ chiMiddleware "github.com/go-chi/chi/v5/middleware"
+)
+
+// Server представляет HTTP сервер
+type Server struct {
+ router *chi.Mux
+ authHandler *handlers.AuthHandler
+ adminHandler *handlers.AdminHandler
+ userHandler *handlers.UserHandler
+ requestHandler *handlers.RequestHandler
+ jwtManager *jwt.Manager
+ userService *service.UserService
+ config *config.Config
+}
+
+// NewServer создает новый HTTP сервер
+func NewServer(
+ cfg *config.Config,
+ authService *service.AuthService,
+ userService *service.UserService,
+ requestService *service.RequestService,
+ jwtManager *jwt.Manager,
+) *Server {
+ s := &Server{
+ router: chi.NewRouter(),
+ authHandler: handlers.NewAuthHandler(authService),
+ adminHandler: handlers.NewAdminHandler(userService),
+ userHandler: handlers.NewUserHandler(userService),
+ requestHandler: handlers.NewRequestHandler(requestService),
+ jwtManager: jwtManager,
+ userService: userService,
+ config: cfg,
+ }
+
+ s.setupRoutes()
+ return s
+}
+
+// setupRoutes настраивает маршруты
+func (s *Server) setupRoutes() {
+ r := s.router
+
+ // Глобальные middleware
+ r.Use(chiMiddleware.RequestID)
+ r.Use(chiMiddleware.RealIP)
+ r.Use(middleware.Logger)
+ r.Use(middleware.Recovery)
+ r.Use(middleware.CORS(s.config.CORSAllowedOrigins))
+
+ // Health check
+ r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte("OK"))
+ })
+
+ // API v1
+ r.Route("/api/v1", func(r chi.Router) {
+ // Публичные маршруты (без аутентификации)
+ r.Group(func(r chi.Router) {
+ r.Post("/auth/register", s.authHandler.Register)
+ r.Post("/auth/login", s.authHandler.Login)
+ r.Post("/auth/refresh", s.authHandler.RefreshToken)
+
+ // Типы заявок (публичные)
+ r.Get("/request-types", s.requestHandler.ListRequestTypes)
+ })
+
+ // Защищенные маршруты (требуют аутентификации)
+ r.Group(func(r chi.Router) {
+ r.Use(middleware.AuthMiddleware(s.jwtManager))
+
+ // Auth
+ r.Get("/auth/me", s.authHandler.Me)
+ r.Post("/auth/logout", s.authHandler.Logout)
+
+ // Users
+ r.Get("/users/me", s.userHandler.GetMyProfile)
+ r.Patch("/users/me", s.userHandler.UpdateProfile)
+ r.Post("/users/me/location", s.userHandler.UpdateLocation)
+ r.Post("/users/me/verify-email", s.userHandler.VerifyEmail)
+ r.Get("/users/me/roles", s.userHandler.GetMyRoles)
+ r.Get("/users/me/permissions", s.userHandler.GetMyPermissions)
+ r.Get("/users/{id}", s.userHandler.GetProfile)
+
+ // Requests
+ r.Post("/requests", s.requestHandler.CreateRequest)
+ r.Get("/requests/my", s.requestHandler.GetMyRequests)
+ r.Get("/requests/nearby", s.requestHandler.FindNearbyRequests)
+ r.Get("/requests/bounds", s.requestHandler.FindRequestsInBounds)
+ r.Get("/requests/{id}", s.requestHandler.GetRequest)
+ r.Post("/requests/{id}/responses", s.requestHandler.CreateVolunteerResponse)
+ r.Get("/requests/{id}/responses", s.requestHandler.GetRequestResponses)
+ r.Post("/requests/{id}/responses/{response_id}/accept", s.requestHandler.AcceptVolunteerResponseHandler)
+ r.Post("/requests/{id}/complete", s.requestHandler.CompleteRequestWithRatingHandler)
+ })
+
+ // Маршруты для модераторов (требуют роль moderator или admin)
+ r.Group(func(r chi.Router) {
+ r.Use(middleware.AuthMiddleware(s.jwtManager))
+ r.Use(middleware.RequireAnyRole(s.userService, "moderator", "admin"))
+
+ // Модерация заявок
+ r.Get("/moderation/requests/pending", s.requestHandler.GetPendingModerationRequests)
+ r.Get("/moderation/requests/my", s.requestHandler.GetMyModeratedRequests)
+ r.Post("/moderation/requests/{id}/approve", s.requestHandler.ApproveRequest)
+ r.Post("/moderation/requests/{id}/reject", s.requestHandler.RejectRequest)
+ r.Post("/moderation/requests/{id}/moderate", s.requestHandler.ModerateRequestProcedure)
+ })
+
+ // Маршруты для администраторов (требуют роль admin)
+ r.Group(func(r chi.Router) {
+ r.Use(middleware.AuthMiddleware(s.jwtManager))
+ r.Use(middleware.RequireRole(s.userService, "admin"))
+
+ // Админские маршруты можно добавить позже
+ r.Get("/admin/users/{id}/roles/{role_id}", s.adminHandler.AssignRole)
+ })
+ })
+}
+
+// ServeHTTP реализует интерфейс http.Handler
+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ s.router.ServeHTTP(w, r)
+}
+
+// Router возвращает роутер Chi
+func (s *Server) Router() *chi.Mux {
+ return s.router
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..62a1ff0
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,139 @@
+package config
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/joho/godotenv"
+)
+
+// Config содержит конфигурацию приложения
+type Config struct {
+ // Database
+ DatabaseURL string
+
+ // Server
+ ServerHost string
+ ServerPort string
+ AppEnv string
+
+ // JWT
+ JWTSecret string
+ JWTAccessTokenTTL time.Duration
+ JWTRefreshTokenTTL time.Duration
+
+ // CORS
+ CORSAllowedOrigins []string
+ CORSAllowedMethods []string
+ CORSAllowedHeaders []string
+
+ // Rate Limiting
+ RateLimitRequestsPerMinute int
+ RateLimitBurst int
+
+ // Matching Algorithm
+ MatchingDefaultRadiusMeters int
+ MatchingDefaultLimit int
+}
+
+// Load загружает конфигурацию из переменных окружения
+func Load() (*Config, error) {
+ // Загружаем .env файл если он существует
+ _ = godotenv.Load()
+
+ cfg := &Config{
+ DatabaseURL: getDatabaseURL(),
+ ServerHost: getEnv("APP_HOST", "0.0.0.0"),
+ ServerPort: getEnv("APP_PORT", "8080"),
+ AppEnv: getEnv("APP_ENV", "development"),
+
+ JWTSecret: getEnv("JWT_SECRET", "change_me_to_secure_random_string_min_32_chars"),
+ JWTAccessTokenTTL: parseDuration(getEnv("JWT_ACCESS_TOKEN_EXPIRY", "15m")),
+ JWTRefreshTokenTTL: parseDuration(getEnv("JWT_REFRESH_TOKEN_EXPIRY", "168h")), // 7 days
+
+ RateLimitRequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 60),
+ RateLimitBurst: getEnvInt("RATE_LIMIT_BURST", 10),
+
+ MatchingDefaultRadiusMeters: getEnvInt("MATCHING_DEFAULT_RADIUS_METERS", 10000),
+ MatchingDefaultLimit: getEnvInt("MATCHING_DEFAULT_LIMIT", 20),
+ }
+
+ return cfg, nil
+}
+
+// NewDBPool создает новый пул соединений с БД
+func NewDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
+ config, err := pgxpool.ParseConfig(databaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse database URL: %w", err)
+ }
+
+ // Настройка пула соединений
+ config.MaxConns = 25
+ config.MinConns = 5
+ config.MaxConnLifetime = time.Hour
+ config.MaxConnIdleTime = time.Minute * 30
+ config.HealthCheckPeriod = time.Minute
+
+ pool, err := pgxpool.NewWithConfig(ctx, config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create connection pool: %w", err)
+ }
+
+ // Проверка соединения
+ if err := pool.Ping(ctx); err != nil {
+ return nil, fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ return pool, nil
+}
+
+// getDatabaseURL собирает DATABASE_URL из отдельных переменных или использует готовый
+func getDatabaseURL() string {
+ // Если задан DATABASE_URL, используем его
+ if url := os.Getenv("DATABASE_URL"); url != "" {
+ return url
+ }
+
+ // Иначе собираем из отдельных переменных
+ user := getEnv("DB_USER", "volontery")
+ password := getEnv("DB_PASSWORD", "volontery")
+ host := getEnv("DB_HOST", "localhost")
+ port := getEnv("DB_PORT", "5432")
+ name := getEnv("DB_NAME", "volontery_db")
+ sslmode := getEnv("DB_SSLMODE", "disable")
+
+ return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
+ user, password, host, port, name, sslmode)
+}
+
+// getEnv получает переменную окружения или возвращает значение по умолчанию
+func getEnv(key, defaultValue string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return defaultValue
+}
+
+// getEnvInt получает целочисленную переменную окружения
+func getEnvInt(key string, defaultValue int) int {
+ if value := os.Getenv(key); value != "" {
+ if intVal, err := strconv.Atoi(value); err == nil {
+ return intVal
+ }
+ }
+ return defaultValue
+}
+
+// parseDuration парсит duration строку
+func parseDuration(s string) time.Duration {
+ d, err := time.ParseDuration(s)
+ if err != nil {
+ return 15 * time.Minute // default
+ }
+ return d
+}
diff --git a/internal/database/auth.sql.go b/internal/database/auth.sql.go
new file mode 100644
index 0000000..2ffaa80
--- /dev/null
+++ b/internal/database/auth.sql.go
@@ -0,0 +1,548 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: auth.sql
+
+package database
+
+import (
+ "context"
+ "net/netip"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const CleanupExpiredSessions = `-- name: CleanupExpiredSessions :exec
+DELETE FROM user_sessions
+WHERE expires_at < CURRENT_TIMESTAMP
+ OR last_activity_at < CURRENT_TIMESTAMP - INTERVAL '7 days'
+`
+
+func (q *Queries) CleanupExpiredSessions(ctx context.Context) error {
+ _, err := q.db.Exec(ctx, CleanupExpiredSessions)
+ return err
+}
+
+const CleanupExpiredTokens = `-- name: CleanupExpiredTokens :exec
+DELETE FROM refresh_tokens
+WHERE expires_at < CURRENT_TIMESTAMP
+ OR (revoked = TRUE AND revoked_at < CURRENT_TIMESTAMP - INTERVAL '30 days')
+`
+
+func (q *Queries) CleanupExpiredTokens(ctx context.Context) error {
+ _, err := q.db.Exec(ctx, CleanupExpiredTokens)
+ return err
+}
+
+const CreateRefreshToken = `-- name: CreateRefreshToken :one
+
+INSERT INTO refresh_tokens (
+ user_id,
+ token,
+ expires_at,
+ user_agent,
+ ip_address
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5
+) RETURNING id, user_id, token, expires_at, user_agent, ip_address, revoked, revoked_at, created_at
+`
+
+type CreateRefreshTokenParams struct {
+ UserID int64 `json:"user_id"`
+ Token string `json:"token"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ UserAgent pgtype.Text `json:"user_agent"`
+ IpAddress *netip.Addr `json:"ip_address"`
+}
+
+// ============================================================================
+// Refresh Tokens
+// ============================================================================
+func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) {
+ row := q.db.QueryRow(ctx, CreateRefreshToken,
+ arg.UserID,
+ arg.Token,
+ arg.ExpiresAt,
+ arg.UserAgent,
+ arg.IpAddress,
+ )
+ var i RefreshToken
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.Token,
+ &i.ExpiresAt,
+ &i.UserAgent,
+ &i.IpAddress,
+ &i.Revoked,
+ &i.RevokedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const CreateUser = `-- name: CreateUser :one
+
+
+INSERT INTO users (
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ location,
+ address,
+ city
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ ST_SetSRID(ST_MakePoint($6, $7), 4326)::geography,
+ $8,
+ $9
+) RETURNING
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+`
+
+type CreateUserParams struct {
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ StMakepoint interface{} `json:"st_makepoint"`
+ StMakepoint_2 interface{} `json:"st_makepoint_2"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+}
+
+type CreateUserRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+// Фаза 1A: Аутентификация (КРИТИЧНО)
+// Запросы для регистрации, входа и управления токенами
+// ============================================================================
+// Пользователи
+// ============================================================================
+func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
+ row := q.db.QueryRow(ctx, CreateUser,
+ arg.Email,
+ arg.Phone,
+ arg.PasswordHash,
+ arg.FirstName,
+ arg.LastName,
+ arg.StMakepoint,
+ arg.StMakepoint_2,
+ arg.Address,
+ arg.City,
+ )
+ var i CreateUserRow
+ err := row.Scan(
+ &i.ID,
+ &i.Email,
+ &i.Phone,
+ &i.PasswordHash,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ &i.IsBlocked,
+ &i.EmailVerified,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastLoginAt,
+ &i.DeletedAt,
+ )
+ return i, err
+}
+
+const CreateUserSession = `-- name: CreateUserSession :one
+
+INSERT INTO user_sessions (
+ user_id,
+ session_token,
+ refresh_token_id,
+ expires_at,
+ user_agent,
+ ip_address,
+ device_info
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ $7
+) RETURNING id, user_id, session_token, refresh_token_id, expires_at, last_activity_at, user_agent, ip_address, device_info, created_at
+`
+
+type CreateUserSessionParams struct {
+ UserID int64 `json:"user_id"`
+ SessionToken string `json:"session_token"`
+ RefreshTokenID pgtype.Int8 `json:"refresh_token_id"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ UserAgent pgtype.Text `json:"user_agent"`
+ IpAddress *netip.Addr `json:"ip_address"`
+ DeviceInfo []byte `json:"device_info"`
+}
+
+// ============================================================================
+// User Sessions
+// ============================================================================
+func (q *Queries) CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error) {
+ row := q.db.QueryRow(ctx, CreateUserSession,
+ arg.UserID,
+ arg.SessionToken,
+ arg.RefreshTokenID,
+ arg.ExpiresAt,
+ arg.UserAgent,
+ arg.IpAddress,
+ arg.DeviceInfo,
+ )
+ var i UserSession
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.SessionToken,
+ &i.RefreshTokenID,
+ &i.ExpiresAt,
+ &i.LastActivityAt,
+ &i.UserAgent,
+ &i.IpAddress,
+ &i.DeviceInfo,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const EmailExists = `-- name: EmailExists :one
+SELECT EXISTS(
+ SELECT 1 FROM users
+ WHERE email = $1 AND deleted_at IS NULL
+)
+`
+
+func (q *Queries) EmailExists(ctx context.Context, email string) (bool, error) {
+ row := q.db.QueryRow(ctx, EmailExists, email)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const GetRefreshToken = `-- name: GetRefreshToken :one
+SELECT id, user_id, token, expires_at, user_agent, ip_address, revoked, revoked_at, created_at FROM refresh_tokens
+WHERE token = $1
+ AND revoked = FALSE
+ AND expires_at > CURRENT_TIMESTAMP
+`
+
+func (q *Queries) GetRefreshToken(ctx context.Context, token string) (RefreshToken, error) {
+ row := q.db.QueryRow(ctx, GetRefreshToken, token)
+ var i RefreshToken
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.Token,
+ &i.ExpiresAt,
+ &i.UserAgent,
+ &i.IpAddress,
+ &i.Revoked,
+ &i.RevokedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetUserByEmail = `-- name: GetUserByEmail :one
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE email = $1 AND deleted_at IS NULL
+`
+
+type GetUserByEmailRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
+ row := q.db.QueryRow(ctx, GetUserByEmail, email)
+ var i GetUserByEmailRow
+ err := row.Scan(
+ &i.ID,
+ &i.Email,
+ &i.Phone,
+ &i.PasswordHash,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ &i.IsBlocked,
+ &i.EmailVerified,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastLoginAt,
+ &i.DeletedAt,
+ )
+ return i, err
+}
+
+const GetUserByID = `-- name: GetUserByID :one
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE id = $1 AND deleted_at IS NULL
+`
+
+type GetUserByIDRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error) {
+ row := q.db.QueryRow(ctx, GetUserByID, id)
+ var i GetUserByIDRow
+ err := row.Scan(
+ &i.ID,
+ &i.Email,
+ &i.Phone,
+ &i.PasswordHash,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ &i.IsBlocked,
+ &i.EmailVerified,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastLoginAt,
+ &i.DeletedAt,
+ )
+ return i, err
+}
+
+const GetUserSession = `-- name: GetUserSession :one
+SELECT id, user_id, session_token, refresh_token_id, expires_at, last_activity_at, user_agent, ip_address, device_info, created_at FROM user_sessions
+WHERE session_token = $1
+ AND expires_at > CURRENT_TIMESTAMP
+`
+
+func (q *Queries) GetUserSession(ctx context.Context, sessionToken string) (UserSession, error) {
+ row := q.db.QueryRow(ctx, GetUserSession, sessionToken)
+ var i UserSession
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.SessionToken,
+ &i.RefreshTokenID,
+ &i.ExpiresAt,
+ &i.LastActivityAt,
+ &i.UserAgent,
+ &i.IpAddress,
+ &i.DeviceInfo,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const InvalidateAllUserSessions = `-- name: InvalidateAllUserSessions :exec
+DELETE FROM user_sessions
+WHERE user_id = $1
+`
+
+func (q *Queries) InvalidateAllUserSessions(ctx context.Context, userID int64) error {
+ _, err := q.db.Exec(ctx, InvalidateAllUserSessions, userID)
+ return err
+}
+
+const InvalidateUserSession = `-- name: InvalidateUserSession :exec
+DELETE FROM user_sessions
+WHERE id = $1
+`
+
+func (q *Queries) InvalidateUserSession(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, InvalidateUserSession, id)
+ return err
+}
+
+const RevokeAllUserTokens = `-- name: RevokeAllUserTokens :exec
+UPDATE refresh_tokens
+SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
+WHERE user_id = $1 AND revoked = FALSE
+`
+
+func (q *Queries) RevokeAllUserTokens(ctx context.Context, userID int64) error {
+ _, err := q.db.Exec(ctx, RevokeAllUserTokens, userID)
+ return err
+}
+
+const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec
+UPDATE refresh_tokens
+SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) RevokeRefreshToken(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, RevokeRefreshToken, id)
+ return err
+}
+
+const UpdateLastLogin = `-- name: UpdateLastLogin :exec
+UPDATE users
+SET last_login_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) UpdateLastLogin(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, UpdateLastLogin, id)
+ return err
+}
+
+const UpdateSessionActivity = `-- name: UpdateSessionActivity :exec
+UPDATE user_sessions
+SET last_activity_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) UpdateSessionActivity(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, UpdateSessionActivity, id)
+ return err
+}
diff --git a/internal/database/db.go b/internal/database/db.go
new file mode 100644
index 0000000..bdf4241
--- /dev/null
+++ b/internal/database/db.go
@@ -0,0 +1,32 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+type DBTX interface {
+ Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
+ Query(context.Context, string, ...interface{}) (pgx.Rows, error)
+ QueryRow(context.Context, string, ...interface{}) pgx.Row
+}
+
+func New(db DBTX) *Queries {
+ return &Queries{db: db}
+}
+
+type Queries struct {
+ db DBTX
+}
+
+func (q *Queries) WithTx(tx pgx.Tx) *Queries {
+ return &Queries{
+ db: tx,
+ }
+}
diff --git a/internal/database/geography.go b/internal/database/geography.go
new file mode 100644
index 0000000..9f4ad8f
--- /dev/null
+++ b/internal/database/geography.go
@@ -0,0 +1,43 @@
+package database
+
+import (
+ "database/sql/driver"
+ "fmt"
+)
+
+// GeographyPoint представляет PostGIS GEOGRAPHY(POINT) в WGS84
+// Для Sprint 1 мы используем ST_X() и ST_Y() в SELECT запросах,
+// поэтому этот тип используется только для INSERT операций
+type GeographyPoint struct {
+ Longitude float64
+ Latitude float64
+ Valid bool
+}
+
+func (g *GeographyPoint) Scan(value interface{}) error {
+ if value == nil {
+ g.Valid = false
+ return nil
+ }
+ // В Sprint 1 мы не используем Scan, так как извлекаем координаты через ST_X/ST_Y
+ // Для production: использовать github.com/twpayne/go-geom для полноценного парсинга
+ return fmt.Errorf("GeographyPoint.Scan not implemented - use ST_X/ST_Y in queries")
+}
+
+// Value реализует driver.Valuer для использования в INSERT/UPDATE запросах
+func (g GeographyPoint) Value() (driver.Value, error) {
+ if !g.Valid {
+ return nil, nil
+ }
+ // Возвращаем WKT формат с SRID для PostGIS
+ return fmt.Sprintf("SRID=4326;POINT(%f %f)", g.Longitude, g.Latitude), nil
+}
+
+// NewGeographyPoint создает новую точку с координатами
+func NewGeographyPoint(lon, lat float64) *GeographyPoint {
+ return &GeographyPoint{
+ Longitude: lon,
+ Latitude: lat,
+ Valid: true,
+ }
+}
diff --git a/internal/database/geospatial.sql.go b/internal/database/geospatial.sql.go
new file mode 100644
index 0000000..7f449dc
--- /dev/null
+++ b/internal/database/geospatial.sql.go
@@ -0,0 +1,412 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: geospatial.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const CountRequestsNearby = `-- name: CountRequestsNearby :one
+
+SELECT COUNT(*) FROM requests r
+WHERE r.deleted_at IS NULL
+ AND r.status = $3
+ AND ST_DWithin(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
+ $4
+ )
+`
+
+type CountRequestsNearbyParams struct {
+ StMakepoint interface{} `json:"st_makepoint"`
+ StMakepoint_2 interface{} `json:"st_makepoint_2"`
+ Status NullRequestStatus `json:"status"`
+ StDwithin interface{} `json:"st_dwithin"`
+}
+
+// ============================================================================
+// Подсчет заявок поблизости
+// ============================================================================
+func (q *Queries) CountRequestsNearby(ctx context.Context, arg CountRequestsNearbyParams) (int64, error) {
+ row := q.db.QueryRow(ctx, CountRequestsNearby,
+ arg.StMakepoint,
+ arg.StMakepoint_2,
+ arg.Status,
+ arg.StDwithin,
+ )
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const FindNearestRequestsForVolunteer = `-- name: FindNearestRequestsForVolunteer :many
+
+SELECT
+ r.id,
+ r.title,
+ r.description,
+ r.urgency,
+ r.status,
+ r.created_at,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ ST_Distance(
+ r.location,
+ (SELECT u.location FROM users u WHERE u.id = $1)
+ ) as distance_meters,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.deleted_at IS NULL
+ AND r.status = 'approved'
+ AND r.assigned_volunteer_id IS NULL
+ AND ST_DWithin(
+ r.location,
+ (SELECT u.location FROM users u WHERE u.id = $1),
+ $2
+ )
+ORDER BY
+ CASE r.urgency
+ WHEN 'urgent' THEN 1
+ WHEN 'high' THEN 2
+ WHEN 'medium' THEN 3
+ ELSE 4
+ END,
+ distance_meters
+LIMIT $3
+`
+
+type FindNearestRequestsForVolunteerParams struct {
+ ID int64 `json:"id"`
+ StDwithin interface{} `json:"st_dwithin"`
+ Limit int32 `json:"limit"`
+}
+
+type FindNearestRequestsForVolunteerRow struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Urgency pgtype.Text `json:"urgency"`
+ Status NullRequestStatus `json:"status"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ DistanceMeters interface{} `json:"distance_meters"`
+ RequestTypeName string `json:"request_type_name"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+}
+
+// ============================================================================
+// Поиск ближайших заявок для волонтера
+// ============================================================================
+func (q *Queries) FindNearestRequestsForVolunteer(ctx context.Context, arg FindNearestRequestsForVolunteerParams) ([]FindNearestRequestsForVolunteerRow, error) {
+ rows, err := q.db.Query(ctx, FindNearestRequestsForVolunteer, arg.ID, arg.StDwithin, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []FindNearestRequestsForVolunteerRow{}
+ for rows.Next() {
+ var i FindNearestRequestsForVolunteerRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.Description,
+ &i.Urgency,
+ &i.Status,
+ &i.CreatedAt,
+ &i.Latitude,
+ &i.Longitude,
+ &i.DistanceMeters,
+ &i.RequestTypeName,
+ &i.RequestTypeIcon,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const FindRequestsInBounds = `-- name: FindRequestsInBounds :many
+
+SELECT
+ r.id,
+ r.title,
+ r.urgency,
+ r.status,
+ r.created_at,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ rt.icon as request_type_icon,
+ rt.name as request_type_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.deleted_at IS NULL
+ AND r.status::text = ANY($1::text[])
+ AND ST_Within(
+ r.location::geometry,
+ ST_MakeEnvelope($2, $3, $4, $5, 4326)
+ )
+ORDER BY r.created_at DESC
+LIMIT 200
+`
+
+type FindRequestsInBoundsParams struct {
+ Column1 []string `json:"column_1"`
+ StMakeenvelope interface{} `json:"st_makeenvelope"`
+ StMakeenvelope_2 interface{} `json:"st_makeenvelope_2"`
+ StMakeenvelope_3 interface{} `json:"st_makeenvelope_3"`
+ StMakeenvelope_4 interface{} `json:"st_makeenvelope_4"`
+}
+
+type FindRequestsInBoundsRow struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Urgency pgtype.Text `json:"urgency"`
+ Status NullRequestStatus `json:"status"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+ RequestTypeName string `json:"request_type_name"`
+}
+
+// ============================================================================
+// Поиск заявок в прямоугольной области (для карты)
+// ============================================================================
+func (q *Queries) FindRequestsInBounds(ctx context.Context, arg FindRequestsInBoundsParams) ([]FindRequestsInBoundsRow, error) {
+ rows, err := q.db.Query(ctx, FindRequestsInBounds,
+ arg.Column1,
+ arg.StMakeenvelope,
+ arg.StMakeenvelope_2,
+ arg.StMakeenvelope_3,
+ arg.StMakeenvelope_4,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []FindRequestsInBoundsRow{}
+ for rows.Next() {
+ var i FindRequestsInBoundsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.Urgency,
+ &i.Status,
+ &i.CreatedAt,
+ &i.Latitude,
+ &i.Longitude,
+ &i.RequestTypeIcon,
+ &i.RequestTypeName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const FindRequestsNearby = `-- name: FindRequestsNearby :many
+
+
+SELECT
+ r.id,
+ r.title,
+ r.description,
+ r.address,
+ r.city,
+ r.urgency,
+ r.status,
+ r.created_at,
+ r.desired_completion_date,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ ST_Distance(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
+ ) as distance_meters,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.deleted_at IS NULL
+ AND r.status::text = ANY($3::text[])
+ AND ST_DWithin(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
+ $4
+ )
+ORDER BY distance_meters
+LIMIT $5 OFFSET $6
+`
+
+type FindRequestsNearbyParams struct {
+ StMakepoint interface{} `json:"st_makepoint"`
+ StMakepoint_2 interface{} `json:"st_makepoint_2"`
+ Column3 []string `json:"column_3"`
+ StDwithin interface{} `json:"st_dwithin"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type FindRequestsNearbyRow struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ Urgency pgtype.Text `json:"urgency"`
+ Status NullRequestStatus `json:"status"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ DistanceMeters interface{} `json:"distance_meters"`
+ RequestTypeName string `json:"request_type_name"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+ RequesterName interface{} `json:"requester_name"`
+}
+
+// Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
+// PostGIS запросы для поиска заявок по геолокации
+// ============================================================================
+// Поиск заявок рядом с точкой
+// ============================================================================
+func (q *Queries) FindRequestsNearby(ctx context.Context, arg FindRequestsNearbyParams) ([]FindRequestsNearbyRow, error) {
+ rows, err := q.db.Query(ctx, FindRequestsNearby,
+ arg.StMakepoint,
+ arg.StMakepoint_2,
+ arg.Column3,
+ arg.StDwithin,
+ arg.Limit,
+ arg.Offset,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []FindRequestsNearbyRow{}
+ for rows.Next() {
+ var i FindRequestsNearbyRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Title,
+ &i.Description,
+ &i.Address,
+ &i.City,
+ &i.Urgency,
+ &i.Status,
+ &i.CreatedAt,
+ &i.DesiredCompletionDate,
+ &i.Latitude,
+ &i.Longitude,
+ &i.DistanceMeters,
+ &i.RequestTypeName,
+ &i.RequestTypeIcon,
+ &i.RequesterName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const FindVolunteersNearRequest = `-- name: FindVolunteersNearRequest :many
+
+SELECT
+ u.id,
+ (u.first_name || ' ' || u.last_name) as full_name,
+ u.avatar_url,
+ u.volunteer_rating,
+ u.completed_requests_count,
+ ST_Y(u.location::geometry) as latitude,
+ ST_X(u.location::geometry) as longitude,
+ ST_Distance(
+ u.location,
+ (SELECT req.location FROM requests req WHERE req.id = $1)
+ ) as distance_meters
+FROM users u
+JOIN user_roles ur ON ur.user_id = u.id
+JOIN roles r ON r.id = ur.role_id
+WHERE r.name = 'volunteer'
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+ AND u.location IS NOT NULL
+ AND ST_DWithin(
+ u.location,
+ (SELECT req.location FROM requests req WHERE req.id = $1),
+ $2
+ )
+ORDER BY distance_meters
+LIMIT $3
+`
+
+type FindVolunteersNearRequestParams struct {
+ ID int64 `json:"id"`
+ StDwithin interface{} `json:"st_dwithin"`
+ Limit int32 `json:"limit"`
+}
+
+type FindVolunteersNearRequestRow struct {
+ ID int64 `json:"id"`
+ FullName interface{} `json:"full_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ DistanceMeters interface{} `json:"distance_meters"`
+}
+
+// ============================================================================
+// Поиск волонтеров рядом с заявкой
+// ============================================================================
+func (q *Queries) FindVolunteersNearRequest(ctx context.Context, arg FindVolunteersNearRequestParams) ([]FindVolunteersNearRequestRow, error) {
+ rows, err := q.db.Query(ctx, FindVolunteersNearRequest, arg.ID, arg.StDwithin, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []FindVolunteersNearRequestRow{}
+ for rows.Next() {
+ var i FindVolunteersNearRequestRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.FullName,
+ &i.AvatarUrl,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.Latitude,
+ &i.Longitude,
+ &i.DistanceMeters,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
diff --git a/internal/database/models.go b/internal/database/models.go
new file mode 100644
index 0000000..97a036d
--- /dev/null
+++ b/internal/database/models.go
@@ -0,0 +1,530 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package database
+
+import (
+ "database/sql/driver"
+ "fmt"
+ "net/netip"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// Статусы жизненного цикла жалобы
+type ComplaintStatus string
+
+const (
+ ComplaintStatusPending ComplaintStatus = "pending"
+ ComplaintStatusInReview ComplaintStatus = "in_review"
+ ComplaintStatusResolved ComplaintStatus = "resolved"
+ ComplaintStatusRejected ComplaintStatus = "rejected"
+)
+
+func (e *ComplaintStatus) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = ComplaintStatus(s)
+ case string:
+ *e = ComplaintStatus(s)
+ default:
+ return fmt.Errorf("unsupported scan type for ComplaintStatus: %T", src)
+ }
+ return nil
+}
+
+type NullComplaintStatus struct {
+ ComplaintStatus ComplaintStatus `json:"complaint_status"`
+ Valid bool `json:"valid"` // Valid is true if ComplaintStatus is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullComplaintStatus) Scan(value interface{}) error {
+ if value == nil {
+ ns.ComplaintStatus, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.ComplaintStatus.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullComplaintStatus) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.ComplaintStatus), nil
+}
+
+func AllComplaintStatusValues() []ComplaintStatus {
+ return []ComplaintStatus{
+ ComplaintStatusPending,
+ ComplaintStatusInReview,
+ ComplaintStatusResolved,
+ ComplaintStatusRejected,
+ }
+}
+
+// Типы жалоб на пользователей
+type ComplaintType string
+
+const (
+ ComplaintTypeInappropriateBehavior ComplaintType = "inappropriate_behavior"
+ ComplaintTypeNoShow ComplaintType = "no_show"
+ ComplaintTypeFraud ComplaintType = "fraud"
+ ComplaintTypeSpam ComplaintType = "spam"
+ ComplaintTypeOther ComplaintType = "other"
+)
+
+func (e *ComplaintType) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = ComplaintType(s)
+ case string:
+ *e = ComplaintType(s)
+ default:
+ return fmt.Errorf("unsupported scan type for ComplaintType: %T", src)
+ }
+ return nil
+}
+
+type NullComplaintType struct {
+ ComplaintType ComplaintType `json:"complaint_type"`
+ Valid bool `json:"valid"` // Valid is true if ComplaintType is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullComplaintType) Scan(value interface{}) error {
+ if value == nil {
+ ns.ComplaintType, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.ComplaintType.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullComplaintType) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.ComplaintType), nil
+}
+
+func AllComplaintTypeValues() []ComplaintType {
+ return []ComplaintType{
+ ComplaintTypeInappropriateBehavior,
+ ComplaintTypeNoShow,
+ ComplaintTypeFraud,
+ ComplaintTypeSpam,
+ ComplaintTypeOther,
+ }
+}
+
+// Типы действий модераторов для аудита
+type ModeratorActionType string
+
+const (
+ ModeratorActionTypeApproveRequest ModeratorActionType = "approve_request"
+ ModeratorActionTypeRejectRequest ModeratorActionType = "reject_request"
+ ModeratorActionTypeBlockUser ModeratorActionType = "block_user"
+ ModeratorActionTypeUnblockUser ModeratorActionType = "unblock_user"
+ ModeratorActionTypeResolveComplaint ModeratorActionType = "resolve_complaint"
+ ModeratorActionTypeRejectComplaint ModeratorActionType = "reject_complaint"
+ ModeratorActionTypeEditRequest ModeratorActionType = "edit_request"
+ ModeratorActionTypeDeleteRequest ModeratorActionType = "delete_request"
+)
+
+func (e *ModeratorActionType) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = ModeratorActionType(s)
+ case string:
+ *e = ModeratorActionType(s)
+ default:
+ return fmt.Errorf("unsupported scan type for ModeratorActionType: %T", src)
+ }
+ return nil
+}
+
+type NullModeratorActionType struct {
+ ModeratorActionType ModeratorActionType `json:"moderator_action_type"`
+ Valid bool `json:"valid"` // Valid is true if ModeratorActionType is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullModeratorActionType) Scan(value interface{}) error {
+ if value == nil {
+ ns.ModeratorActionType, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.ModeratorActionType.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullModeratorActionType) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.ModeratorActionType), nil
+}
+
+func AllModeratorActionTypeValues() []ModeratorActionType {
+ return []ModeratorActionType{
+ ModeratorActionTypeApproveRequest,
+ ModeratorActionTypeRejectRequest,
+ ModeratorActionTypeBlockUser,
+ ModeratorActionTypeUnblockUser,
+ ModeratorActionTypeResolveComplaint,
+ ModeratorActionTypeRejectComplaint,
+ ModeratorActionTypeEditRequest,
+ ModeratorActionTypeDeleteRequest,
+ }
+}
+
+// Статусы жизненного цикла заявки на помощь
+type RequestStatus string
+
+const (
+ RequestStatusPendingModeration RequestStatus = "pending_moderation"
+ RequestStatusApproved RequestStatus = "approved"
+ RequestStatusInProgress RequestStatus = "in_progress"
+ RequestStatusCompleted RequestStatus = "completed"
+ RequestStatusCancelled RequestStatus = "cancelled"
+ RequestStatusRejected RequestStatus = "rejected"
+)
+
+func (e *RequestStatus) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = RequestStatus(s)
+ case string:
+ *e = RequestStatus(s)
+ default:
+ return fmt.Errorf("unsupported scan type for RequestStatus: %T", src)
+ }
+ return nil
+}
+
+type NullRequestStatus struct {
+ RequestStatus RequestStatus `json:"request_status"`
+ Valid bool `json:"valid"` // Valid is true if RequestStatus is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullRequestStatus) Scan(value interface{}) error {
+ if value == nil {
+ ns.RequestStatus, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.RequestStatus.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullRequestStatus) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.RequestStatus), nil
+}
+
+func AllRequestStatusValues() []RequestStatus {
+ return []RequestStatus{
+ RequestStatusPendingModeration,
+ RequestStatusApproved,
+ RequestStatusInProgress,
+ RequestStatusCompleted,
+ RequestStatusCancelled,
+ RequestStatusRejected,
+ }
+}
+
+// Статусы отклика волонтёра на заявку
+type ResponseStatus string
+
+const (
+ ResponseStatusPending ResponseStatus = "pending"
+ ResponseStatusAccepted ResponseStatus = "accepted"
+ ResponseStatusRejected ResponseStatus = "rejected"
+ ResponseStatusCancelled ResponseStatus = "cancelled"
+)
+
+func (e *ResponseStatus) Scan(src interface{}) error {
+ switch s := src.(type) {
+ case []byte:
+ *e = ResponseStatus(s)
+ case string:
+ *e = ResponseStatus(s)
+ default:
+ return fmt.Errorf("unsupported scan type for ResponseStatus: %T", src)
+ }
+ return nil
+}
+
+type NullResponseStatus struct {
+ ResponseStatus ResponseStatus `json:"response_status"`
+ Valid bool `json:"valid"` // Valid is true if ResponseStatus is not NULL
+}
+
+// Scan implements the Scanner interface.
+func (ns *NullResponseStatus) Scan(value interface{}) error {
+ if value == nil {
+ ns.ResponseStatus, ns.Valid = "", false
+ return nil
+ }
+ ns.Valid = true
+ return ns.ResponseStatus.Scan(value)
+}
+
+// Value implements the driver Valuer interface.
+func (ns NullResponseStatus) Value() (driver.Value, error) {
+ if !ns.Valid {
+ return nil, nil
+ }
+ return string(ns.ResponseStatus), nil
+}
+
+func AllResponseStatusValues() []ResponseStatus {
+ return []ResponseStatus{
+ ResponseStatusPending,
+ ResponseStatusAccepted,
+ ResponseStatusRejected,
+ ResponseStatusCancelled,
+ }
+}
+
+// Жалобы пользователей друг на друга
+type Complaint struct {
+ ID int64 `json:"id"`
+ // Пользователь, подающий жалобу
+ ComplainantID int64 `json:"complainant_id"`
+ // Пользователь, на которого жалуются
+ DefendantID int64 `json:"defendant_id"`
+ RequestID pgtype.Int8 `json:"request_id"`
+ Type ComplaintType `json:"type"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Status NullComplaintStatus `json:"status"`
+ ModeratorID pgtype.Int8 `json:"moderator_id"`
+ ModeratorComment pgtype.Text `json:"moderator_comment"`
+ ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+// Полный аудит всех действий модераторов в системе
+type ModeratorAction struct {
+ ID int64 `json:"id"`
+ ModeratorID int64 `json:"moderator_id"`
+ ActionType ModeratorActionType `json:"action_type"`
+ TargetUserID pgtype.Int8 `json:"target_user_id"`
+ TargetRequestID pgtype.Int8 `json:"target_request_id"`
+ TargetComplaintID pgtype.Int8 `json:"target_complaint_id"`
+ Comment pgtype.Text `json:"comment"`
+ // Дополнительные данные в JSON (изменённые поля, причины и т.д.)
+ Metadata []byte `json:"metadata"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Справочник разрешений для RBAC системы
+type Permission struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ // Ресурс: request, user, complaint и т.д.
+ Resource string `json:"resource"`
+ // Действие: create, read, update, delete, moderate
+ Action string `json:"action"`
+ Description pgtype.Text `json:"description"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Рейтинги волонтёров за выполненную помощь
+type Rating struct {
+ ID int64 `json:"id"`
+ // Связь с откликом (один рейтинг на один отклик)
+ VolunteerResponseID int64 `json:"volunteer_response_id"`
+ // Денормализация для быстрого доступа
+ VolunteerID int64 `json:"volunteer_id"`
+ // Кто оставил рейтинг
+ RequesterID int64 `json:"requester_id"`
+ RequestID int64 `json:"request_id"`
+ // Оценка от 1 до 5 звёзд
+ Rating int32 `json:"rating"`
+ Comment pgtype.Text `json:"comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+// Refresh токены для JWT аутентификации
+type RefreshToken struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ // Хеш refresh токена
+ Token string `json:"token"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ UserAgent pgtype.Text `json:"user_agent"`
+ IpAddress *netip.Addr `json:"ip_address"`
+ // Токен отозван (для принудительного логаута)
+ Revoked pgtype.Bool `json:"revoked"`
+ RevokedAt pgtype.Timestamptz `json:"revoked_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Заявки на помощь от маломобильных граждан
+type Request struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ // Волонтёр, который взял заявку в работу
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ // Координаты места, где нужна помощь (WGS84, SRID 4326)
+ Location interface{} `json:"location"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ // Срочность: low, medium, high, urgent
+ Urgency pgtype.Text `json:"urgency"`
+ Status NullRequestStatus `json:"status"`
+ ModerationComment pgtype.Text `json:"moderation_comment"`
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ ModeratedAt pgtype.Timestamptz `json:"moderated_at"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ // Дополнительная информация: код домофона, этаж и т.д.
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ CompletedAt pgtype.Timestamptz `json:"completed_at"`
+ // Soft delete - дата удаления заявки
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+// Полная история изменения статусов заявок для аудита
+type RequestStatusHistory struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ // Предыдущий статус (NULL при создании)
+ FromStatus NullRequestStatus `json:"from_status"`
+ ToStatus RequestStatus `json:"to_status"`
+ ChangedBy int64 `json:"changed_by"`
+ Comment pgtype.Text `json:"comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Справочник типов помощи (продукты, медикаменты, техника)
+type RequestType struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Description pgtype.Text `json:"description"`
+ // Название иконки для UI
+ Icon pgtype.Text `json:"icon"`
+ // Активность типа (для скрытия без удаления)
+ IsActive pgtype.Bool `json:"is_active"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Справочник ролей для RBAC системы
+type Role struct {
+ ID int64 `json:"id"`
+ // Уникальное название роли
+ Name string `json:"name"`
+ Description pgtype.Text `json:"description"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Связь ролей и разрешений (Many-to-Many) для гибкой системы RBAC
+type RolePermission struct {
+ ID int64 `json:"id"`
+ RoleID int64 `json:"role_id"`
+ PermissionID int64 `json:"permission_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Пользователи системы: маломобильные граждане, волонтёры, модераторы
+type User struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ // Координаты домашнего адреса в формате WGS84 (SRID 4326)
+ Location interface{} `json:"location"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ // Средний рейтинг волонтёра (0-5), обновляется триггером
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ // Количество выполненных заявок, обновляется триггером
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+ // Soft delete - дата удаления пользователя
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+// Блокировки пользователей модераторами
+type UserBlock struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ BlockedBy int64 `json:"blocked_by"`
+ ComplaintID pgtype.Int8 `json:"complaint_id"`
+ Reason string `json:"reason"`
+ // Дата окончания блокировки (NULL = бессрочная)
+ BlockedUntil pgtype.Timestamptz `json:"blocked_until"`
+ // Активна ли блокировка в данный момент
+ IsActive pgtype.Bool `json:"is_active"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UnblockedAt pgtype.Timestamptz `json:"unblocked_at"`
+ UnblockedBy pgtype.Int8 `json:"unblocked_by"`
+}
+
+// Связь пользователей и ролей (Many-to-Many). Один пользователь может иметь несколько ролей
+type UserRole struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ RoleID int64 `json:"role_id"`
+ AssignedAt pgtype.Timestamptz `json:"assigned_at"`
+ // Кто назначил роль (для аудита)
+ AssignedBy pgtype.Int8 `json:"assigned_by"`
+}
+
+// Активные сессии пользователей для отслеживания активности
+type UserSession struct {
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ SessionToken string `json:"session_token"`
+ RefreshTokenID pgtype.Int8 `json:"refresh_token_id"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ // Последняя активность пользователя в сессии
+ LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
+ UserAgent pgtype.Text `json:"user_agent"`
+ IpAddress *netip.Addr `json:"ip_address"`
+ // Информация об устройстве: ОС, браузер, версия и т.д.
+ DeviceInfo []byte `json:"device_info"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+// Отклики волонтёров на заявки помощи
+type VolunteerResponse struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ Status NullResponseStatus `json:"status"`
+ // Сообщение волонтёра при отклике (опционально)
+ Message pgtype.Text `json:"message"`
+ // Время создания отклика
+ RespondedAt pgtype.Timestamptz `json:"responded_at"`
+ AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
+ RejectedAt pgtype.Timestamptz `json:"rejected_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
diff --git a/internal/database/querier.go b/internal/database/querier.go
new file mode 100644
index 0000000..997f5dd
--- /dev/null
+++ b/internal/database/querier.go
@@ -0,0 +1,185 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+type Querier interface {
+ AcceptVolunteerResponse(ctx context.Context, id int64) error
+ ApproveRequest(ctx context.Context, arg ApproveRequestParams) error
+ AssignRoleToUser(ctx context.Context, arg AssignRoleToUserParams) (UserRole, error)
+ AssignVolunteerToRequest(ctx context.Context, arg AssignVolunteerToRequestParams) error
+ BlockUser(ctx context.Context, id int64) error
+ CalculateVolunteerAverageRating(ctx context.Context, volunteerID int64) (CalculateVolunteerAverageRatingRow, error)
+ // ============================================================================
+ // Хранимые процедуры
+ // ============================================================================
+ CallAcceptVolunteerResponse(ctx context.Context, arg CallAcceptVolunteerResponseParams) (CallAcceptVolunteerResponseRow, error)
+ CallCompleteRequestWithRating(ctx context.Context, arg CallCompleteRequestWithRatingParams) (CallCompleteRequestWithRatingRow, error)
+ CallModerateRequest(ctx context.Context, arg CallModerateRequestParams) (CallModerateRequestRow, error)
+ CancelRequest(ctx context.Context, id int64) error
+ CleanupExpiredSessions(ctx context.Context) error
+ CleanupExpiredTokens(ctx context.Context) error
+ CompleteRequest(ctx context.Context, id int64) error
+ CountPendingResponsesByVolunteer(ctx context.Context, volunteerID int64) (int64, error)
+ // ============================================================================
+ // Статистика
+ // ============================================================================
+ CountRequestsByRequester(ctx context.Context, requesterID int64) (int64, error)
+ CountRequestsByStatus(ctx context.Context, status NullRequestStatus) (int64, error)
+ // ============================================================================
+ // Подсчет заявок поблизости
+ // ============================================================================
+ CountRequestsNearby(ctx context.Context, arg CountRequestsNearbyParams) (int64, error)
+ CountResponsesByRequest(ctx context.Context, requestID int64) (int64, error)
+ // ============================================================================
+ // Рейтинги
+ // ============================================================================
+ CreateRating(ctx context.Context, arg CreateRatingParams) (Rating, error)
+ // ============================================================================
+ // Refresh Tokens
+ // ============================================================================
+ CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error)
+ // Фаза 2A: Управление заявками (ВЫСОКИЙ ПРИОРИТЕТ)
+ // CRUD операции для заявок на помощь
+ // ============================================================================
+ // Создание и получение заявок
+ // ============================================================================
+ CreateRequest(ctx context.Context, arg CreateRequestParams) (CreateRequestRow, error)
+ // ============================================================================
+ // История изменения статусов заявок
+ // ============================================================================
+ CreateStatusHistoryEntry(ctx context.Context, arg CreateStatusHistoryEntryParams) (RequestStatusHistory, error)
+ // Фаза 1A: Аутентификация (КРИТИЧНО)
+ // Запросы для регистрации, входа и управления токенами
+ // ============================================================================
+ // Пользователи
+ // ============================================================================
+ CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error)
+ // ============================================================================
+ // User Sessions
+ // ============================================================================
+ CreateUserSession(ctx context.Context, arg CreateUserSessionParams) (UserSession, error)
+ // Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
+ // Запросы для управления откликами волонтеров и историей изменения статусов заявок
+ // ============================================================================
+ // Отклики волонтеров
+ // ============================================================================
+ CreateVolunteerResponse(ctx context.Context, arg CreateVolunteerResponseParams) (VolunteerResponse, error)
+ // ============================================================================
+ // Удаление заявок
+ // ============================================================================
+ DeleteRequest(ctx context.Context, arg DeleteRequestParams) error
+ EmailExists(ctx context.Context, email string) (bool, error)
+ // ============================================================================
+ // Поиск ближайших заявок для волонтера
+ // ============================================================================
+ FindNearestRequestsForVolunteer(ctx context.Context, arg FindNearestRequestsForVolunteerParams) ([]FindNearestRequestsForVolunteerRow, error)
+ // ============================================================================
+ // Поиск заявок в прямоугольной области (для карты)
+ // ============================================================================
+ FindRequestsInBounds(ctx context.Context, arg FindRequestsInBoundsParams) ([]FindRequestsInBoundsRow, error)
+ // Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
+ // PostGIS запросы для поиска заявок по геолокации
+ // ============================================================================
+ // Поиск заявок рядом с точкой
+ // ============================================================================
+ FindRequestsNearby(ctx context.Context, arg FindRequestsNearbyParams) ([]FindRequestsNearbyRow, error)
+ // ============================================================================
+ // Поиск волонтеров рядом с заявкой
+ // ============================================================================
+ FindVolunteersNearRequest(ctx context.Context, arg FindVolunteersNearRequestParams) ([]FindVolunteersNearRequestRow, error)
+ GetLatestStatusChange(ctx context.Context, requestID int64) (GetLatestStatusChangeRow, error)
+ GetModeratedRequests(ctx context.Context, arg GetModeratedRequestsParams) ([]GetModeratedRequestsRow, error)
+ GetModeratorActionsByModerator(ctx context.Context, arg GetModeratorActionsByModeratorParams) ([]GetModeratorActionsByModeratorRow, error)
+ // ============================================================================
+ // Аудит действий модераторов
+ // ============================================================================
+ GetModeratorActionsByRequest(ctx context.Context, targetRequestID pgtype.Int8) ([]GetModeratorActionsByRequestRow, error)
+ // ============================================================================
+ // Модерация заявок
+ // ============================================================================
+ GetPendingModerationRequests(ctx context.Context, arg GetPendingModerationRequestsParams) ([]GetPendingModerationRequestsRow, error)
+ GetPermissionByName(ctx context.Context, name string) (Permission, error)
+ GetRatingByResponseID(ctx context.Context, volunteerResponseID int64) (Rating, error)
+ GetRatingsByVolunteer(ctx context.Context, arg GetRatingsByVolunteerParams) ([]GetRatingsByVolunteerRow, error)
+ GetRefreshToken(ctx context.Context, token string) (RefreshToken, error)
+ GetRequestByID(ctx context.Context, id int64) (GetRequestByIDRow, error)
+ GetRequestStatusHistory(ctx context.Context, requestID int64) ([]GetRequestStatusHistoryRow, error)
+ GetRequestTypeByID(ctx context.Context, id int64) (RequestType, error)
+ GetRequestTypeByName(ctx context.Context, name string) (RequestType, error)
+ GetRequestsByRequester(ctx context.Context, arg GetRequestsByRequesterParams) ([]GetRequestsByRequesterRow, error)
+ GetRequestsByStatus(ctx context.Context, arg GetRequestsByStatusParams) ([]GetRequestsByStatusRow, error)
+ GetResponseByID(ctx context.Context, id int64) (GetResponseByIDRow, error)
+ GetResponsesByRequest(ctx context.Context, requestID int64) ([]GetResponsesByRequestRow, error)
+ GetResponsesByVolunteer(ctx context.Context, arg GetResponsesByVolunteerParams) ([]GetResponsesByVolunteerRow, error)
+ GetRoleByID(ctx context.Context, id int64) (Role, error)
+ // Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
+ // Запросы для управления ролями и правами доступа
+ // ============================================================================
+ // Роли
+ // ============================================================================
+ GetRoleByName(ctx context.Context, name string) (Role, error)
+ GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
+ GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error)
+ // ============================================================================
+ // Права доступа
+ // ============================================================================
+ GetUserPermissions(ctx context.Context, id int64) ([]GetUserPermissionsRow, error)
+ // Фаза 1C: Управление профилем (КРИТИЧНО)
+ // Запросы для получения и обновления профилей пользователей
+ // ============================================================================
+ // Профиль пользователя
+ // ============================================================================
+ GetUserProfile(ctx context.Context, id int64) (GetUserProfileRow, error)
+ // ============================================================================
+ // Пользовательские роли
+ // ============================================================================
+ GetUserRoles(ctx context.Context, userID int64) ([]Role, error)
+ GetUserSession(ctx context.Context, sessionToken string) (UserSession, error)
+ // ============================================================================
+ // Поиск пользователей
+ // ============================================================================
+ GetUsersByIDs(ctx context.Context, dollar_1 []int64) ([]GetUsersByIDsRow, error)
+ GetVolunteerStatistics(ctx context.Context, id int64) (GetVolunteerStatisticsRow, error)
+ InvalidateAllUserSessions(ctx context.Context, userID int64) error
+ InvalidateUserSession(ctx context.Context, id int64) error
+ ListAllRoles(ctx context.Context) ([]Role, error)
+ ListPermissionsByRole(ctx context.Context, roleID int64) ([]Permission, error)
+ // ============================================================================
+ // Типы заявок
+ // ============================================================================
+ ListRequestTypes(ctx context.Context) ([]RequestType, error)
+ ModerateRequest(ctx context.Context, arg ModerateRequestParams) error
+ RejectRequest(ctx context.Context, arg RejectRequestParams) error
+ RejectVolunteerResponse(ctx context.Context, id int64) error
+ RemoveRoleFromUser(ctx context.Context, arg RemoveRoleFromUserParams) error
+ RevokeAllUserTokens(ctx context.Context, userID int64) error
+ RevokeRefreshToken(ctx context.Context, id int64) error
+ SearchUsersByName(ctx context.Context, arg SearchUsersByNameParams) ([]SearchUsersByNameRow, error)
+ SoftDeleteUser(ctx context.Context, id int64) error
+ UnblockUser(ctx context.Context, id int64) error
+ UpdateLastLogin(ctx context.Context, id int64) error
+ UpdateRating(ctx context.Context, arg UpdateRatingParams) error
+ // ============================================================================
+ // Обновление заявок
+ // ============================================================================
+ UpdateRequestStatus(ctx context.Context, arg UpdateRequestStatusParams) error
+ UpdateSessionActivity(ctx context.Context, id int64) error
+ UpdateUserLocation(ctx context.Context, arg UpdateUserLocationParams) error
+ UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error
+ UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
+ UserHasAnyPermission(ctx context.Context, arg UserHasAnyPermissionParams) (bool, error)
+ UserHasPermission(ctx context.Context, arg UserHasPermissionParams) (bool, error)
+ UserHasRole(ctx context.Context, arg UserHasRoleParams) (bool, error)
+ UserHasRoleByName(ctx context.Context, arg UserHasRoleByNameParams) (bool, error)
+ VerifyUserEmail(ctx context.Context, id int64) error
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/internal/database/queries/auth.sql b/internal/database/queries/auth.sql
new file mode 100644
index 0000000..5697c7e
--- /dev/null
+++ b/internal/database/queries/auth.sql
@@ -0,0 +1,194 @@
+-- Фаза 1A: Аутентификация (КРИТИЧНО)
+-- Запросы для регистрации, входа и управления токенами
+
+-- ============================================================================
+-- Пользователи
+-- ============================================================================
+
+-- name: CreateUser :one
+INSERT INTO users (
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ location,
+ address,
+ city
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ ST_SetSRID(ST_MakePoint($6, $7), 4326)::geography,
+ $8,
+ $9
+) RETURNING
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at;
+
+-- name: GetUserByEmail :one
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE email = $1 AND deleted_at IS NULL;
+
+-- name: GetUserByID :one
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE id = $1 AND deleted_at IS NULL;
+
+-- name: EmailExists :one
+SELECT EXISTS(
+ SELECT 1 FROM users
+ WHERE email = $1 AND deleted_at IS NULL
+);
+
+-- name: UpdateLastLogin :exec
+UPDATE users
+SET last_login_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- ============================================================================
+-- Refresh Tokens
+-- ============================================================================
+
+-- name: CreateRefreshToken :one
+INSERT INTO refresh_tokens (
+ user_id,
+ token,
+ expires_at,
+ user_agent,
+ ip_address
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5
+) RETURNING *;
+
+-- name: GetRefreshToken :one
+SELECT * FROM refresh_tokens
+WHERE token = $1
+ AND revoked = FALSE
+ AND expires_at > CURRENT_TIMESTAMP;
+
+-- name: RevokeRefreshToken :exec
+UPDATE refresh_tokens
+SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: RevokeAllUserTokens :exec
+UPDATE refresh_tokens
+SET revoked = TRUE, revoked_at = CURRENT_TIMESTAMP
+WHERE user_id = $1 AND revoked = FALSE;
+
+-- name: CleanupExpiredTokens :exec
+DELETE FROM refresh_tokens
+WHERE expires_at < CURRENT_TIMESTAMP
+ OR (revoked = TRUE AND revoked_at < CURRENT_TIMESTAMP - INTERVAL '30 days');
+
+-- ============================================================================
+-- User Sessions
+-- ============================================================================
+
+-- name: CreateUserSession :one
+INSERT INTO user_sessions (
+ user_id,
+ session_token,
+ refresh_token_id,
+ expires_at,
+ user_agent,
+ ip_address,
+ device_info
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ $7
+) RETURNING *;
+
+-- name: GetUserSession :one
+SELECT * FROM user_sessions
+WHERE session_token = $1
+ AND expires_at > CURRENT_TIMESTAMP;
+
+-- name: UpdateSessionActivity :exec
+UPDATE user_sessions
+SET last_activity_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: InvalidateUserSession :exec
+DELETE FROM user_sessions
+WHERE id = $1;
+
+-- name: InvalidateAllUserSessions :exec
+DELETE FROM user_sessions
+WHERE user_id = $1;
+
+-- name: CleanupExpiredSessions :exec
+DELETE FROM user_sessions
+WHERE expires_at < CURRENT_TIMESTAMP
+ OR last_activity_at < CURRENT_TIMESTAMP - INTERVAL '7 days';
diff --git a/internal/database/queries/geospatial.sql b/internal/database/queries/geospatial.sql
new file mode 100644
index 0000000..507e82f
--- /dev/null
+++ b/internal/database/queries/geospatial.sql
@@ -0,0 +1,151 @@
+-- Фаза 2B: Геопространственные запросы (ВЫСОКИЙ ПРИОРИТЕТ)
+-- PostGIS запросы для поиска заявок по геолокации
+
+-- ============================================================================
+-- Поиск заявок рядом с точкой
+-- ============================================================================
+
+-- name: FindRequestsNearby :many
+SELECT
+ r.id,
+ r.title,
+ r.description,
+ r.address,
+ r.city,
+ r.urgency,
+ r.status,
+ r.created_at,
+ r.desired_completion_date,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ ST_Distance(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography
+ ) as distance_meters,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.deleted_at IS NULL
+ AND r.status::text = ANY($3::text[])
+ AND ST_DWithin(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
+ $4
+ )
+ORDER BY distance_meters
+LIMIT $5 OFFSET $6;
+
+-- ============================================================================
+-- Поиск заявок в прямоугольной области (для карты)
+-- ============================================================================
+
+-- name: FindRequestsInBounds :many
+SELECT
+ r.id,
+ r.title,
+ r.urgency,
+ r.status,
+ r.created_at,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ rt.icon as request_type_icon,
+ rt.name as request_type_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.deleted_at IS NULL
+ AND r.status::text = ANY($1::text[])
+ AND ST_Within(
+ r.location::geometry,
+ ST_MakeEnvelope($2, $3, $4, $5, 4326)
+ )
+ORDER BY r.created_at DESC
+LIMIT 200;
+
+-- ============================================================================
+-- Подсчет заявок поблизости
+-- ============================================================================
+
+-- name: CountRequestsNearby :one
+SELECT COUNT(*) FROM requests r
+WHERE r.deleted_at IS NULL
+ AND r.status = $3
+ AND ST_DWithin(
+ r.location,
+ ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography,
+ $4
+ );
+
+-- ============================================================================
+-- Поиск волонтеров рядом с заявкой
+-- ============================================================================
+
+-- name: FindVolunteersNearRequest :many
+SELECT
+ u.id,
+ (u.first_name || ' ' || u.last_name) as full_name,
+ u.avatar_url,
+ u.volunteer_rating,
+ u.completed_requests_count,
+ ST_Y(u.location::geometry) as latitude,
+ ST_X(u.location::geometry) as longitude,
+ ST_Distance(
+ u.location,
+ (SELECT req.location FROM requests req WHERE req.id = $1)
+ ) as distance_meters
+FROM users u
+JOIN user_roles ur ON ur.user_id = u.id
+JOIN roles r ON r.id = ur.role_id
+WHERE r.name = 'volunteer'
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+ AND u.location IS NOT NULL
+ AND ST_DWithin(
+ u.location,
+ (SELECT req.location FROM requests req WHERE req.id = $1),
+ $2
+ )
+ORDER BY distance_meters
+LIMIT $3;
+
+-- ============================================================================
+-- Поиск ближайших заявок для волонтера
+-- ============================================================================
+
+-- name: FindNearestRequestsForVolunteer :many
+SELECT
+ r.id,
+ r.title,
+ r.description,
+ r.urgency,
+ r.status,
+ r.created_at,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ ST_Distance(
+ r.location,
+ (SELECT u.location FROM users u WHERE u.id = $1)
+ ) as distance_meters,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.deleted_at IS NULL
+ AND r.status = 'approved'
+ AND r.assigned_volunteer_id IS NULL
+ AND ST_DWithin(
+ r.location,
+ (SELECT u.location FROM users u WHERE u.id = $1),
+ $2
+ )
+ORDER BY
+ CASE r.urgency
+ WHEN 'urgent' THEN 1
+ WHEN 'high' THEN 2
+ WHEN 'medium' THEN 3
+ ELSE 4
+ END,
+ distance_meters
+LIMIT $3;
diff --git a/internal/database/queries/rbac.sql b/internal/database/queries/rbac.sql
new file mode 100644
index 0000000..ea983a7
--- /dev/null
+++ b/internal/database/queries/rbac.sql
@@ -0,0 +1,102 @@
+-- Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
+-- Запросы для управления ролями и правами доступа
+
+-- ============================================================================
+-- Роли
+-- ============================================================================
+
+-- name: GetRoleByName :one
+SELECT * FROM roles
+WHERE name = $1;
+
+-- name: GetRoleByID :one
+SELECT * FROM roles
+WHERE id = $1;
+
+-- name: ListAllRoles :many
+SELECT * FROM roles
+ORDER BY name;
+
+-- ============================================================================
+-- Пользовательские роли
+-- ============================================================================
+
+-- name: GetUserRoles :many
+SELECT r.* FROM roles r
+JOIN user_roles ur ON ur.role_id = r.id
+WHERE ur.user_id = $1
+ORDER BY r.name;
+
+-- name: AssignRoleToUser :one
+INSERT INTO user_roles (user_id, role_id, assigned_by)
+VALUES ($1, $2, $3)
+ON CONFLICT (user_id, role_id) DO NOTHING
+RETURNING *;
+
+-- name: RemoveRoleFromUser :exec
+DELETE FROM user_roles
+WHERE user_id = $1 AND role_id = $2;
+
+-- name: UserHasRole :one
+SELECT EXISTS(
+ SELECT 1 FROM user_roles
+ WHERE user_id = $1 AND role_id = $2
+);
+
+-- name: UserHasRoleByName :one
+SELECT EXISTS(
+ SELECT 1 FROM user_roles ur
+ JOIN roles r ON r.id = ur.role_id
+ WHERE ur.user_id = $1 AND r.name = $2
+);
+
+-- ============================================================================
+-- Права доступа
+-- ============================================================================
+
+-- name: GetUserPermissions :many
+SELECT DISTINCT p.name, p.resource, p.action, p.description
+FROM users u
+JOIN user_roles ur ON ur.user_id = u.id
+JOIN role_permissions rp ON rp.role_id = ur.role_id
+JOIN permissions p ON p.id = rp.permission_id
+WHERE u.id = $1
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+ORDER BY p.resource, p.action;
+
+-- name: GetPermissionByName :one
+SELECT * FROM permissions
+WHERE name = $1;
+
+-- name: ListPermissionsByRole :many
+SELECT p.* FROM permissions p
+JOIN role_permissions rp ON rp.permission_id = p.id
+WHERE rp.role_id = $1
+ORDER BY p.resource, p.action;
+
+-- name: UserHasPermission :one
+SELECT EXISTS(
+ SELECT 1
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = $1
+ AND p.name = $2
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+);
+
+-- name: UserHasAnyPermission :one
+SELECT EXISTS(
+ SELECT 1
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = $1
+ AND p.name = ANY($2::varchar[])
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+);
diff --git a/internal/database/queries/requests.sql b/internal/database/queries/requests.sql
new file mode 100644
index 0000000..a0125b7
--- /dev/null
+++ b/internal/database/queries/requests.sql
@@ -0,0 +1,339 @@
+-- Фаза 2A: Управление заявками (ВЫСОКИЙ ПРИОРИТЕТ)
+-- CRUD операции для заявок на помощь
+
+-- ============================================================================
+-- Создание и получение заявок
+-- ============================================================================
+
+-- name: CreateRequest :one
+INSERT INTO requests (
+ requester_id,
+ request_type_id,
+ title,
+ description,
+ location,
+ address,
+ city,
+ desired_completion_date,
+ urgency,
+ contact_phone,
+ contact_notes
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography,
+ $7,
+ $8,
+ $9,
+ $10,
+ $11,
+ $12
+) RETURNING
+ id,
+ requester_id,
+ request_type_id,
+ title,
+ description,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ desired_completion_date,
+ urgency,
+ contact_phone,
+ contact_notes,
+ status,
+ assigned_volunteer_id,
+ created_at,
+ updated_at,
+ deleted_at;
+
+-- name: GetRequestByID :one
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ r.completed_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ u.phone as requester_phone,
+ u.email as requester_email,
+ (av.first_name || ' ' || av.last_name) as assigned_volunteer_name,
+ av.phone as assigned_volunteer_phone
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+LEFT JOIN users av ON av.id = r.assigned_volunteer_id
+WHERE r.id = $1 AND r.deleted_at IS NULL;
+
+-- name: GetRequestsByRequester :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.requester_id = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3;
+
+-- name: GetRequestsByStatus :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ rt.name as request_type_name,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.status = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3;
+
+-- ============================================================================
+-- Обновление заявок
+-- ============================================================================
+
+-- name: UpdateRequestStatus :exec
+UPDATE requests SET
+ status = $2,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL;
+
+-- name: AssignVolunteerToRequest :exec
+UPDATE requests SET
+ assigned_volunteer_id = $2,
+ status = 'in_progress',
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL;
+
+-- name: CompleteRequest :exec
+UPDATE requests SET
+ status = 'completed',
+ completed_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL;
+
+-- name: CancelRequest :exec
+UPDATE requests SET
+ status = 'cancelled',
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL;
+
+-- name: ModerateRequest :exec
+UPDATE requests SET
+ status = $2,
+ moderated_by = $3,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = $4,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- ============================================================================
+-- Удаление заявок
+-- ============================================================================
+
+-- name: DeleteRequest :exec
+UPDATE requests SET
+ deleted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND requester_id = $2
+ AND deleted_at IS NULL;
+
+-- ============================================================================
+-- Типы заявок
+-- ============================================================================
+
+-- name: ListRequestTypes :many
+SELECT * FROM request_types
+WHERE is_active = TRUE
+ORDER BY name;
+
+-- name: GetRequestTypeByID :one
+SELECT * FROM request_types
+WHERE id = $1;
+
+-- name: GetRequestTypeByName :one
+SELECT * FROM request_types
+WHERE name = $1;
+
+-- ============================================================================
+-- Статистика
+-- ============================================================================
+
+-- name: CountRequestsByRequester :one
+SELECT COUNT(*) FROM requests
+WHERE requester_id = $1
+ AND deleted_at IS NULL;
+
+-- name: CountRequestsByStatus :one
+SELECT COUNT(*) FROM requests
+WHERE status = $1
+ AND deleted_at IS NULL;
+
+-- ============================================================================
+-- Модерация заявок
+-- ============================================================================
+
+-- name: GetPendingModerationRequests :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.created_at,
+ r.updated_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ u.email as requester_email,
+ u.phone as requester_phone
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.status = 'pending_moderation'
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at ASC
+LIMIT $1 OFFSET $2;
+
+-- name: ApproveRequest :exec
+UPDATE requests SET
+ status = 'approved',
+ moderated_by = $2,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = sqlc.narg('moderation_comment'),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND status = 'pending_moderation'
+ AND deleted_at IS NULL;
+
+-- name: RejectRequest :exec
+UPDATE requests SET
+ status = 'rejected',
+ moderated_by = $2,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = $3,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND status = 'pending_moderation'
+ AND deleted_at IS NULL;
+
+-- name: GetModeratedRequests :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.status,
+ r.moderated_by,
+ r.moderated_at,
+ r.moderation_comment,
+ r.created_at,
+ rt.name as request_type_name,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ (m.first_name || ' ' || m.last_name) as moderator_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+LEFT JOIN users m ON m.id = r.moderated_by
+WHERE r.moderated_by = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.moderated_at DESC
+LIMIT $2 OFFSET $3;
+
+-- ============================================================================
+-- Аудит действий модераторов
+-- ============================================================================
+
+-- name: GetModeratorActionsByRequest :many
+SELECT
+ ma.*,
+ (u.first_name || ' ' || u.last_name) as moderator_name,
+ u.email as moderator_email
+FROM moderator_actions ma
+JOIN users u ON u.id = ma.moderator_id
+WHERE ma.target_request_id = $1
+ORDER BY ma.created_at DESC;
+
+-- name: GetModeratorActionsByModerator :many
+SELECT
+ ma.*,
+ r.title as request_title,
+ r.status as request_status
+FROM moderator_actions ma
+LEFT JOIN requests r ON r.id = ma.target_request_id
+WHERE ma.moderator_id = $1
+ORDER BY ma.created_at DESC
+LIMIT $2 OFFSET $3;
diff --git a/internal/database/queries/responses.sql b/internal/database/queries/responses.sql
new file mode 100644
index 0000000..addbba1
--- /dev/null
+++ b/internal/database/queries/responses.sql
@@ -0,0 +1,192 @@
+-- Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
+-- Запросы для управления откликами волонтеров и историей изменения статусов заявок
+
+-- ============================================================================
+-- Отклики волонтеров
+-- ============================================================================
+
+-- name: CreateVolunteerResponse :one
+INSERT INTO volunteer_responses (
+ request_id,
+ volunteer_id,
+ message
+) VALUES (
+ $1,
+ $2,
+ $3
+)
+ON CONFLICT (request_id, volunteer_id) DO NOTHING
+RETURNING *;
+
+-- name: GetResponsesByRequest :many
+SELECT
+ vr.*,
+ (u.first_name || ' ' || u.last_name) as volunteer_name,
+ u.avatar_url as volunteer_avatar,
+ u.volunteer_rating,
+ u.completed_requests_count,
+ u.email as volunteer_email,
+ u.phone as volunteer_phone
+FROM volunteer_responses vr
+JOIN users u ON u.id = vr.volunteer_id
+WHERE vr.request_id = $1
+ORDER BY vr.created_at DESC;
+
+-- name: GetResponsesByVolunteer :many
+SELECT
+ vr.*,
+ r.title as request_title,
+ r.status as request_status,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM volunteer_responses vr
+JOIN requests r ON r.id = vr.request_id
+JOIN users u ON u.id = r.requester_id
+WHERE vr.volunteer_id = $1
+ORDER BY vr.created_at DESC
+LIMIT $2 OFFSET $3;
+
+-- name: GetResponseByID :one
+SELECT
+ vr.*,
+ (u.first_name || ' ' || u.last_name) as volunteer_name,
+ r.title as request_title
+FROM volunteer_responses vr
+JOIN users u ON u.id = vr.volunteer_id
+JOIN requests r ON r.id = vr.request_id
+WHERE vr.id = $1;
+
+-- name: AcceptVolunteerResponse :exec
+UPDATE volunteer_responses SET
+ status = 'accepted',
+ accepted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: RejectVolunteerResponse :exec
+UPDATE volunteer_responses SET
+ status = 'rejected',
+ rejected_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: CountResponsesByRequest :one
+SELECT COUNT(*) FROM volunteer_responses
+WHERE request_id = $1;
+
+-- name: CountPendingResponsesByVolunteer :one
+SELECT COUNT(*) FROM volunteer_responses
+WHERE volunteer_id = $1 AND status = 'pending';
+
+-- ============================================================================
+-- История изменения статусов заявок
+-- ============================================================================
+
+-- name: CreateStatusHistoryEntry :one
+INSERT INTO request_status_history (
+ request_id,
+ from_status,
+ to_status,
+ changed_by,
+ comment
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ sqlc.narg('comment')
+) RETURNING *;
+
+-- name: GetRequestStatusHistory :many
+SELECT
+ rsh.*,
+ (u.first_name || ' ' || u.last_name) as changed_by_name
+FROM request_status_history rsh
+JOIN users u ON u.id = rsh.changed_by
+WHERE rsh.request_id = $1
+ORDER BY rsh.created_at DESC;
+
+-- name: GetLatestStatusChange :one
+SELECT
+ rsh.*,
+ (u.first_name || ' ' || u.last_name) as changed_by_name
+FROM request_status_history rsh
+JOIN users u ON u.id = rsh.changed_by
+WHERE rsh.request_id = $1
+ORDER BY rsh.created_at DESC
+LIMIT 1;
+
+-- ============================================================================
+-- Рейтинги
+-- ============================================================================
+
+-- name: CreateRating :one
+INSERT INTO ratings (
+ volunteer_response_id,
+ volunteer_id,
+ requester_id,
+ request_id,
+ rating,
+ comment
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ sqlc.narg('comment')
+) RETURNING *;
+
+-- name: GetRatingByResponseID :one
+SELECT * FROM ratings
+WHERE volunteer_response_id = $1;
+
+-- name: GetRatingsByVolunteer :many
+SELECT
+ r.*,
+ req.title as request_title,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM ratings r
+JOIN requests req ON req.id = r.request_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.volunteer_id = $1
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3;
+
+-- name: CalculateVolunteerAverageRating :one
+SELECT
+ COALESCE(AVG(rating), 0) as average_rating,
+ COUNT(*) as total_ratings
+FROM ratings
+WHERE volunteer_id = $1;
+
+-- name: UpdateRating :exec
+UPDATE ratings SET
+ rating = $2,
+ comment = sqlc.narg('comment'),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- ============================================================================
+-- Хранимые процедуры
+-- ============================================================================
+
+-- name: CallAcceptVolunteerResponse :one
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT,
+ r.out_request_id::BIGINT,
+ r.out_volunteer_id::BIGINT
+FROM accept_volunteer_response($1, $2) AS r(success, message, out_request_id, out_volunteer_id);
+
+-- name: CallCompleteRequestWithRating :one
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT,
+ r.out_rating_id::BIGINT
+FROM complete_request_with_rating($1, $2, $3, sqlc.narg('comment')) AS r(success, message, out_rating_id);
+
+-- name: CallModerateRequest :one
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT
+FROM moderate_request($1, $2, $3, sqlc.narg('comment')) AS r(success, message);
diff --git a/internal/database/queries/users.sql b/internal/database/queries/users.sql
new file mode 100644
index 0000000..d99168f
--- /dev/null
+++ b/internal/database/queries/users.sql
@@ -0,0 +1,137 @@
+-- Фаза 1C: Управление профилем (КРИТИЧНО)
+-- Запросы для получения и обновления профилей пользователей
+
+-- ============================================================================
+-- Профиль пользователя
+-- ============================================================================
+
+-- name: GetUserProfile :one
+SELECT
+ id,
+ email,
+ phone,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at
+FROM users
+WHERE id = $1 AND deleted_at IS NULL;
+
+-- name: UpdateUserProfile :exec
+UPDATE users SET
+ phone = COALESCE(sqlc.narg('phone'), phone),
+ first_name = COALESCE(sqlc.narg('first_name'), first_name),
+ last_name = COALESCE(sqlc.narg('last_name'), last_name),
+ avatar_url = COALESCE(sqlc.narg('avatar_url'), avatar_url),
+ address = COALESCE(sqlc.narg('address'), address),
+ city = COALESCE(sqlc.narg('city'), city),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = sqlc.arg('user_id');
+
+-- name: UpdateUserLocation :exec
+UPDATE users SET
+ location = ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography,
+ address = COALESCE(sqlc.narg('address'), address),
+ city = COALESCE(sqlc.narg('city'), city),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: VerifyUserEmail :exec
+UPDATE users SET
+ email_verified = TRUE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: UpdateUserPassword :exec
+UPDATE users SET
+ password_hash = $2,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: BlockUser :exec
+UPDATE users SET
+ is_blocked = TRUE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: UnblockUser :exec
+UPDATE users SET
+ is_blocked = FALSE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- name: SoftDeleteUser :exec
+UPDATE users SET
+ deleted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1;
+
+-- ============================================================================
+-- Поиск пользователей
+-- ============================================================================
+
+-- name: GetUsersByIDs :many
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE id = ANY($1::bigint[])
+ AND deleted_at IS NULL;
+
+-- name: SearchUsersByName :many
+SELECT
+ id,
+ email,
+ first_name,
+ last_name,
+ avatar_url,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified
+FROM users
+WHERE (first_name ILIKE '%' || $1 || '%' OR last_name ILIKE '%' || $1 || '%' OR (first_name || ' ' || last_name) ILIKE '%' || $1 || '%')
+ AND deleted_at IS NULL
+ AND is_blocked = FALSE
+ORDER BY volunteer_rating DESC NULLS LAST
+LIMIT $2 OFFSET $3;
+
+-- name: GetVolunteerStatistics :one
+SELECT
+ id,
+ first_name,
+ last_name,
+ volunteer_rating,
+ completed_requests_count,
+ created_at as member_since
+FROM users
+WHERE id = $1
+ AND deleted_at IS NULL;
diff --git a/internal/database/rbac.sql.go b/internal/database/rbac.sql.go
new file mode 100644
index 0000000..d77fe24
--- /dev/null
+++ b/internal/database/rbac.sql.go
@@ -0,0 +1,352 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: rbac.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const AssignRoleToUser = `-- name: AssignRoleToUser :one
+INSERT INTO user_roles (user_id, role_id, assigned_by)
+VALUES ($1, $2, $3)
+ON CONFLICT (user_id, role_id) DO NOTHING
+RETURNING id, user_id, role_id, assigned_at, assigned_by
+`
+
+type AssignRoleToUserParams struct {
+ UserID int64 `json:"user_id"`
+ RoleID int64 `json:"role_id"`
+ AssignedBy pgtype.Int8 `json:"assigned_by"`
+}
+
+func (q *Queries) AssignRoleToUser(ctx context.Context, arg AssignRoleToUserParams) (UserRole, error) {
+ row := q.db.QueryRow(ctx, AssignRoleToUser, arg.UserID, arg.RoleID, arg.AssignedBy)
+ var i UserRole
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.RoleID,
+ &i.AssignedAt,
+ &i.AssignedBy,
+ )
+ return i, err
+}
+
+const GetPermissionByName = `-- name: GetPermissionByName :one
+SELECT id, name, resource, action, description, created_at FROM permissions
+WHERE name = $1
+`
+
+func (q *Queries) GetPermissionByName(ctx context.Context, name string) (Permission, error) {
+ row := q.db.QueryRow(ctx, GetPermissionByName, name)
+ var i Permission
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Resource,
+ &i.Action,
+ &i.Description,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetRoleByID = `-- name: GetRoleByID :one
+SELECT id, name, description, created_at FROM roles
+WHERE id = $1
+`
+
+func (q *Queries) GetRoleByID(ctx context.Context, id int64) (Role, error) {
+ row := q.db.QueryRow(ctx, GetRoleByID, id)
+ var i Role
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetRoleByName = `-- name: GetRoleByName :one
+
+
+SELECT id, name, description, created_at FROM roles
+WHERE name = $1
+`
+
+// Фаза 1B: RBAC (Role-Based Access Control) (КРИТИЧНО)
+// Запросы для управления ролями и правами доступа
+// ============================================================================
+// Роли
+// ============================================================================
+func (q *Queries) GetRoleByName(ctx context.Context, name string) (Role, error) {
+ row := q.db.QueryRow(ctx, GetRoleByName, name)
+ var i Role
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetUserPermissions = `-- name: GetUserPermissions :many
+
+SELECT DISTINCT p.name, p.resource, p.action, p.description
+FROM users u
+JOIN user_roles ur ON ur.user_id = u.id
+JOIN role_permissions rp ON rp.role_id = ur.role_id
+JOIN permissions p ON p.id = rp.permission_id
+WHERE u.id = $1
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+ORDER BY p.resource, p.action
+`
+
+type GetUserPermissionsRow struct {
+ Name string `json:"name"`
+ Resource string `json:"resource"`
+ Action string `json:"action"`
+ Description pgtype.Text `json:"description"`
+}
+
+// ============================================================================
+// Права доступа
+// ============================================================================
+func (q *Queries) GetUserPermissions(ctx context.Context, id int64) ([]GetUserPermissionsRow, error) {
+ rows, err := q.db.Query(ctx, GetUserPermissions, id)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetUserPermissionsRow{}
+ for rows.Next() {
+ var i GetUserPermissionsRow
+ if err := rows.Scan(
+ &i.Name,
+ &i.Resource,
+ &i.Action,
+ &i.Description,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetUserRoles = `-- name: GetUserRoles :many
+
+SELECT r.id, r.name, r.description, r.created_at FROM roles r
+JOIN user_roles ur ON ur.role_id = r.id
+WHERE ur.user_id = $1
+ORDER BY r.name
+`
+
+// ============================================================================
+// Пользовательские роли
+// ============================================================================
+func (q *Queries) GetUserRoles(ctx context.Context, userID int64) ([]Role, error) {
+ rows, err := q.db.Query(ctx, GetUserRoles, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Role{}
+ for rows.Next() {
+ var i Role
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const ListAllRoles = `-- name: ListAllRoles :many
+SELECT id, name, description, created_at FROM roles
+ORDER BY name
+`
+
+func (q *Queries) ListAllRoles(ctx context.Context) ([]Role, error) {
+ rows, err := q.db.Query(ctx, ListAllRoles)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Role{}
+ for rows.Next() {
+ var i Role
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const ListPermissionsByRole = `-- name: ListPermissionsByRole :many
+SELECT p.id, p.name, p.resource, p.action, p.description, p.created_at FROM permissions p
+JOIN role_permissions rp ON rp.permission_id = p.id
+WHERE rp.role_id = $1
+ORDER BY p.resource, p.action
+`
+
+func (q *Queries) ListPermissionsByRole(ctx context.Context, roleID int64) ([]Permission, error) {
+ rows, err := q.db.Query(ctx, ListPermissionsByRole, roleID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Permission{}
+ for rows.Next() {
+ var i Permission
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Resource,
+ &i.Action,
+ &i.Description,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const RemoveRoleFromUser = `-- name: RemoveRoleFromUser :exec
+DELETE FROM user_roles
+WHERE user_id = $1 AND role_id = $2
+`
+
+type RemoveRoleFromUserParams struct {
+ UserID int64 `json:"user_id"`
+ RoleID int64 `json:"role_id"`
+}
+
+func (q *Queries) RemoveRoleFromUser(ctx context.Context, arg RemoveRoleFromUserParams) error {
+ _, err := q.db.Exec(ctx, RemoveRoleFromUser, arg.UserID, arg.RoleID)
+ return err
+}
+
+const UserHasAnyPermission = `-- name: UserHasAnyPermission :one
+SELECT EXISTS(
+ SELECT 1
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = $1
+ AND p.name = ANY($2::varchar[])
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+)
+`
+
+type UserHasAnyPermissionParams struct {
+ ID int64 `json:"id"`
+ Column2 []string `json:"column_2"`
+}
+
+func (q *Queries) UserHasAnyPermission(ctx context.Context, arg UserHasAnyPermissionParams) (bool, error) {
+ row := q.db.QueryRow(ctx, UserHasAnyPermission, arg.ID, arg.Column2)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const UserHasPermission = `-- name: UserHasPermission :one
+SELECT EXISTS(
+ SELECT 1
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = $1
+ AND p.name = $2
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+)
+`
+
+type UserHasPermissionParams struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+}
+
+func (q *Queries) UserHasPermission(ctx context.Context, arg UserHasPermissionParams) (bool, error) {
+ row := q.db.QueryRow(ctx, UserHasPermission, arg.ID, arg.Name)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const UserHasRole = `-- name: UserHasRole :one
+SELECT EXISTS(
+ SELECT 1 FROM user_roles
+ WHERE user_id = $1 AND role_id = $2
+)
+`
+
+type UserHasRoleParams struct {
+ UserID int64 `json:"user_id"`
+ RoleID int64 `json:"role_id"`
+}
+
+func (q *Queries) UserHasRole(ctx context.Context, arg UserHasRoleParams) (bool, error) {
+ row := q.db.QueryRow(ctx, UserHasRole, arg.UserID, arg.RoleID)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const UserHasRoleByName = `-- name: UserHasRoleByName :one
+SELECT EXISTS(
+ SELECT 1 FROM user_roles ur
+ JOIN roles r ON r.id = ur.role_id
+ WHERE ur.user_id = $1 AND r.name = $2
+)
+`
+
+type UserHasRoleByNameParams struct {
+ UserID int64 `json:"user_id"`
+ Name string `json:"name"`
+}
+
+func (q *Queries) UserHasRoleByName(ctx context.Context, arg UserHasRoleByNameParams) (bool, error) {
+ row := q.db.QueryRow(ctx, UserHasRoleByName, arg.UserID, arg.Name)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
diff --git a/internal/database/requests.sql.go b/internal/database/requests.sql.go
new file mode 100644
index 0000000..a6a8359
--- /dev/null
+++ b/internal/database/requests.sql.go
@@ -0,0 +1,1030 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: requests.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const ApproveRequest = `-- name: ApproveRequest :exec
+UPDATE requests SET
+ status = 'approved',
+ moderated_by = $2,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = $3,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND status = 'pending_moderation'
+ AND deleted_at IS NULL
+`
+
+type ApproveRequestParams struct {
+ ID int64 `json:"id"`
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ ModerationComment pgtype.Text `json:"moderation_comment"`
+}
+
+func (q *Queries) ApproveRequest(ctx context.Context, arg ApproveRequestParams) error {
+ _, err := q.db.Exec(ctx, ApproveRequest, arg.ID, arg.ModeratedBy, arg.ModerationComment)
+ return err
+}
+
+const AssignVolunteerToRequest = `-- name: AssignVolunteerToRequest :exec
+UPDATE requests SET
+ assigned_volunteer_id = $2,
+ status = 'in_progress',
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL
+`
+
+type AssignVolunteerToRequestParams struct {
+ ID int64 `json:"id"`
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+}
+
+func (q *Queries) AssignVolunteerToRequest(ctx context.Context, arg AssignVolunteerToRequestParams) error {
+ _, err := q.db.Exec(ctx, AssignVolunteerToRequest, arg.ID, arg.AssignedVolunteerID)
+ return err
+}
+
+const CancelRequest = `-- name: CancelRequest :exec
+UPDATE requests SET
+ status = 'cancelled',
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL
+`
+
+func (q *Queries) CancelRequest(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, CancelRequest, id)
+ return err
+}
+
+const CompleteRequest = `-- name: CompleteRequest :exec
+UPDATE requests SET
+ status = 'completed',
+ completed_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL
+`
+
+func (q *Queries) CompleteRequest(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, CompleteRequest, id)
+ return err
+}
+
+const CountRequestsByRequester = `-- name: CountRequestsByRequester :one
+
+SELECT COUNT(*) FROM requests
+WHERE requester_id = $1
+ AND deleted_at IS NULL
+`
+
+// ============================================================================
+// Статистика
+// ============================================================================
+func (q *Queries) CountRequestsByRequester(ctx context.Context, requesterID int64) (int64, error) {
+ row := q.db.QueryRow(ctx, CountRequestsByRequester, requesterID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const CountRequestsByStatus = `-- name: CountRequestsByStatus :one
+SELECT COUNT(*) FROM requests
+WHERE status = $1
+ AND deleted_at IS NULL
+`
+
+func (q *Queries) CountRequestsByStatus(ctx context.Context, status NullRequestStatus) (int64, error) {
+ row := q.db.QueryRow(ctx, CountRequestsByStatus, status)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const CreateRequest = `-- name: CreateRequest :one
+
+
+INSERT INTO requests (
+ requester_id,
+ request_type_id,
+ title,
+ description,
+ location,
+ address,
+ city,
+ desired_completion_date,
+ urgency,
+ contact_phone,
+ contact_notes
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography,
+ $7,
+ $8,
+ $9,
+ $10,
+ $11,
+ $12
+) RETURNING
+ id,
+ requester_id,
+ request_type_id,
+ title,
+ description,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ desired_completion_date,
+ urgency,
+ contact_phone,
+ contact_notes,
+ status,
+ assigned_volunteer_id,
+ created_at,
+ updated_at,
+ deleted_at
+`
+
+type CreateRequestParams struct {
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ StMakepoint interface{} `json:"st_makepoint"`
+ StMakepoint_2 interface{} `json:"st_makepoint_2"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+}
+
+type CreateRequestRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ Status NullRequestStatus `json:"status"`
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+// Фаза 2A: Управление заявками (ВЫСОКИЙ ПРИОРИТЕТ)
+// CRUD операции для заявок на помощь
+// ============================================================================
+// Создание и получение заявок
+// ============================================================================
+func (q *Queries) CreateRequest(ctx context.Context, arg CreateRequestParams) (CreateRequestRow, error) {
+ row := q.db.QueryRow(ctx, CreateRequest,
+ arg.RequesterID,
+ arg.RequestTypeID,
+ arg.Title,
+ arg.Description,
+ arg.StMakepoint,
+ arg.StMakepoint_2,
+ arg.Address,
+ arg.City,
+ arg.DesiredCompletionDate,
+ arg.Urgency,
+ arg.ContactPhone,
+ arg.ContactNotes,
+ )
+ var i CreateRequestRow
+ err := row.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.DesiredCompletionDate,
+ &i.Urgency,
+ &i.ContactPhone,
+ &i.ContactNotes,
+ &i.Status,
+ &i.AssignedVolunteerID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.DeletedAt,
+ )
+ return i, err
+}
+
+const DeleteRequest = `-- name: DeleteRequest :exec
+
+UPDATE requests SET
+ deleted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND requester_id = $2
+ AND deleted_at IS NULL
+`
+
+type DeleteRequestParams struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+}
+
+// ============================================================================
+// Удаление заявок
+// ============================================================================
+func (q *Queries) DeleteRequest(ctx context.Context, arg DeleteRequestParams) error {
+ _, err := q.db.Exec(ctx, DeleteRequest, arg.ID, arg.RequesterID)
+ return err
+}
+
+const GetModeratedRequests = `-- name: GetModeratedRequests :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.status,
+ r.moderated_by,
+ r.moderated_at,
+ r.moderation_comment,
+ r.created_at,
+ rt.name as request_type_name,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ (m.first_name || ' ' || m.last_name) as moderator_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+LEFT JOIN users m ON m.id = r.moderated_by
+WHERE r.moderated_by = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.moderated_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetModeratedRequestsParams struct {
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetModeratedRequestsRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ Status NullRequestStatus `json:"status"`
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ ModeratedAt pgtype.Timestamptz `json:"moderated_at"`
+ ModerationComment pgtype.Text `json:"moderation_comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ RequestTypeName string `json:"request_type_name"`
+ RequesterName interface{} `json:"requester_name"`
+ ModeratorName interface{} `json:"moderator_name"`
+}
+
+func (q *Queries) GetModeratedRequests(ctx context.Context, arg GetModeratedRequestsParams) ([]GetModeratedRequestsRow, error) {
+ rows, err := q.db.Query(ctx, GetModeratedRequests, arg.ModeratedBy, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetModeratedRequestsRow{}
+ for rows.Next() {
+ var i GetModeratedRequestsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.Status,
+ &i.ModeratedBy,
+ &i.ModeratedAt,
+ &i.ModerationComment,
+ &i.CreatedAt,
+ &i.RequestTypeName,
+ &i.RequesterName,
+ &i.ModeratorName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetModeratorActionsByModerator = `-- name: GetModeratorActionsByModerator :many
+SELECT
+ ma.id, ma.moderator_id, ma.action_type, ma.target_user_id, ma.target_request_id, ma.target_complaint_id, ma.comment, ma.metadata, ma.created_at,
+ r.title as request_title,
+ r.status as request_status
+FROM moderator_actions ma
+LEFT JOIN requests r ON r.id = ma.target_request_id
+WHERE ma.moderator_id = $1
+ORDER BY ma.created_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetModeratorActionsByModeratorParams struct {
+ ModeratorID int64 `json:"moderator_id"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetModeratorActionsByModeratorRow struct {
+ ID int64 `json:"id"`
+ ModeratorID int64 `json:"moderator_id"`
+ ActionType ModeratorActionType `json:"action_type"`
+ TargetUserID pgtype.Int8 `json:"target_user_id"`
+ TargetRequestID pgtype.Int8 `json:"target_request_id"`
+ TargetComplaintID pgtype.Int8 `json:"target_complaint_id"`
+ Comment pgtype.Text `json:"comment"`
+ Metadata []byte `json:"metadata"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ RequestTitle pgtype.Text `json:"request_title"`
+ RequestStatus NullRequestStatus `json:"request_status"`
+}
+
+func (q *Queries) GetModeratorActionsByModerator(ctx context.Context, arg GetModeratorActionsByModeratorParams) ([]GetModeratorActionsByModeratorRow, error) {
+ rows, err := q.db.Query(ctx, GetModeratorActionsByModerator, arg.ModeratorID, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetModeratorActionsByModeratorRow{}
+ for rows.Next() {
+ var i GetModeratorActionsByModeratorRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.ModeratorID,
+ &i.ActionType,
+ &i.TargetUserID,
+ &i.TargetRequestID,
+ &i.TargetComplaintID,
+ &i.Comment,
+ &i.Metadata,
+ &i.CreatedAt,
+ &i.RequestTitle,
+ &i.RequestStatus,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetModeratorActionsByRequest = `-- name: GetModeratorActionsByRequest :many
+
+SELECT
+ ma.id, ma.moderator_id, ma.action_type, ma.target_user_id, ma.target_request_id, ma.target_complaint_id, ma.comment, ma.metadata, ma.created_at,
+ (u.first_name || ' ' || u.last_name) as moderator_name,
+ u.email as moderator_email
+FROM moderator_actions ma
+JOIN users u ON u.id = ma.moderator_id
+WHERE ma.target_request_id = $1
+ORDER BY ma.created_at DESC
+`
+
+type GetModeratorActionsByRequestRow struct {
+ ID int64 `json:"id"`
+ ModeratorID int64 `json:"moderator_id"`
+ ActionType ModeratorActionType `json:"action_type"`
+ TargetUserID pgtype.Int8 `json:"target_user_id"`
+ TargetRequestID pgtype.Int8 `json:"target_request_id"`
+ TargetComplaintID pgtype.Int8 `json:"target_complaint_id"`
+ Comment pgtype.Text `json:"comment"`
+ Metadata []byte `json:"metadata"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ ModeratorName interface{} `json:"moderator_name"`
+ ModeratorEmail string `json:"moderator_email"`
+}
+
+// ============================================================================
+// Аудит действий модераторов
+// ============================================================================
+func (q *Queries) GetModeratorActionsByRequest(ctx context.Context, targetRequestID pgtype.Int8) ([]GetModeratorActionsByRequestRow, error) {
+ rows, err := q.db.Query(ctx, GetModeratorActionsByRequest, targetRequestID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetModeratorActionsByRequestRow{}
+ for rows.Next() {
+ var i GetModeratorActionsByRequestRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.ModeratorID,
+ &i.ActionType,
+ &i.TargetUserID,
+ &i.TargetRequestID,
+ &i.TargetComplaintID,
+ &i.Comment,
+ &i.Metadata,
+ &i.CreatedAt,
+ &i.ModeratorName,
+ &i.ModeratorEmail,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetPendingModerationRequests = `-- name: GetPendingModerationRequests :many
+
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.created_at,
+ r.updated_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ u.email as requester_email,
+ u.phone as requester_phone
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.status = 'pending_moderation'
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at ASC
+LIMIT $1 OFFSET $2
+`
+
+type GetPendingModerationRequestsParams struct {
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetPendingModerationRequestsRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ Status NullRequestStatus `json:"status"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ RequestTypeName string `json:"request_type_name"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+ RequesterName interface{} `json:"requester_name"`
+ RequesterEmail string `json:"requester_email"`
+ RequesterPhone pgtype.Text `json:"requester_phone"`
+}
+
+// ============================================================================
+// Модерация заявок
+// ============================================================================
+func (q *Queries) GetPendingModerationRequests(ctx context.Context, arg GetPendingModerationRequestsParams) ([]GetPendingModerationRequestsRow, error) {
+ rows, err := q.db.Query(ctx, GetPendingModerationRequests, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetPendingModerationRequestsRow{}
+ for rows.Next() {
+ var i GetPendingModerationRequestsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.DesiredCompletionDate,
+ &i.Urgency,
+ &i.ContactPhone,
+ &i.ContactNotes,
+ &i.Status,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.RequestTypeName,
+ &i.RequestTypeIcon,
+ &i.RequesterName,
+ &i.RequesterEmail,
+ &i.RequesterPhone,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetRequestByID = `-- name: GetRequestByID :one
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ r.completed_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ u.phone as requester_phone,
+ u.email as requester_email,
+ (av.first_name || ' ' || av.last_name) as assigned_volunteer_name,
+ av.phone as assigned_volunteer_phone
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+LEFT JOIN users av ON av.id = r.assigned_volunteer_id
+WHERE r.id = $1 AND r.deleted_at IS NULL
+`
+
+type GetRequestByIDRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ Status NullRequestStatus `json:"status"`
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ CompletedAt pgtype.Timestamptz `json:"completed_at"`
+ RequestTypeName string `json:"request_type_name"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+ RequesterName interface{} `json:"requester_name"`
+ RequesterPhone pgtype.Text `json:"requester_phone"`
+ RequesterEmail string `json:"requester_email"`
+ AssignedVolunteerName interface{} `json:"assigned_volunteer_name"`
+ AssignedVolunteerPhone pgtype.Text `json:"assigned_volunteer_phone"`
+}
+
+func (q *Queries) GetRequestByID(ctx context.Context, id int64) (GetRequestByIDRow, error) {
+ row := q.db.QueryRow(ctx, GetRequestByID, id)
+ var i GetRequestByIDRow
+ err := row.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.DesiredCompletionDate,
+ &i.Urgency,
+ &i.ContactPhone,
+ &i.ContactNotes,
+ &i.Status,
+ &i.AssignedVolunteerID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.DeletedAt,
+ &i.CompletedAt,
+ &i.RequestTypeName,
+ &i.RequestTypeIcon,
+ &i.RequesterName,
+ &i.RequesterPhone,
+ &i.RequesterEmail,
+ &i.AssignedVolunteerName,
+ &i.AssignedVolunteerPhone,
+ )
+ return i, err
+}
+
+const GetRequestTypeByID = `-- name: GetRequestTypeByID :one
+SELECT id, name, description, icon, is_active, created_at FROM request_types
+WHERE id = $1
+`
+
+func (q *Queries) GetRequestTypeByID(ctx context.Context, id int64) (RequestType, error) {
+ row := q.db.QueryRow(ctx, GetRequestTypeByID, id)
+ var i RequestType
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.Icon,
+ &i.IsActive,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetRequestTypeByName = `-- name: GetRequestTypeByName :one
+SELECT id, name, description, icon, is_active, created_at FROM request_types
+WHERE name = $1
+`
+
+func (q *Queries) GetRequestTypeByName(ctx context.Context, name string) (RequestType, error) {
+ row := q.db.QueryRow(ctx, GetRequestTypeByName, name)
+ var i RequestType
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.Icon,
+ &i.IsActive,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const GetRequestsByRequester = `-- name: GetRequestsByRequester :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ rt.name as request_type_name,
+ rt.icon as request_type_icon
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+WHERE r.requester_id = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetRequestsByRequesterParams struct {
+ RequesterID int64 `json:"requester_id"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetRequestsByRequesterRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ Status NullRequestStatus `json:"status"`
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ RequestTypeName string `json:"request_type_name"`
+ RequestTypeIcon pgtype.Text `json:"request_type_icon"`
+}
+
+func (q *Queries) GetRequestsByRequester(ctx context.Context, arg GetRequestsByRequesterParams) ([]GetRequestsByRequesterRow, error) {
+ rows, err := q.db.Query(ctx, GetRequestsByRequester, arg.RequesterID, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetRequestsByRequesterRow{}
+ for rows.Next() {
+ var i GetRequestsByRequesterRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.DesiredCompletionDate,
+ &i.Urgency,
+ &i.ContactPhone,
+ &i.ContactNotes,
+ &i.Status,
+ &i.AssignedVolunteerID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.DeletedAt,
+ &i.RequestTypeName,
+ &i.RequestTypeIcon,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetRequestsByStatus = `-- name: GetRequestsByStatus :many
+SELECT
+ r.id,
+ r.requester_id,
+ r.request_type_id,
+ r.title,
+ r.description,
+ ST_Y(r.location::geometry) as latitude,
+ ST_X(r.location::geometry) as longitude,
+ r.address,
+ r.city,
+ r.desired_completion_date,
+ r.urgency,
+ r.contact_phone,
+ r.contact_notes,
+ r.status,
+ r.assigned_volunteer_id,
+ r.created_at,
+ r.updated_at,
+ r.deleted_at,
+ rt.name as request_type_name,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM requests r
+JOIN request_types rt ON rt.id = r.request_type_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.status = $1
+ AND r.deleted_at IS NULL
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetRequestsByStatusParams struct {
+ Status NullRequestStatus `json:"status"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetRequestsByStatusRow struct {
+ ID int64 `json:"id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address string `json:"address"`
+ City pgtype.Text `json:"city"`
+ DesiredCompletionDate pgtype.Timestamptz `json:"desired_completion_date"`
+ Urgency pgtype.Text `json:"urgency"`
+ ContactPhone pgtype.Text `json:"contact_phone"`
+ ContactNotes pgtype.Text `json:"contact_notes"`
+ Status NullRequestStatus `json:"status"`
+ AssignedVolunteerID pgtype.Int8 `json:"assigned_volunteer_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ RequestTypeName string `json:"request_type_name"`
+ RequesterName interface{} `json:"requester_name"`
+}
+
+func (q *Queries) GetRequestsByStatus(ctx context.Context, arg GetRequestsByStatusParams) ([]GetRequestsByStatusRow, error) {
+ rows, err := q.db.Query(ctx, GetRequestsByStatus, arg.Status, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetRequestsByStatusRow{}
+ for rows.Next() {
+ var i GetRequestsByStatusRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequesterID,
+ &i.RequestTypeID,
+ &i.Title,
+ &i.Description,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.DesiredCompletionDate,
+ &i.Urgency,
+ &i.ContactPhone,
+ &i.ContactNotes,
+ &i.Status,
+ &i.AssignedVolunteerID,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.DeletedAt,
+ &i.RequestTypeName,
+ &i.RequesterName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const ListRequestTypes = `-- name: ListRequestTypes :many
+
+SELECT id, name, description, icon, is_active, created_at FROM request_types
+WHERE is_active = TRUE
+ORDER BY name
+`
+
+// ============================================================================
+// Типы заявок
+// ============================================================================
+func (q *Queries) ListRequestTypes(ctx context.Context) ([]RequestType, error) {
+ rows, err := q.db.Query(ctx, ListRequestTypes)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []RequestType{}
+ for rows.Next() {
+ var i RequestType
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Description,
+ &i.Icon,
+ &i.IsActive,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const ModerateRequest = `-- name: ModerateRequest :exec
+UPDATE requests SET
+ status = $2,
+ moderated_by = $3,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = $4,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+type ModerateRequestParams struct {
+ ID int64 `json:"id"`
+ Status NullRequestStatus `json:"status"`
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ ModerationComment pgtype.Text `json:"moderation_comment"`
+}
+
+func (q *Queries) ModerateRequest(ctx context.Context, arg ModerateRequestParams) error {
+ _, err := q.db.Exec(ctx, ModerateRequest,
+ arg.ID,
+ arg.Status,
+ arg.ModeratedBy,
+ arg.ModerationComment,
+ )
+ return err
+}
+
+const RejectRequest = `-- name: RejectRequest :exec
+UPDATE requests SET
+ status = 'rejected',
+ moderated_by = $2,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = $3,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND status = 'pending_moderation'
+ AND deleted_at IS NULL
+`
+
+type RejectRequestParams struct {
+ ID int64 `json:"id"`
+ ModeratedBy pgtype.Int8 `json:"moderated_by"`
+ ModerationComment pgtype.Text `json:"moderation_comment"`
+}
+
+func (q *Queries) RejectRequest(ctx context.Context, arg RejectRequestParams) error {
+ _, err := q.db.Exec(ctx, RejectRequest, arg.ID, arg.ModeratedBy, arg.ModerationComment)
+ return err
+}
+
+const UpdateRequestStatus = `-- name: UpdateRequestStatus :exec
+
+UPDATE requests SET
+ status = $2,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+ AND deleted_at IS NULL
+`
+
+type UpdateRequestStatusParams struct {
+ ID int64 `json:"id"`
+ Status NullRequestStatus `json:"status"`
+}
+
+// ============================================================================
+// Обновление заявок
+// ============================================================================
+func (q *Queries) UpdateRequestStatus(ctx context.Context, arg UpdateRequestStatusParams) error {
+ _, err := q.db.Exec(ctx, UpdateRequestStatus, arg.ID, arg.Status)
+ return err
+}
diff --git a/internal/database/responses.sql.go b/internal/database/responses.sql.go
new file mode 100644
index 0000000..53bb870
--- /dev/null
+++ b/internal/database/responses.sql.go
@@ -0,0 +1,713 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: responses.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const AcceptVolunteerResponse = `-- name: AcceptVolunteerResponse :exec
+UPDATE volunteer_responses SET
+ status = 'accepted',
+ accepted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) AcceptVolunteerResponse(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, AcceptVolunteerResponse, id)
+ return err
+}
+
+const CalculateVolunteerAverageRating = `-- name: CalculateVolunteerAverageRating :one
+SELECT
+ COALESCE(AVG(rating), 0) as average_rating,
+ COUNT(*) as total_ratings
+FROM ratings
+WHERE volunteer_id = $1
+`
+
+type CalculateVolunteerAverageRatingRow struct {
+ AverageRating interface{} `json:"average_rating"`
+ TotalRatings int64 `json:"total_ratings"`
+}
+
+func (q *Queries) CalculateVolunteerAverageRating(ctx context.Context, volunteerID int64) (CalculateVolunteerAverageRatingRow, error) {
+ row := q.db.QueryRow(ctx, CalculateVolunteerAverageRating, volunteerID)
+ var i CalculateVolunteerAverageRatingRow
+ err := row.Scan(&i.AverageRating, &i.TotalRatings)
+ return i, err
+}
+
+const CallAcceptVolunteerResponse = `-- name: CallAcceptVolunteerResponse :one
+
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT,
+ r.out_request_id::BIGINT,
+ r.out_volunteer_id::BIGINT
+FROM accept_volunteer_response($1, $2) AS r(success, message, out_request_id, out_volunteer_id)
+`
+
+type CallAcceptVolunteerResponseParams struct {
+ PResponseID int64 `json:"p_response_id"`
+ PRequesterID int64 `json:"p_requester_id"`
+}
+
+type CallAcceptVolunteerResponseRow struct {
+ Success bool `json:"r_success"`
+ Message string `json:"r_message"`
+ RequestID int64 `json:"r_out_request_id"`
+ VolunteerID int64 `json:"r_out_volunteer_id"`
+}
+
+// ============================================================================
+// Хранимые процедуры
+// ============================================================================
+func (q *Queries) CallAcceptVolunteerResponse(ctx context.Context, arg CallAcceptVolunteerResponseParams) (CallAcceptVolunteerResponseRow, error) {
+ row := q.db.QueryRow(ctx, CallAcceptVolunteerResponse, arg.PResponseID, arg.PRequesterID)
+ var i CallAcceptVolunteerResponseRow
+ err := row.Scan(
+ &i.Success,
+ &i.Message,
+ &i.RequestID,
+ &i.VolunteerID,
+ )
+ return i, err
+}
+
+const CallCompleteRequestWithRating = `-- name: CallCompleteRequestWithRating :one
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT,
+ r.out_rating_id::BIGINT
+FROM complete_request_with_rating($1, $2, $3, $4) AS r(success, message, out_rating_id)
+`
+
+type CallCompleteRequestWithRatingParams struct {
+ PRequestID int64 `json:"p_request_id"`
+ PRequesterID int64 `json:"p_requester_id"`
+ PRating int32 `json:"p_rating"`
+ Comment pgtype.Text `json:"comment"`
+}
+
+type CallCompleteRequestWithRatingRow struct {
+ Success bool `json:"r_success"`
+ Message string `json:"r_message"`
+ RatingID int64 `json:"r_out_rating_id"`
+}
+
+func (q *Queries) CallCompleteRequestWithRating(ctx context.Context, arg CallCompleteRequestWithRatingParams) (CallCompleteRequestWithRatingRow, error) {
+ row := q.db.QueryRow(ctx, CallCompleteRequestWithRating,
+ arg.PRequestID,
+ arg.PRequesterID,
+ arg.PRating,
+ arg.Comment,
+ )
+ var i CallCompleteRequestWithRatingRow
+ err := row.Scan(&i.Success, &i.Message, &i.RatingID)
+ return i, err
+}
+
+const CallModerateRequest = `-- name: CallModerateRequest :one
+SELECT
+ r.success::BOOLEAN,
+ r.message::TEXT
+FROM moderate_request($1, $2, $3, $4) AS r(success, message)
+`
+
+type CallModerateRequestParams struct {
+ PRequestID int64 `json:"p_request_id"`
+ PModeratorID int64 `json:"p_moderator_id"`
+ PAction string `json:"p_action"`
+ Comment pgtype.Text `json:"comment"`
+}
+
+type CallModerateRequestRow struct {
+ Success bool `json:"r_success"`
+ Message string `json:"r_message"`
+}
+
+func (q *Queries) CallModerateRequest(ctx context.Context, arg CallModerateRequestParams) (CallModerateRequestRow, error) {
+ row := q.db.QueryRow(ctx, CallModerateRequest,
+ arg.PRequestID,
+ arg.PModeratorID,
+ arg.PAction,
+ arg.Comment,
+ )
+ var i CallModerateRequestRow
+ err := row.Scan(&i.Success, &i.Message)
+ return i, err
+}
+
+const CountPendingResponsesByVolunteer = `-- name: CountPendingResponsesByVolunteer :one
+SELECT COUNT(*) FROM volunteer_responses
+WHERE volunteer_id = $1 AND status = 'pending'
+`
+
+func (q *Queries) CountPendingResponsesByVolunteer(ctx context.Context, volunteerID int64) (int64, error) {
+ row := q.db.QueryRow(ctx, CountPendingResponsesByVolunteer, volunteerID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const CountResponsesByRequest = `-- name: CountResponsesByRequest :one
+SELECT COUNT(*) FROM volunteer_responses
+WHERE request_id = $1
+`
+
+func (q *Queries) CountResponsesByRequest(ctx context.Context, requestID int64) (int64, error) {
+ row := q.db.QueryRow(ctx, CountResponsesByRequest, requestID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const CreateRating = `-- name: CreateRating :one
+
+INSERT INTO ratings (
+ volunteer_response_id,
+ volunteer_id,
+ requester_id,
+ request_id,
+ rating,
+ comment
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6
+) RETURNING id, volunteer_response_id, volunteer_id, requester_id, request_id, rating, comment, created_at, updated_at
+`
+
+type CreateRatingParams struct {
+ VolunteerResponseID int64 `json:"volunteer_response_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestID int64 `json:"request_id"`
+ Rating int32 `json:"rating"`
+ Comment pgtype.Text `json:"comment"`
+}
+
+// ============================================================================
+// Рейтинги
+// ============================================================================
+func (q *Queries) CreateRating(ctx context.Context, arg CreateRatingParams) (Rating, error) {
+ row := q.db.QueryRow(ctx, CreateRating,
+ arg.VolunteerResponseID,
+ arg.VolunteerID,
+ arg.RequesterID,
+ arg.RequestID,
+ arg.Rating,
+ arg.Comment,
+ )
+ var i Rating
+ err := row.Scan(
+ &i.ID,
+ &i.VolunteerResponseID,
+ &i.VolunteerID,
+ &i.RequesterID,
+ &i.RequestID,
+ &i.Rating,
+ &i.Comment,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const CreateStatusHistoryEntry = `-- name: CreateStatusHistoryEntry :one
+
+INSERT INTO request_status_history (
+ request_id,
+ from_status,
+ to_status,
+ changed_by,
+ comment
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5
+) RETURNING id, request_id, from_status, to_status, changed_by, comment, created_at
+`
+
+type CreateStatusHistoryEntryParams struct {
+ RequestID int64 `json:"request_id"`
+ FromStatus NullRequestStatus `json:"from_status"`
+ ToStatus RequestStatus `json:"to_status"`
+ ChangedBy int64 `json:"changed_by"`
+ Comment pgtype.Text `json:"comment"`
+}
+
+// ============================================================================
+// История изменения статусов заявок
+// ============================================================================
+func (q *Queries) CreateStatusHistoryEntry(ctx context.Context, arg CreateStatusHistoryEntryParams) (RequestStatusHistory, error) {
+ row := q.db.QueryRow(ctx, CreateStatusHistoryEntry,
+ arg.RequestID,
+ arg.FromStatus,
+ arg.ToStatus,
+ arg.ChangedBy,
+ arg.Comment,
+ )
+ var i RequestStatusHistory
+ err := row.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.FromStatus,
+ &i.ToStatus,
+ &i.ChangedBy,
+ &i.Comment,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const CreateVolunteerResponse = `-- name: CreateVolunteerResponse :one
+
+
+INSERT INTO volunteer_responses (
+ request_id,
+ volunteer_id,
+ message
+) VALUES (
+ $1,
+ $2,
+ $3
+)
+ON CONFLICT (request_id, volunteer_id) DO NOTHING
+RETURNING id, request_id, volunteer_id, status, message, responded_at, accepted_at, rejected_at, created_at, updated_at
+`
+
+type CreateVolunteerResponseParams struct {
+ RequestID int64 `json:"request_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ Message pgtype.Text `json:"message"`
+}
+
+// Фаза 3: Отклики волонтеров и история статусов (СРЕДНИЙ ПРИОРИТЕТ)
+// Запросы для управления откликами волонтеров и историей изменения статусов заявок
+// ============================================================================
+// Отклики волонтеров
+// ============================================================================
+func (q *Queries) CreateVolunteerResponse(ctx context.Context, arg CreateVolunteerResponseParams) (VolunteerResponse, error) {
+ row := q.db.QueryRow(ctx, CreateVolunteerResponse, arg.RequestID, arg.VolunteerID, arg.Message)
+ var i VolunteerResponse
+ err := row.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.VolunteerID,
+ &i.Status,
+ &i.Message,
+ &i.RespondedAt,
+ &i.AcceptedAt,
+ &i.RejectedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const GetLatestStatusChange = `-- name: GetLatestStatusChange :one
+SELECT
+ rsh.id, rsh.request_id, rsh.from_status, rsh.to_status, rsh.changed_by, rsh.comment, rsh.created_at,
+ (u.first_name || ' ' || u.last_name) as changed_by_name
+FROM request_status_history rsh
+JOIN users u ON u.id = rsh.changed_by
+WHERE rsh.request_id = $1
+ORDER BY rsh.created_at DESC
+LIMIT 1
+`
+
+type GetLatestStatusChangeRow struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ FromStatus NullRequestStatus `json:"from_status"`
+ ToStatus RequestStatus `json:"to_status"`
+ ChangedBy int64 `json:"changed_by"`
+ Comment pgtype.Text `json:"comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ ChangedByName interface{} `json:"changed_by_name"`
+}
+
+func (q *Queries) GetLatestStatusChange(ctx context.Context, requestID int64) (GetLatestStatusChangeRow, error) {
+ row := q.db.QueryRow(ctx, GetLatestStatusChange, requestID)
+ var i GetLatestStatusChangeRow
+ err := row.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.FromStatus,
+ &i.ToStatus,
+ &i.ChangedBy,
+ &i.Comment,
+ &i.CreatedAt,
+ &i.ChangedByName,
+ )
+ return i, err
+}
+
+const GetRatingByResponseID = `-- name: GetRatingByResponseID :one
+SELECT id, volunteer_response_id, volunteer_id, requester_id, request_id, rating, comment, created_at, updated_at FROM ratings
+WHERE volunteer_response_id = $1
+`
+
+func (q *Queries) GetRatingByResponseID(ctx context.Context, volunteerResponseID int64) (Rating, error) {
+ row := q.db.QueryRow(ctx, GetRatingByResponseID, volunteerResponseID)
+ var i Rating
+ err := row.Scan(
+ &i.ID,
+ &i.VolunteerResponseID,
+ &i.VolunteerID,
+ &i.RequesterID,
+ &i.RequestID,
+ &i.Rating,
+ &i.Comment,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const GetRatingsByVolunteer = `-- name: GetRatingsByVolunteer :many
+SELECT
+ r.id, r.volunteer_response_id, r.volunteer_id, r.requester_id, r.request_id, r.rating, r.comment, r.created_at, r.updated_at,
+ req.title as request_title,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM ratings r
+JOIN requests req ON req.id = r.request_id
+JOIN users u ON u.id = r.requester_id
+WHERE r.volunteer_id = $1
+ORDER BY r.created_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetRatingsByVolunteerParams struct {
+ VolunteerID int64 `json:"volunteer_id"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetRatingsByVolunteerRow struct {
+ ID int64 `json:"id"`
+ VolunteerResponseID int64 `json:"volunteer_response_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ RequesterID int64 `json:"requester_id"`
+ RequestID int64 `json:"request_id"`
+ Rating int32 `json:"rating"`
+ Comment pgtype.Text `json:"comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ RequestTitle string `json:"request_title"`
+ RequesterName interface{} `json:"requester_name"`
+}
+
+func (q *Queries) GetRatingsByVolunteer(ctx context.Context, arg GetRatingsByVolunteerParams) ([]GetRatingsByVolunteerRow, error) {
+ rows, err := q.db.Query(ctx, GetRatingsByVolunteer, arg.VolunteerID, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetRatingsByVolunteerRow{}
+ for rows.Next() {
+ var i GetRatingsByVolunteerRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.VolunteerResponseID,
+ &i.VolunteerID,
+ &i.RequesterID,
+ &i.RequestID,
+ &i.Rating,
+ &i.Comment,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.RequestTitle,
+ &i.RequesterName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetRequestStatusHistory = `-- name: GetRequestStatusHistory :many
+SELECT
+ rsh.id, rsh.request_id, rsh.from_status, rsh.to_status, rsh.changed_by, rsh.comment, rsh.created_at,
+ (u.first_name || ' ' || u.last_name) as changed_by_name
+FROM request_status_history rsh
+JOIN users u ON u.id = rsh.changed_by
+WHERE rsh.request_id = $1
+ORDER BY rsh.created_at DESC
+`
+
+type GetRequestStatusHistoryRow struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ FromStatus NullRequestStatus `json:"from_status"`
+ ToStatus RequestStatus `json:"to_status"`
+ ChangedBy int64 `json:"changed_by"`
+ Comment pgtype.Text `json:"comment"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ ChangedByName interface{} `json:"changed_by_name"`
+}
+
+func (q *Queries) GetRequestStatusHistory(ctx context.Context, requestID int64) ([]GetRequestStatusHistoryRow, error) {
+ rows, err := q.db.Query(ctx, GetRequestStatusHistory, requestID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetRequestStatusHistoryRow{}
+ for rows.Next() {
+ var i GetRequestStatusHistoryRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.FromStatus,
+ &i.ToStatus,
+ &i.ChangedBy,
+ &i.Comment,
+ &i.CreatedAt,
+ &i.ChangedByName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetResponseByID = `-- name: GetResponseByID :one
+SELECT
+ vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
+ (u.first_name || ' ' || u.last_name) as volunteer_name,
+ r.title as request_title
+FROM volunteer_responses vr
+JOIN users u ON u.id = vr.volunteer_id
+JOIN requests r ON r.id = vr.request_id
+WHERE vr.id = $1
+`
+
+type GetResponseByIDRow struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ Status NullResponseStatus `json:"status"`
+ Message pgtype.Text `json:"message"`
+ RespondedAt pgtype.Timestamptz `json:"responded_at"`
+ AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
+ RejectedAt pgtype.Timestamptz `json:"rejected_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ VolunteerName interface{} `json:"volunteer_name"`
+ RequestTitle string `json:"request_title"`
+}
+
+func (q *Queries) GetResponseByID(ctx context.Context, id int64) (GetResponseByIDRow, error) {
+ row := q.db.QueryRow(ctx, GetResponseByID, id)
+ var i GetResponseByIDRow
+ err := row.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.VolunteerID,
+ &i.Status,
+ &i.Message,
+ &i.RespondedAt,
+ &i.AcceptedAt,
+ &i.RejectedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.VolunteerName,
+ &i.RequestTitle,
+ )
+ return i, err
+}
+
+const GetResponsesByRequest = `-- name: GetResponsesByRequest :many
+SELECT
+ vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
+ (u.first_name || ' ' || u.last_name) as volunteer_name,
+ u.avatar_url as volunteer_avatar,
+ u.volunteer_rating,
+ u.completed_requests_count,
+ u.email as volunteer_email,
+ u.phone as volunteer_phone
+FROM volunteer_responses vr
+JOIN users u ON u.id = vr.volunteer_id
+WHERE vr.request_id = $1
+ORDER BY vr.created_at DESC
+`
+
+type GetResponsesByRequestRow struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ Status NullResponseStatus `json:"status"`
+ Message pgtype.Text `json:"message"`
+ RespondedAt pgtype.Timestamptz `json:"responded_at"`
+ AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
+ RejectedAt pgtype.Timestamptz `json:"rejected_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ VolunteerName interface{} `json:"volunteer_name"`
+ VolunteerAvatar pgtype.Text `json:"volunteer_avatar"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ VolunteerEmail string `json:"volunteer_email"`
+ VolunteerPhone pgtype.Text `json:"volunteer_phone"`
+}
+
+func (q *Queries) GetResponsesByRequest(ctx context.Context, requestID int64) ([]GetResponsesByRequestRow, error) {
+ rows, err := q.db.Query(ctx, GetResponsesByRequest, requestID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetResponsesByRequestRow{}
+ for rows.Next() {
+ var i GetResponsesByRequestRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.VolunteerID,
+ &i.Status,
+ &i.Message,
+ &i.RespondedAt,
+ &i.AcceptedAt,
+ &i.RejectedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.VolunteerName,
+ &i.VolunteerAvatar,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.VolunteerEmail,
+ &i.VolunteerPhone,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetResponsesByVolunteer = `-- name: GetResponsesByVolunteer :many
+SELECT
+ vr.id, vr.request_id, vr.volunteer_id, vr.status, vr.message, vr.responded_at, vr.accepted_at, vr.rejected_at, vr.created_at, vr.updated_at,
+ r.title as request_title,
+ r.status as request_status,
+ (u.first_name || ' ' || u.last_name) as requester_name
+FROM volunteer_responses vr
+JOIN requests r ON r.id = vr.request_id
+JOIN users u ON u.id = r.requester_id
+WHERE vr.volunteer_id = $1
+ORDER BY vr.created_at DESC
+LIMIT $2 OFFSET $3
+`
+
+type GetResponsesByVolunteerParams struct {
+ VolunteerID int64 `json:"volunteer_id"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type GetResponsesByVolunteerRow struct {
+ ID int64 `json:"id"`
+ RequestID int64 `json:"request_id"`
+ VolunteerID int64 `json:"volunteer_id"`
+ Status NullResponseStatus `json:"status"`
+ Message pgtype.Text `json:"message"`
+ RespondedAt pgtype.Timestamptz `json:"responded_at"`
+ AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
+ RejectedAt pgtype.Timestamptz `json:"rejected_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ RequestTitle string `json:"request_title"`
+ RequestStatus NullRequestStatus `json:"request_status"`
+ RequesterName interface{} `json:"requester_name"`
+}
+
+func (q *Queries) GetResponsesByVolunteer(ctx context.Context, arg GetResponsesByVolunteerParams) ([]GetResponsesByVolunteerRow, error) {
+ rows, err := q.db.Query(ctx, GetResponsesByVolunteer, arg.VolunteerID, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetResponsesByVolunteerRow{}
+ for rows.Next() {
+ var i GetResponsesByVolunteerRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RequestID,
+ &i.VolunteerID,
+ &i.Status,
+ &i.Message,
+ &i.RespondedAt,
+ &i.AcceptedAt,
+ &i.RejectedAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.RequestTitle,
+ &i.RequestStatus,
+ &i.RequesterName,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const RejectVolunteerResponse = `-- name: RejectVolunteerResponse :exec
+UPDATE volunteer_responses SET
+ status = 'rejected',
+ rejected_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) RejectVolunteerResponse(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, RejectVolunteerResponse, id)
+ return err
+}
+
+const UpdateRating = `-- name: UpdateRating :exec
+UPDATE ratings SET
+ rating = $2,
+ comment = $3,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+type UpdateRatingParams struct {
+ ID int64 `json:"id"`
+ Rating int32 `json:"rating"`
+ Comment pgtype.Text `json:"comment"`
+}
+
+func (q *Queries) UpdateRating(ctx context.Context, arg UpdateRatingParams) error {
+ _, err := q.db.Exec(ctx, UpdateRating, arg.ID, arg.Rating, arg.Comment)
+ return err
+}
diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go
new file mode 100644
index 0000000..934a4e1
--- /dev/null
+++ b/internal/database/users.sql.go
@@ -0,0 +1,413 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.30.0
+// source: users.sql
+
+package database
+
+import (
+ "context"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const BlockUser = `-- name: BlockUser :exec
+UPDATE users SET
+ is_blocked = TRUE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) BlockUser(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, BlockUser, id)
+ return err
+}
+
+const GetUserProfile = `-- name: GetUserProfile :one
+
+
+SELECT
+ id,
+ email,
+ phone,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at
+FROM users
+WHERE id = $1 AND deleted_at IS NULL
+`
+
+type GetUserProfileRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+}
+
+// Фаза 1C: Управление профилем (КРИТИЧНО)
+// Запросы для получения и обновления профилей пользователей
+// ============================================================================
+// Профиль пользователя
+// ============================================================================
+func (q *Queries) GetUserProfile(ctx context.Context, id int64) (GetUserProfileRow, error) {
+ row := q.db.QueryRow(ctx, GetUserProfile, id)
+ var i GetUserProfileRow
+ err := row.Scan(
+ &i.ID,
+ &i.Email,
+ &i.Phone,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ &i.IsBlocked,
+ &i.EmailVerified,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastLoginAt,
+ )
+ return i, err
+}
+
+const GetUsersByIDs = `-- name: GetUsersByIDs :many
+
+SELECT
+ id,
+ email,
+ phone,
+ password_hash,
+ first_name,
+ last_name,
+ avatar_url,
+ ST_Y(location::geometry) as latitude,
+ ST_X(location::geometry) as longitude,
+ address,
+ city,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified,
+ is_blocked,
+ email_verified,
+ created_at,
+ updated_at,
+ last_login_at,
+ deleted_at
+FROM users
+WHERE id = ANY($1::bigint[])
+ AND deleted_at IS NULL
+`
+
+type GetUsersByIDsRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ Phone pgtype.Text `json:"phone"`
+ PasswordHash string `json:"password_hash"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Latitude interface{} `json:"latitude"`
+ Longitude interface{} `json:"longitude"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+ IsBlocked pgtype.Bool `json:"is_blocked"`
+ EmailVerified pgtype.Bool `json:"email_verified"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ LastLoginAt pgtype.Timestamptz `json:"last_login_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+}
+
+// ============================================================================
+// Поиск пользователей
+// ============================================================================
+func (q *Queries) GetUsersByIDs(ctx context.Context, dollar_1 []int64) ([]GetUsersByIDsRow, error) {
+ rows, err := q.db.Query(ctx, GetUsersByIDs, dollar_1)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []GetUsersByIDsRow{}
+ for rows.Next() {
+ var i GetUsersByIDsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Email,
+ &i.Phone,
+ &i.PasswordHash,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.Latitude,
+ &i.Longitude,
+ &i.Address,
+ &i.City,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ &i.IsBlocked,
+ &i.EmailVerified,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.LastLoginAt,
+ &i.DeletedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const GetVolunteerStatistics = `-- name: GetVolunteerStatistics :one
+SELECT
+ id,
+ first_name,
+ last_name,
+ volunteer_rating,
+ completed_requests_count,
+ created_at as member_since
+FROM users
+WHERE id = $1
+ AND deleted_at IS NULL
+`
+
+type GetVolunteerStatisticsRow struct {
+ ID int64 `json:"id"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ MemberSince pgtype.Timestamptz `json:"member_since"`
+}
+
+func (q *Queries) GetVolunteerStatistics(ctx context.Context, id int64) (GetVolunteerStatisticsRow, error) {
+ row := q.db.QueryRow(ctx, GetVolunteerStatistics, id)
+ var i GetVolunteerStatisticsRow
+ err := row.Scan(
+ &i.ID,
+ &i.FirstName,
+ &i.LastName,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.MemberSince,
+ )
+ return i, err
+}
+
+const SearchUsersByName = `-- name: SearchUsersByName :many
+SELECT
+ id,
+ email,
+ first_name,
+ last_name,
+ avatar_url,
+ volunteer_rating,
+ completed_requests_count,
+ is_verified
+FROM users
+WHERE (first_name ILIKE '%' || $1 || '%' OR last_name ILIKE '%' || $1 || '%' OR (first_name || ' ' || last_name) ILIKE '%' || $1 || '%')
+ AND deleted_at IS NULL
+ AND is_blocked = FALSE
+ORDER BY volunteer_rating DESC NULLS LAST
+LIMIT $2 OFFSET $3
+`
+
+type SearchUsersByNameParams struct {
+ Column1 pgtype.Text `json:"column_1"`
+ Limit int32 `json:"limit"`
+ Offset int32 `json:"offset"`
+}
+
+type SearchUsersByNameRow struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ VolunteerRating pgtype.Numeric `json:"volunteer_rating"`
+ CompletedRequestsCount pgtype.Int4 `json:"completed_requests_count"`
+ IsVerified pgtype.Bool `json:"is_verified"`
+}
+
+func (q *Queries) SearchUsersByName(ctx context.Context, arg SearchUsersByNameParams) ([]SearchUsersByNameRow, error) {
+ rows, err := q.db.Query(ctx, SearchUsersByName, arg.Column1, arg.Limit, arg.Offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []SearchUsersByNameRow{}
+ for rows.Next() {
+ var i SearchUsersByNameRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Email,
+ &i.FirstName,
+ &i.LastName,
+ &i.AvatarUrl,
+ &i.VolunteerRating,
+ &i.CompletedRequestsCount,
+ &i.IsVerified,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const SoftDeleteUser = `-- name: SoftDeleteUser :exec
+UPDATE users SET
+ deleted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) SoftDeleteUser(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, SoftDeleteUser, id)
+ return err
+}
+
+const UnblockUser = `-- name: UnblockUser :exec
+UPDATE users SET
+ is_blocked = FALSE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) UnblockUser(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, UnblockUser, id)
+ return err
+}
+
+const UpdateUserLocation = `-- name: UpdateUserLocation :exec
+UPDATE users SET
+ location = ST_SetSRID(ST_MakePoint($2, $3), 4326)::geography,
+ address = COALESCE($4, address),
+ city = COALESCE($5, city),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+type UpdateUserLocationParams struct {
+ ID int64 `json:"id"`
+ StMakepoint interface{} `json:"st_makepoint"`
+ StMakepoint_2 interface{} `json:"st_makepoint_2"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+}
+
+func (q *Queries) UpdateUserLocation(ctx context.Context, arg UpdateUserLocationParams) error {
+ _, err := q.db.Exec(ctx, UpdateUserLocation,
+ arg.ID,
+ arg.StMakepoint,
+ arg.StMakepoint_2,
+ arg.Address,
+ arg.City,
+ )
+ return err
+}
+
+const UpdateUserPassword = `-- name: UpdateUserPassword :exec
+UPDATE users SET
+ password_hash = $2,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+type UpdateUserPasswordParams struct {
+ ID int64 `json:"id"`
+ PasswordHash string `json:"password_hash"`
+}
+
+func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error {
+ _, err := q.db.Exec(ctx, UpdateUserPassword, arg.ID, arg.PasswordHash)
+ return err
+}
+
+const UpdateUserProfile = `-- name: UpdateUserProfile :exec
+UPDATE users SET
+ phone = COALESCE($1, phone),
+ first_name = COALESCE($2, first_name),
+ last_name = COALESCE($3, last_name),
+ avatar_url = COALESCE($4, avatar_url),
+ address = COALESCE($5, address),
+ city = COALESCE($6, city),
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $7
+`
+
+type UpdateUserProfileParams struct {
+ Phone pgtype.Text `json:"phone"`
+ FirstName pgtype.Text `json:"first_name"`
+ LastName pgtype.Text `json:"last_name"`
+ AvatarUrl pgtype.Text `json:"avatar_url"`
+ Address pgtype.Text `json:"address"`
+ City pgtype.Text `json:"city"`
+ UserID int64 `json:"user_id"`
+}
+
+func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error {
+ _, err := q.db.Exec(ctx, UpdateUserProfile,
+ arg.Phone,
+ arg.FirstName,
+ arg.LastName,
+ arg.AvatarUrl,
+ arg.Address,
+ arg.City,
+ arg.UserID,
+ )
+ return err
+}
+
+const VerifyUserEmail = `-- name: VerifyUserEmail :exec
+UPDATE users SET
+ email_verified = TRUE,
+ updated_at = CURRENT_TIMESTAMP
+WHERE id = $1
+`
+
+func (q *Queries) VerifyUserEmail(ctx context.Context, id int64) error {
+ _, err := q.db.Exec(ctx, VerifyUserEmail, id)
+ return err
+}
diff --git a/internal/pkg/jwt/jwt.go b/internal/pkg/jwt/jwt.go
new file mode 100644
index 0000000..aff7574
--- /dev/null
+++ b/internal/pkg/jwt/jwt.go
@@ -0,0 +1,113 @@
+package jwt
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+// Claims содержит данные JWT токена
+type Claims struct {
+ UserID int64 `json:"user_id"`
+ Email string `json:"email"`
+ jwt.RegisteredClaims
+}
+
+// Manager управляет JWT токенами
+type Manager struct {
+ secretKey string
+ accessTokenDuration time.Duration
+ refreshTokenDuration time.Duration
+}
+
+// NewManager создает новый JWT менеджер
+func NewManager(secretKey string, accessDuration, refreshDuration time.Duration) *Manager {
+ return &Manager{
+ secretKey: secretKey,
+ accessTokenDuration: accessDuration,
+ refreshTokenDuration: refreshDuration,
+ }
+}
+
+// GenerateAccessToken генерирует access токен
+func (m *Manager) GenerateAccessToken(userID int64, email string) (string, error) {
+ claims := Claims{
+ UserID: userID,
+ Email: email,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.accessTokenDuration)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ NotBefore: jwt.NewNumericDate(time.Now()),
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(m.secretKey))
+}
+
+// GenerateRefreshToken генерирует refresh токен
+func (m *Manager) GenerateRefreshToken(userID int64, email string) (string, error) {
+ // Генерируем уникальный ID для токена
+ jti, err := generateJTI()
+ if err != nil {
+ return "", fmt.Errorf("failed to generate jti: %w", err)
+ }
+
+ claims := Claims{
+ UserID: userID,
+ Email: email,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ID: jti,
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(m.refreshTokenDuration)),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ NotBefore: jwt.NewNumericDate(time.Now()),
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(m.secretKey))
+}
+
+// generateJTI генерирует уникальный ID для JWT токена
+func generateJTI() (string, error) {
+ bytes := make([]byte, 16)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
+
+// ValidateToken валидирует токен и возвращает claims
+func (m *Manager) ValidateToken(tokenString string) (*Claims, error) {
+ token, err := 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(m.secretKey), nil
+ })
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse token: %w", err)
+ }
+
+ claims, ok := token.Claims.(*Claims)
+ if !ok || !token.Valid {
+ return nil, fmt.Errorf("invalid token claims")
+ }
+
+ return claims, nil
+}
+
+// GetExpirationTime возвращает время истечения access токена
+func (m *Manager) GetAccessTokenDuration() time.Duration {
+ return m.accessTokenDuration
+}
+
+// GetRefreshTokenDuration возвращает время истечения refresh токена
+func (m *Manager) GetRefreshTokenDuration() time.Duration {
+ return m.refreshTokenDuration
+}
diff --git a/internal/pkg/password/password.go b/internal/pkg/password/password.go
new file mode 100644
index 0000000..5aed23a
--- /dev/null
+++ b/internal/pkg/password/password.go
@@ -0,0 +1,36 @@
+package password
+
+import (
+ "fmt"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ // DefaultCost - стандартная стоимость хеширования bcrypt
+ DefaultCost = bcrypt.DefaultCost
+)
+
+// Hash хеширует пароль с использованием bcrypt
+func Hash(password string) (string, error) {
+ if password == "" {
+ return "", fmt.Errorf("password cannot be empty")
+ }
+
+ hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), DefaultCost)
+ if err != nil {
+ return "", fmt.Errorf("failed to hash password: %w", err)
+ }
+
+ return string(hashedBytes), nil
+}
+
+// Verify проверяет соответствие пароля хешу
+func Verify(hashedPassword, password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
+}
+
+// IsValid проверяет валидность пароля (минимальная длина)
+func IsValid(password string) bool {
+ return len(password) >= 8
+}
diff --git a/internal/repository/auth_repository.go b/internal/repository/auth_repository.go
new file mode 100644
index 0000000..36cfbf9
--- /dev/null
+++ b/internal/repository/auth_repository.go
@@ -0,0 +1,73 @@
+package repository
+
+import (
+ "context"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+)
+
+// AuthRepository предоставляет методы для работы с аутентификацией
+type AuthRepository struct {
+ queries *database.Queries
+}
+
+// NewAuthRepository создает новый AuthRepository
+func NewAuthRepository(queries *database.Queries) *AuthRepository {
+ return &AuthRepository{queries: queries}
+}
+
+// CreateRefreshToken создает новый refresh токен
+func (r *AuthRepository) CreateRefreshToken(ctx context.Context, params database.CreateRefreshTokenParams) (*database.RefreshToken, error) {
+ result, err := r.queries.CreateRefreshToken(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetRefreshToken получает refresh токен
+func (r *AuthRepository) GetRefreshToken(ctx context.Context, token string) (*database.RefreshToken, error) {
+ result, err := r.queries.GetRefreshToken(ctx, token)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// RevokeRefreshToken отзывает refresh токен
+func (r *AuthRepository) RevokeRefreshToken(ctx context.Context, id int64) error {
+ return r.queries.RevokeRefreshToken(ctx, id)
+}
+
+// RevokeAllUserTokens отзывает все токены пользователя
+func (r *AuthRepository) RevokeAllUserTokens(ctx context.Context, userID int64) error {
+ return r.queries.RevokeAllUserTokens(ctx, userID)
+}
+
+// CreateUserSession создает новую сессию
+func (r *AuthRepository) CreateUserSession(ctx context.Context, params database.CreateUserSessionParams) (*database.UserSession, error) {
+ result, err := r.queries.CreateUserSession(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetUserSession получает сессию по токену
+func (r *AuthRepository) GetUserSession(ctx context.Context, sessionToken string) (*database.UserSession, error) {
+ result, err := r.queries.GetUserSession(ctx, sessionToken)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// UpdateSessionActivity обновляет время активности сессии
+func (r *AuthRepository) UpdateSessionActivity(ctx context.Context, id int64) error {
+ return r.queries.UpdateSessionActivity(ctx, id)
+}
+
+// InvalidateUserSession удаляет сессию
+func (r *AuthRepository) InvalidateUserSession(ctx context.Context, id int64) error {
+ return r.queries.InvalidateUserSession(ctx, id)
+}
diff --git a/internal/repository/rbac_repository.go b/internal/repository/rbac_repository.go
new file mode 100644
index 0000000..0d78a40
--- /dev/null
+++ b/internal/repository/rbac_repository.go
@@ -0,0 +1,60 @@
+package repository
+
+import (
+ "context"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+)
+
+// RBACRepository предоставляет методы для работы с RBAC
+type RBACRepository struct {
+ queries *database.Queries
+}
+
+// NewRBACRepository создает новый RBACRepository
+func NewRBACRepository(queries *database.Queries) *RBACRepository {
+ return &RBACRepository{queries: queries}
+}
+
+// GetUserRoles получает роли пользователя
+func (r *RBACRepository) GetUserRoles(ctx context.Context, userID int64) ([]database.Role, error) {
+ return r.queries.GetUserRoles(ctx, userID)
+}
+
+// AssignRoleToUser назначает роль пользователю
+func (r *RBACRepository) AssignRoleToUser(ctx context.Context, params database.AssignRoleToUserParams) (*database.UserRole, error) {
+ result, err := r.queries.AssignRoleToUser(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetRoleByName получает роль по имени
+func (r *RBACRepository) GetRoleByName(ctx context.Context, name string) (*database.Role, error) {
+ result, err := r.queries.GetRoleByName(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// UserHasRole проверяет наличие роли у пользователя
+func (r *RBACRepository) UserHasRole(ctx context.Context, params database.UserHasRoleParams) (bool, error) {
+ return r.queries.UserHasRole(ctx, params)
+}
+
+// UserHasRoleByName проверяет наличие роли по имени
+func (r *RBACRepository) UserHasRoleByName(ctx context.Context, params database.UserHasRoleByNameParams) (bool, error) {
+ return r.queries.UserHasRoleByName(ctx, params)
+}
+
+// GetUserPermissions получает все разрешения пользователя
+func (r *RBACRepository) GetUserPermissions(ctx context.Context, userID int64) ([]database.GetUserPermissionsRow, error) {
+ return r.queries.GetUserPermissions(ctx, userID)
+}
+
+// UserHasPermission проверяет наличие разрешения у пользователя
+func (r *RBACRepository) UserHasPermission(ctx context.Context, params database.UserHasPermissionParams) (bool, error) {
+ return r.queries.UserHasPermission(ctx, params)
+}
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
new file mode 100644
index 0000000..7b806e9
--- /dev/null
+++ b/internal/repository/repository.go
@@ -0,0 +1,26 @@
+package repository
+
+import (
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// Repository содержит все репозитории приложения
+type Repository struct {
+ User *UserRepository
+ Auth *AuthRepository
+ Request *RequestRepository
+ RBAC *RBACRepository
+}
+
+// New создает новый экземпляр Repository
+func New(pool *pgxpool.Pool) *Repository {
+ queries := database.New(pool)
+
+ return &Repository{
+ User: NewUserRepository(queries),
+ Auth: NewAuthRepository(queries),
+ Request: NewRequestRepository(queries),
+ RBAC: NewRBACRepository(queries),
+ }
+}
diff --git a/internal/repository/request_repository.go b/internal/repository/request_repository.go
new file mode 100644
index 0000000..bc3c3b2
--- /dev/null
+++ b/internal/repository/request_repository.go
@@ -0,0 +1,165 @@
+package repository
+
+import (
+ "context"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// RequestRepository предоставляет методы для работы с заявками
+type RequestRepository struct {
+ queries *database.Queries
+}
+
+// NewRequestRepository создает новый RequestRepository
+func NewRequestRepository(queries *database.Queries) *RequestRepository {
+ return &RequestRepository{queries: queries}
+}
+
+// Create создает новую заявку
+func (r *RequestRepository) Create(ctx context.Context, params database.CreateRequestParams) (*database.CreateRequestRow, error) {
+ result, err := r.queries.CreateRequest(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetByID получает заявку по ID
+func (r *RequestRepository) GetByID(ctx context.Context, id int64) (*database.GetRequestByIDRow, error) {
+ result, err := r.queries.GetRequestByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetByRequester получает заявки пользователя
+func (r *RequestRepository) GetByRequester(ctx context.Context, params database.GetRequestsByRequesterParams) ([]database.GetRequestsByRequesterRow, error) {
+ return r.queries.GetRequestsByRequester(ctx, params)
+}
+
+// UpdateStatus обновляет статус заявки
+func (r *RequestRepository) UpdateStatus(ctx context.Context, params database.UpdateRequestStatusParams) error {
+ return r.queries.UpdateRequestStatus(ctx, params)
+}
+
+// Delete удаляет заявку (soft delete)
+func (r *RequestRepository) Delete(ctx context.Context, params database.DeleteRequestParams) error {
+ return r.queries.DeleteRequest(ctx, params)
+}
+
+// ListTypes получает список типов заявок
+func (r *RequestRepository) ListTypes(ctx context.Context) ([]database.RequestType, error) {
+ return r.queries.ListRequestTypes(ctx)
+}
+
+// FindNearby ищет заявки рядом с точкой
+func (r *RequestRepository) FindNearby(ctx context.Context, params database.FindRequestsNearbyParams) ([]database.FindRequestsNearbyRow, error) {
+ return r.queries.FindRequestsNearby(ctx, params)
+}
+
+// FindInBounds ищет заявки в прямоугольной области
+func (r *RequestRepository) FindInBounds(ctx context.Context, params database.FindRequestsInBoundsParams) ([]database.FindRequestsInBoundsRow, error) {
+ return r.queries.FindRequestsInBounds(ctx, params)
+}
+
+// CreateVolunteerResponse создает отклик волонтера
+func (r *RequestRepository) CreateVolunteerResponse(ctx context.Context, params database.CreateVolunteerResponseParams) (*database.VolunteerResponse, error) {
+ result, err := r.queries.CreateVolunteerResponse(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetResponsesByRequest получает отклики на заявку
+func (r *RequestRepository) GetResponsesByRequest(ctx context.Context, requestID int64) ([]database.GetResponsesByRequestRow, error) {
+ return r.queries.GetResponsesByRequest(ctx, requestID)
+}
+
+// GetPendingModerationRequests получает заявки на модерации
+func (r *RequestRepository) GetPendingModerationRequests(ctx context.Context, limit, offset int32) ([]database.GetPendingModerationRequestsRow, error) {
+ return r.queries.GetPendingModerationRequests(ctx, database.GetPendingModerationRequestsParams{
+ Limit: limit,
+ Offset: offset,
+ })
+}
+
+// ApproveRequest одобряет заявку
+func (r *RequestRepository) ApproveRequest(ctx context.Context, params database.ApproveRequestParams) error {
+ return r.queries.ApproveRequest(ctx, params)
+}
+
+// int64ToPgInt8 конвертирует int64 в pgtype.Int8
+func int64ToPgInt8(i int64) pgtype.Int8 {
+ if i == 0 {
+ return pgtype.Int8{Valid: false}
+ }
+ return pgtype.Int8{Int64: i, Valid: true}
+}
+
+// RejectRequest отклоняет заявку
+func (r *RequestRepository) RejectRequest(ctx context.Context, params database.RejectRequestParams) error {
+ return r.queries.RejectRequest(ctx, params)
+}
+
+// GetModeratedRequests получает заявки, модерированные указанным модератором
+func (r *RequestRepository) GetModeratedRequests(ctx context.Context, moderatorID int64, limit, offset int32) ([]database.GetModeratedRequestsRow, error) {
+ return r.queries.GetModeratedRequests(ctx, database.GetModeratedRequestsParams{
+ ModeratedBy: int64ToPgInt8(moderatorID),
+ Limit: limit,
+ Offset: offset,
+ })
+}
+
+// AcceptVolunteerResponse вызывает хранимую процедуру для принятия отклика
+func (r *RequestRepository) AcceptVolunteerResponse(ctx context.Context, responseID, requesterID int64) (*database.CallAcceptVolunteerResponseRow, error) {
+ result, err := r.queries.CallAcceptVolunteerResponse(ctx, database.CallAcceptVolunteerResponseParams{
+ PResponseID: responseID,
+ PRequesterID: requesterID,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// CompleteRequestWithRating вызывает хранимую процедуру для завершения заявки с рейтингом
+func (r *RequestRepository) CompleteRequestWithRating(ctx context.Context, requestID, requesterID int64, rating int32, comment *string) (*database.CallCompleteRequestWithRatingRow, error) {
+ params := database.CallCompleteRequestWithRatingParams{
+ PRequestID: requestID,
+ PRequesterID: requesterID,
+ PRating: rating,
+ }
+
+ if comment != nil {
+ params.Comment = pgtype.Text{String: *comment, Valid: true}
+ }
+
+ result, err := r.queries.CallCompleteRequestWithRating(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// ModerateRequestProcedure вызывает хранимую процедуру для модерации заявки
+func (r *RequestRepository) ModerateRequestProcedure(ctx context.Context, requestID, moderatorID int64, action string, comment *string) (*database.CallModerateRequestRow, error) {
+ params := database.CallModerateRequestParams{
+ PRequestID: requestID,
+ PModeratorID: moderatorID,
+ PAction: action,
+ }
+
+ if comment != nil {
+ params.Comment = pgtype.Text{String: *comment, Valid: true}
+ }
+
+ result, err := r.queries.CallModerateRequest(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go
new file mode 100644
index 0000000..7357322
--- /dev/null
+++ b/internal/repository/user_repository.go
@@ -0,0 +1,78 @@
+package repository
+
+import (
+ "context"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+)
+
+// UserRepository предоставляет методы для работы с пользователями
+type UserRepository struct {
+ queries *database.Queries
+}
+
+// NewUserRepository создает новый UserRepository
+func NewUserRepository(queries *database.Queries) *UserRepository {
+ return &UserRepository{queries: queries}
+}
+
+// GetByID получает пользователя по ID
+func (r *UserRepository) GetByID(ctx context.Context, id int64) (*database.GetUserByIDRow, error) {
+ result, err := r.queries.GetUserByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// GetByEmail получает пользователя по email
+func (r *UserRepository) GetByEmail(ctx context.Context, email string) (*database.GetUserByEmailRow, error) {
+ result, err := r.queries.GetUserByEmail(ctx, email)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// Create создает нового пользователя
+func (r *UserRepository) Create(ctx context.Context, params database.CreateUserParams) (*database.CreateUserRow, error) {
+ result, err := r.queries.CreateUser(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// EmailExists проверяет существование email
+func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
+ return r.queries.EmailExists(ctx, email)
+}
+
+// UpdateLastLogin обновляет время последнего входа
+func (r *UserRepository) UpdateLastLogin(ctx context.Context, id int64) error {
+ return r.queries.UpdateLastLogin(ctx, id)
+}
+
+// GetProfile получает профиль пользователя
+func (r *UserRepository) GetProfile(ctx context.Context, id int64) (*database.GetUserProfileRow, error) {
+ result, err := r.queries.GetUserProfile(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ return &result, nil
+}
+
+// UpdateProfile обновляет профиль пользователя
+func (r *UserRepository) UpdateProfile(ctx context.Context, params database.UpdateUserProfileParams) error {
+ return r.queries.UpdateUserProfile(ctx, params)
+}
+
+// UpdateLocation обновляет геолокацию пользователя
+func (r *UserRepository) UpdateLocation(ctx context.Context, params database.UpdateUserLocationParams) error {
+ return r.queries.UpdateUserLocation(ctx, params)
+}
+
+// VerifyEmail подтверждает email пользователя
+func (r *UserRepository) VerifyEmail(ctx context.Context, id int64) error {
+ return r.queries.VerifyUserEmail(ctx, id)
+}
diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go
new file mode 100644
index 0000000..d8bde03
--- /dev/null
+++ b/internal/service/auth_service.go
@@ -0,0 +1,246 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
+ "git.kirlllll.ru/volontery/backend/internal/pkg/password"
+ "git.kirlllll.ru/volontery/backend/internal/repository"
+)
+
+// AuthService предоставляет методы для аутентификации
+type AuthService struct {
+ userRepo *repository.UserRepository
+ authRepo *repository.AuthRepository
+ rbacRepo *repository.RBACRepository
+ jwtMgr *jwt.Manager
+}
+
+// NewAuthService создает новый AuthService
+func NewAuthService(
+ userRepo *repository.UserRepository,
+ authRepo *repository.AuthRepository,
+ rbacRepo *repository.RBACRepository,
+ jwtMgr *jwt.Manager,
+) *AuthService {
+ return &AuthService{
+ userRepo: userRepo,
+ authRepo: authRepo,
+ rbacRepo: rbacRepo,
+ jwtMgr: jwtMgr,
+ }
+}
+
+// RegisterRequest - запрос на регистрацию
+type RegisterRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Phone string `json:"phone,omitempty"`
+ Latitude float64 `json:"latitude,omitempty"`
+ Longitude float64 `json:"longitude,omitempty"`
+ Address string `json:"address,omitempty"`
+ City string `json:"city,omitempty"`
+ Bio string `json:"bio,omitempty"`
+}
+
+// LoginRequest - запрос на вход
+type LoginRequest struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+// AuthResponse - ответ с токенами
+type AuthResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ User *UserInfo `json:"user"`
+}
+
+// UserInfo - информация о пользователе
+type UserInfo struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ FirstName string `json:"first_name"`
+ LastName string `json:"last_name"`
+ Verified bool `json:"email_verified"`
+}
+
+// Register регистрирует нового пользователя
+func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
+ // Валидация
+ if req.Email == "" {
+ return nil, fmt.Errorf("email is required")
+ }
+ if !password.IsValid(req.Password) {
+ return nil, fmt.Errorf("password must be at least 8 characters")
+ }
+ if req.FirstName == "" || req.LastName == "" {
+ return nil, fmt.Errorf("first name and last name are required")
+ }
+
+ // Проверка существования email
+ exists, err := s.userRepo.EmailExists(ctx, req.Email)
+ if err != nil {
+ return nil, fmt.Errorf("failed to check email: %w", err)
+ }
+ if exists {
+ return nil, fmt.Errorf("email already registered")
+ }
+
+ // Хеширование пароля
+ hashedPassword, err := password.Hash(req.Password)
+ if err != nil {
+ return nil, fmt.Errorf("failed to hash password: %w", err)
+ }
+
+ // Создание пользователя
+ user, err := s.userRepo.Create(ctx, database.CreateUserParams{
+ Email: req.Email,
+ PasswordHash: hashedPassword,
+ FirstName: req.FirstName,
+ LastName: req.LastName,
+ Phone: stringToPgText(req.Phone),
+ StMakepoint: req.Longitude,
+ StMakepoint_2: req.Latitude,
+ Address: stringToPgText(req.Address),
+ City: stringToPgText(req.City),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create user: %w", err)
+ }
+
+ // Назначение роли "requester" по умолчанию
+ requesterRole, err := s.rbacRepo.GetRoleByName(ctx, "requester")
+ if err == nil {
+ _, _ = s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
+ UserID: user.ID,
+ RoleID: requesterRole.ID,
+ AssignedBy: int64ToPgInt8(user.ID), // сам себе назначил
+ })
+ }
+
+ // Генерация токенов
+ return s.generateTokens(ctx, user.ID, user.Email, "", "")
+}
+
+// Login выполняет вход пользователя
+func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
+ // Получение пользователя
+ user, err := s.userRepo.GetByEmail(ctx, req.Email)
+ if err != nil {
+ return nil, fmt.Errorf("invalid email or password")
+ }
+
+ // Проверка пароля
+ if err := password.Verify(user.PasswordHash, req.Password); err != nil {
+ return nil, fmt.Errorf("invalid email or password")
+ }
+
+ // Проверка блокировки
+ if user.IsBlocked.Bool {
+ return nil, fmt.Errorf("user account is blocked")
+ }
+
+ // Обновление времени последнего входа
+ _ = s.userRepo.UpdateLastLogin(ctx, user.ID)
+
+ // Генерация токенов
+ return s.generateTokens(ctx, user.ID, user.Email, "", "")
+}
+
+// RefreshTokens обновляет токены
+func (s *AuthService) RefreshTokens(ctx context.Context, refreshTokenString string) (*AuthResponse, error) {
+ // Валидация refresh токена
+ claims, err := s.jwtMgr.ValidateToken(refreshTokenString)
+ if err != nil {
+ return nil, fmt.Errorf("invalid refresh token")
+ }
+
+ // Проверка токена в БД
+ storedToken, err := s.authRepo.GetRefreshToken(ctx, refreshTokenString)
+ if err != nil {
+ return nil, fmt.Errorf("refresh token not found or expired")
+ }
+
+ // Отзыв старого токена
+ _ = s.authRepo.RevokeRefreshToken(ctx, storedToken.ID)
+
+ // Получение пользователя
+ user, err := s.userRepo.GetByID(ctx, claims.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("user not found")
+ }
+
+ if user.IsBlocked.Bool {
+ return nil, fmt.Errorf("user account is blocked")
+ }
+
+ // Генерация новых токенов
+ return s.generateTokens(ctx, user.ID, user.Email, "", "")
+}
+
+// Logout выход пользователя
+func (s *AuthService) Logout(ctx context.Context, userID int64) error {
+ return s.authRepo.RevokeAllUserTokens(ctx, userID)
+}
+
+// generateTokens генерирует access и refresh токены
+func (s *AuthService) generateTokens(ctx context.Context, userID int64, email, userAgent, ipAddress string) (*AuthResponse, error) {
+ // Генерация access токена
+ accessToken, err := s.jwtMgr.GenerateAccessToken(userID, email)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate access token: %w", err)
+ }
+
+ // Генерация refresh токена
+ refreshToken, err := s.jwtMgr.GenerateRefreshToken(userID, email)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate refresh token: %w", err)
+ }
+
+ // Сохранение refresh токена в БД
+ expiresAt := time.Now().Add(s.jwtMgr.GetRefreshTokenDuration())
+ _, err = s.authRepo.CreateRefreshToken(ctx, database.CreateRefreshTokenParams{
+ UserID: userID,
+ Token: refreshToken,
+ ExpiresAt: timeToPgTimestamptz(expiresAt),
+ UserAgent: stringToPgText(userAgent),
+ IpAddress: nil, // IP адрес не передается в текущей реализации
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to save refresh token: %w", err)
+ }
+
+ // Получение информации о пользователе
+ user, _ := s.userRepo.GetByID(ctx, userID)
+
+ return &AuthResponse{
+ AccessToken: accessToken,
+ RefreshToken: refreshToken,
+ ExpiresIn: int64(s.jwtMgr.GetAccessTokenDuration().Seconds()),
+ User: &UserInfo{
+ ID: userID,
+ Email: email,
+ FirstName: user.FirstName,
+ LastName: user.LastName,
+ Verified: user.EmailVerified.Bool,
+ },
+ }, nil
+}
+
+// generateRandomToken генерирует случайный токен
+func generateRandomToken(length int) (string, error) {
+ bytes := make([]byte, length)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return base64.URLEncoding.EncodeToString(bytes), nil
+}
diff --git a/internal/service/helpers.go b/internal/service/helpers.go
new file mode 100644
index 0000000..fe6a243
--- /dev/null
+++ b/internal/service/helpers.go
@@ -0,0 +1,27 @@
+package service
+
+import (
+ "time"
+
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// Helper functions для конвертации типов Go в pgtype
+
+func stringToPgText(s string) pgtype.Text {
+ if s == "" {
+ return pgtype.Text{Valid: false}
+ }
+ return pgtype.Text{String: s, Valid: true}
+}
+
+func int64ToPgInt8(i int64) pgtype.Int8 {
+ if i == 0 {
+ return pgtype.Int8{Valid: false}
+ }
+ return pgtype.Int8{Int64: i, Valid: true}
+}
+
+func timeToPgTimestamptz(t time.Time) pgtype.Timestamptz {
+ return pgtype.Timestamptz{Time: t, Valid: true}
+}
diff --git a/internal/service/request_service.go b/internal/service/request_service.go
new file mode 100644
index 0000000..32ab511
--- /dev/null
+++ b/internal/service/request_service.go
@@ -0,0 +1,202 @@
+package service
+
+import (
+ "context"
+ "fmt"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "git.kirlllll.ru/volontery/backend/internal/repository"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+// RequestService предоставляет методы для работы с заявками
+type RequestService struct {
+ requestRepo *repository.RequestRepository
+}
+
+// NewRequestService создает новый RequestService
+func NewRequestService(requestRepo *repository.RequestRepository) *RequestService {
+ return &RequestService{
+ requestRepo: requestRepo,
+ }
+}
+
+// CreateRequestInput - входные данные для создания заявки
+type CreateRequestInput struct {
+ RequesterID int64 `json:"requester_id"`
+ RequestTypeID int64 `json:"request_type_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Latitude float64 `json:"latitude"`
+ Longitude float64 `json:"longitude"`
+ Address string `json:"address"`
+ City string `json:"city,omitempty"`
+ DesiredCompletionDate *string `json:"desired_completion_date,omitempty"`
+ Urgency string `json:"urgency"`
+ ContactPhone string `json:"contact_phone,omitempty"`
+ ContactNotes string `json:"contact_notes,omitempty"`
+}
+
+// CreateRequest создает новую заявку
+func (s *RequestService) CreateRequest(ctx context.Context, input CreateRequestInput) (*database.CreateRequestRow, error) {
+ // Валидация
+ if input.Title == "" {
+ return nil, fmt.Errorf("title is required")
+ }
+ if input.Description == "" {
+ return nil, fmt.Errorf("description is required")
+ }
+ if input.Latitude == 0 || input.Longitude == 0 {
+ return nil, fmt.Errorf("location is required")
+ }
+
+ // Создание заявки
+ return s.requestRepo.Create(ctx, database.CreateRequestParams{
+ RequesterID: input.RequesterID,
+ RequestTypeID: input.RequestTypeID,
+ Title: input.Title,
+ Description: input.Description,
+ StMakepoint: input.Longitude,
+ StMakepoint_2: input.Latitude,
+ Address: input.Address,
+ City: stringToPgText(input.City),
+ Urgency: stringToPgText(input.Urgency),
+ ContactPhone: stringToPgText(input.ContactPhone),
+ ContactNotes: stringToPgText(input.ContactNotes),
+ })
+}
+
+// GetRequest получает заявку по ID
+func (s *RequestService) GetRequest(ctx context.Context, id int64) (*database.GetRequestByIDRow, error) {
+ return s.requestRepo.GetByID(ctx, id)
+}
+
+// GetUserRequests получает заявки пользователя
+func (s *RequestService) GetUserRequests(ctx context.Context, userID int64, limit, offset int32) ([]database.GetRequestsByRequesterRow, error) {
+ return s.requestRepo.GetByRequester(ctx, database.GetRequestsByRequesterParams{
+ RequesterID: userID,
+ Limit: limit,
+ Offset: offset,
+ })
+}
+
+// FindNearbyRequests ищет заявки рядом с точкой
+func (s *RequestService) FindNearbyRequests(ctx context.Context, lat, lon float64, radiusMeters float64, statuses []database.RequestStatus, limit, offset int32) ([]database.FindRequestsNearbyRow, error) {
+ // Конвертируем []RequestStatus в []string
+ statusStrings := make([]string, len(statuses))
+ for i, status := range statuses {
+ statusStrings[i] = string(status)
+ }
+
+ return s.requestRepo.FindNearby(ctx, database.FindRequestsNearbyParams{
+ StMakepoint: lon,
+ StMakepoint_2: lat,
+ Column3: statusStrings,
+ StDwithin: radiusMeters,
+ Limit: limit,
+ Offset: offset,
+ })
+}
+
+// FindRequestsInBounds ищет заявки в прямоугольной области (для карты)
+func (s *RequestService) FindRequestsInBounds(ctx context.Context, statuses []database.RequestStatus, minLon, minLat, maxLon, maxLat float64) ([]database.FindRequestsInBoundsRow, error) {
+ // Конвертируем []RequestStatus в []string
+ statusStrings := make([]string, len(statuses))
+ for i, status := range statuses {
+ statusStrings[i] = string(status)
+ }
+
+ return s.requestRepo.FindInBounds(ctx, database.FindRequestsInBoundsParams{
+ Column1: statusStrings,
+ StMakeenvelope: minLon,
+ StMakeenvelope_2: minLat,
+ StMakeenvelope_3: maxLon,
+ StMakeenvelope_4: maxLat,
+ })
+}
+
+// CreateVolunteerResponse создает отклик волонтера на заявку
+func (s *RequestService) CreateVolunteerResponse(ctx context.Context, requestID, volunteerID int64, message string) (*database.VolunteerResponse, error) {
+ return s.requestRepo.CreateVolunteerResponse(ctx, database.CreateVolunteerResponseParams{
+ RequestID: requestID,
+ VolunteerID: volunteerID,
+ Message: stringToPgText(message),
+ })
+}
+
+// GetRequestResponses получает отклики на заявку
+func (s *RequestService) GetRequestResponses(ctx context.Context, requestID int64) ([]database.GetResponsesByRequestRow, error) {
+ return s.requestRepo.GetResponsesByRequest(ctx, requestID)
+}
+
+// ListRequestTypes получает список типов заявок
+func (s *RequestService) ListRequestTypes(ctx context.Context) ([]database.RequestType, error) {
+ return s.requestRepo.ListTypes(ctx)
+}
+
+// GetPendingModerationRequests получает заявки на модерации
+func (s *RequestService) GetPendingModerationRequests(ctx context.Context, limit, offset int32) ([]database.GetPendingModerationRequestsRow, error) {
+ return s.requestRepo.GetPendingModerationRequests(ctx, limit, offset)
+}
+
+// ApproveRequest одобряет заявку
+func (s *RequestService) ApproveRequest(ctx context.Context, requestID, moderatorID int64, comment *string) error {
+ moderationComment := stringToPgText("")
+ if comment != nil {
+ moderationComment = stringToPgText(*comment)
+ }
+
+ return s.requestRepo.ApproveRequest(ctx, database.ApproveRequestParams{
+ ID: requestID,
+ ModeratedBy: pgtype.Int8{
+ Int64: moderatorID,
+ Valid: true,
+ },
+ ModerationComment: moderationComment,
+ })
+}
+
+// RejectRequest отклоняет заявку
+func (s *RequestService) RejectRequest(ctx context.Context, requestID, moderatorID int64, comment string) error {
+ if comment == "" {
+ return fmt.Errorf("rejection comment is required")
+ }
+
+ return s.requestRepo.RejectRequest(ctx, database.RejectRequestParams{
+ ID: requestID,
+ ModeratedBy: pgtype.Int8{
+ Int64: moderatorID,
+ Valid: true,
+ },
+ ModerationComment: stringToPgText(comment),
+ })
+}
+
+// GetModeratedRequests получает заявки, модерированные указанным модератором
+func (s *RequestService) GetModeratedRequests(ctx context.Context, moderatorID int64, limit, offset int32) ([]database.GetModeratedRequestsRow, error) {
+ return s.requestRepo.GetModeratedRequests(ctx, moderatorID, limit, offset)
+}
+
+// AcceptVolunteerResponse принимает отклик волонтера через хранимую процедуру
+func (s *RequestService) AcceptVolunteerResponse(ctx context.Context, responseID, requesterID int64) (*database.CallAcceptVolunteerResponseRow, error) {
+ return s.requestRepo.AcceptVolunteerResponse(ctx, responseID, requesterID)
+}
+
+// CompleteRequestWithRating завершает заявку с рейтингом через хранимую процедуру
+func (s *RequestService) CompleteRequestWithRating(ctx context.Context, requestID, requesterID int64, rating int32, comment *string) (*database.CallCompleteRequestWithRatingRow, error) {
+ if rating < 1 || rating > 5 {
+ return nil, fmt.Errorf("rating must be between 1 and 5")
+ }
+ return s.requestRepo.CompleteRequestWithRating(ctx, requestID, requesterID, rating, comment)
+}
+
+// ModerateRequestProcedure модерирует заявку через хранимую процедуру
+func (s *RequestService) ModerateRequestProcedure(ctx context.Context, requestID, moderatorID int64, action string, comment *string) (*database.CallModerateRequestRow, error) {
+ if action != "approve" && action != "reject" {
+ return nil, fmt.Errorf("action must be 'approve' or 'reject'")
+ }
+ if action == "reject" && (comment == nil || *comment == "") {
+ return nil, fmt.Errorf("comment is required when rejecting")
+ }
+ return s.requestRepo.ModerateRequestProcedure(ctx, requestID, moderatorID, action, comment)
+}
diff --git a/internal/service/user_service.go b/internal/service/user_service.go
new file mode 100644
index 0000000..44ce60d
--- /dev/null
+++ b/internal/service/user_service.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "context"
+ "fmt"
+
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "git.kirlllll.ru/volontery/backend/internal/repository"
+)
+
+// UserService предоставляет методы для работы с пользователями
+type UserService struct {
+ userRepo *repository.UserRepository
+ rbacRepo *repository.RBACRepository
+}
+
+// NewUserService создает новый UserService
+func NewUserService(userRepo *repository.UserRepository, rbacRepo *repository.RBACRepository) *UserService {
+ return &UserService{
+ userRepo: userRepo,
+ rbacRepo: rbacRepo,
+ }
+}
+
+// GetUserProfile получает профиль пользователя
+func (s *UserService) GetUserProfile(ctx context.Context, userID int64) (*database.GetUserProfileRow, error) {
+ return s.userRepo.GetProfile(ctx, userID)
+}
+
+// UpdateProfileInput - входные данные для обновления профиля
+type UpdateProfileInput struct {
+ FirstName string `json:"first_name,omitempty"`
+ LastName string `json:"last_name,omitempty"`
+ Phone string `json:"phone,omitempty"`
+ Bio string `json:"bio,omitempty"`
+ Address string `json:"address,omitempty"`
+ City string `json:"city,omitempty"`
+}
+
+// UpdateUserProfile обновляет профиль пользователя
+func (s *UserService) UpdateUserProfile(ctx context.Context, userID int64, input UpdateProfileInput) error {
+ return s.userRepo.UpdateProfile(ctx, database.UpdateUserProfileParams{
+ UserID: userID,
+ FirstName: stringToPgText(input.FirstName),
+ LastName: stringToPgText(input.LastName),
+ Phone: stringToPgText(input.Phone),
+ Address: stringToPgText(input.Address),
+ City: stringToPgText(input.City),
+ })
+}
+
+// UpdateUserLocation обновляет местоположение пользователя
+func (s *UserService) UpdateUserLocation(ctx context.Context, userID int64, lat, lon float64) error {
+ if lat == 0 || lon == 0 {
+ return fmt.Errorf("invalid coordinates")
+ }
+
+ return s.userRepo.UpdateLocation(ctx, database.UpdateUserLocationParams{
+ ID: userID,
+ StMakepoint: lon,
+ StMakepoint_2: lat,
+ })
+}
+
+// VerifyEmail подтверждает email пользователя
+func (s *UserService) VerifyEmail(ctx context.Context, userID int64) error {
+ return s.userRepo.VerifyEmail(ctx, userID)
+}
+
+// GetUserRoles получает роли пользователя
+func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]database.Role, error) {
+ return s.rbacRepo.GetUserRoles(ctx, userID)
+}
+
+// GetUserPermissions получает разрешения пользователя
+func (s *UserService) GetUserPermissions(ctx context.Context, userID int64) ([]database.GetUserPermissionsRow, error) {
+ return s.rbacRepo.GetUserPermissions(ctx, userID)
+}
+
+// HasPermission проверяет наличие разрешения у пользователя
+func (s *UserService) HasPermission(ctx context.Context, userID int64, permissionName string) (bool, error) {
+ return s.rbacRepo.UserHasPermission(ctx, database.UserHasPermissionParams{
+ ID: userID,
+ Name: permissionName,
+ })
+}
+
+// AssignRole назначает роль пользователю
+func (s *UserService) AssignRole(ctx context.Context, userID, roleID, assignedBy int64) error {
+ _, err := s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
+ UserID: userID,
+ RoleID: roleID,
+ AssignedBy: int64ToPgInt8(assignedBy),
+ })
+ return err
+}
diff --git a/migrations/00001_enable_extensions.sql b/migrations/00001_enable_extensions.sql
new file mode 100644
index 0000000..bc801a4
--- /dev/null
+++ b/migrations/00001_enable_extensions.sql
@@ -0,0 +1,22 @@
+-- +goose Up
+-- +goose StatementBegin
+-- Включение расширения PostGIS для работы с геолокацией
+CREATE EXTENSION IF NOT EXISTS postgis;
+
+-- Включение расширения для генерации UUID (на случай будущего использования)
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Включение расширения для нечеткого поиска текста
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+COMMENT ON EXTENSION postgis IS 'PostGIS geometry and geography spatial types and functions';
+COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions';
+COMMENT ON EXTENSION pg_trgm IS 'Trigram similarity and distance functions for text search';
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP EXTENSION IF EXISTS pg_trgm;
+DROP EXTENSION IF EXISTS "uuid-ossp";
+DROP EXTENSION IF EXISTS postgis CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00002_create_base_dictionaries.sql b/migrations/00002_create_base_dictionaries.sql
new file mode 100644
index 0000000..fd7bca8
--- /dev/null
+++ b/migrations/00002_create_base_dictionaries.sql
@@ -0,0 +1,56 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТАБЛИЦА: roles - Роли пользователей
+-- =========================================
+CREATE TABLE roles (
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(50) NOT NULL UNIQUE,
+ description TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMENT ON TABLE roles IS 'Справочник ролей для RBAC системы';
+COMMENT ON COLUMN roles.name IS 'Уникальное название роли';
+
+-- =========================================
+-- ТАБЛИЦА: permissions - Разрешения системы
+-- =========================================
+CREATE TABLE permissions (
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL UNIQUE,
+ resource VARCHAR(50) NOT NULL,
+ action VARCHAR(50) NOT NULL,
+ description TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMENT ON TABLE permissions IS 'Справочник разрешений для RBAC системы';
+COMMENT ON COLUMN permissions.resource IS 'Ресурс: request, user, complaint и т.д.';
+COMMENT ON COLUMN permissions.action IS 'Действие: create, read, update, delete, moderate';
+
+-- =========================================
+-- ТАБЛИЦА: request_types - Типы заявок на помощь
+-- =========================================
+CREATE TABLE request_types (
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL UNIQUE,
+ description TEXT,
+ icon VARCHAR(50),
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMENT ON TABLE request_types IS 'Справочник типов помощи (продукты, медикаменты, техника)';
+COMMENT ON COLUMN request_types.icon IS 'Название иконки для UI';
+COMMENT ON COLUMN request_types.is_active IS 'Активность типа (для скрытия без удаления)';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS request_types CASCADE;
+DROP TABLE IF EXISTS permissions CASCADE;
+DROP TABLE IF EXISTS roles CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00003_create_users_table.sql b/migrations/00003_create_users_table.sql
new file mode 100644
index 0000000..b4427ea
--- /dev/null
+++ b/migrations/00003_create_users_table.sql
@@ -0,0 +1,59 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТАБЛИЦА: users - Пользователи системы
+-- =========================================
+CREATE TABLE users (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Аутентификация
+ email VARCHAR(255) NOT NULL UNIQUE,
+ phone VARCHAR(20),
+ password_hash VARCHAR(255) NOT NULL,
+
+ -- Профиль
+ first_name VARCHAR(100) NOT NULL,
+ last_name VARCHAR(100) NOT NULL,
+ avatar_url TEXT,
+
+ -- Геолокация (домашний адрес)
+ location GEOGRAPHY(POINT, 4326),
+ address TEXT,
+ city VARCHAR(100),
+
+ -- Статистика для волонтёров (денормализация для производительности)
+ volunteer_rating NUMERIC(3, 2) DEFAULT 0.00 CHECK (volunteer_rating >= 0 AND volunteer_rating <= 5),
+ completed_requests_count INTEGER DEFAULT 0 CHECK (completed_requests_count >= 0),
+
+ -- Статусы
+ is_verified BOOLEAN DEFAULT FALSE,
+ is_blocked BOOLEAN DEFAULT FALSE,
+ email_verified BOOLEAN DEFAULT FALSE,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ last_login_at TIMESTAMP WITH TIME ZONE,
+ deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+-- Комментарии
+COMMENT ON TABLE users IS 'Пользователи системы: маломобильные граждане, волонтёры, модераторы';
+COMMENT ON COLUMN users.location IS 'Координаты домашнего адреса в формате WGS84 (SRID 4326)';
+COMMENT ON COLUMN users.volunteer_rating IS 'Средний рейтинг волонтёра (0-5), обновляется триггером';
+COMMENT ON COLUMN users.completed_requests_count IS 'Количество выполненных заявок, обновляется триггером';
+COMMENT ON COLUMN users.deleted_at IS 'Soft delete - дата удаления пользователя';
+
+-- Индексы
+CREATE INDEX idx_users_email ON users(email);
+CREATE INDEX idx_users_phone ON users(phone) WHERE phone IS NOT NULL;
+CREATE INDEX idx_users_is_blocked ON users(is_blocked) WHERE is_blocked = TRUE;
+CREATE INDEX idx_users_deleted_at ON users(deleted_at) WHERE deleted_at IS NULL;
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS users CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00004_create_rbac_tables.sql b/migrations/00004_create_rbac_tables.sql
new file mode 100644
index 0000000..e30de48
--- /dev/null
+++ b/migrations/00004_create_rbac_tables.sql
@@ -0,0 +1,44 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТАБЛИЦА: user_roles - Связь пользователей и ролей (Many-to-Many)
+-- =========================================
+CREATE TABLE user_roles (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ assigned_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ assigned_by BIGINT REFERENCES users(id),
+ UNIQUE(user_id, role_id)
+);
+
+COMMENT ON TABLE user_roles IS 'Связь пользователей и ролей (Many-to-Many). Один пользователь может иметь несколько ролей';
+COMMENT ON COLUMN user_roles.assigned_by IS 'Кто назначил роль (для аудита)';
+
+-- =========================================
+-- ТАБЛИЦА: role_permissions - Связь ролей и разрешений (Many-to-Many)
+-- =========================================
+CREATE TABLE role_permissions (
+ id BIGSERIAL PRIMARY KEY,
+ role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
+ permission_id BIGINT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(role_id, permission_id)
+);
+
+COMMENT ON TABLE role_permissions IS 'Связь ролей и разрешений (Many-to-Many) для гибкой системы RBAC';
+
+-- Индексы для оптимизации запросов прав доступа
+CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
+CREATE INDEX idx_user_roles_role_id ON user_roles(role_id);
+CREATE INDEX idx_role_permissions_role_id ON role_permissions(role_id);
+CREATE INDEX idx_role_permissions_permission_id ON role_permissions(permission_id);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS role_permissions CASCADE;
+DROP TABLE IF EXISTS user_roles CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00005_create_requests_table.sql b/migrations/00005_create_requests_table.sql
new file mode 100644
index 0000000..2c75361
--- /dev/null
+++ b/migrations/00005_create_requests_table.sql
@@ -0,0 +1,103 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ENUM: request_status - Статусы заявки
+-- =========================================
+CREATE TYPE request_status AS ENUM (
+ 'pending_moderation', -- На модерации
+ 'approved', -- Одобрена, ожидает отклика волонтёра
+ 'in_progress', -- Взята волонтёром в работу
+ 'completed', -- Успешно выполнена
+ 'cancelled', -- Отменена заявителем
+ 'rejected' -- Отклонена модератором
+);
+
+COMMENT ON TYPE request_status IS 'Статусы жизненного цикла заявки на помощь';
+
+-- =========================================
+-- ТАБЛИЦА: requests - Заявки на помощь
+-- =========================================
+CREATE TABLE requests (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Связи
+ requester_id BIGINT NOT NULL REFERENCES users(id),
+ request_type_id BIGINT NOT NULL REFERENCES request_types(id),
+ assigned_volunteer_id BIGINT REFERENCES users(id),
+
+ -- Основная информация
+ title VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL,
+
+ -- Геолокация (обязательное поле для геопоиска)
+ location GEOGRAPHY(POINT, 4326) NOT NULL,
+ address TEXT NOT NULL,
+ city VARCHAR(100),
+
+ -- Детали
+ desired_completion_date TIMESTAMP WITH TIME ZONE,
+ urgency VARCHAR(20) DEFAULT 'medium' CHECK (urgency IN ('low', 'medium', 'high', 'urgent')),
+
+ -- Статус и модерация
+ status request_status DEFAULT 'pending_moderation',
+ moderation_comment TEXT,
+ moderated_by BIGINT REFERENCES users(id),
+ moderated_at TIMESTAMP WITH TIME ZONE,
+
+ -- Контактная информация
+ contact_phone VARCHAR(20),
+ contact_notes TEXT,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ completed_at TIMESTAMP WITH TIME ZONE,
+ deleted_at TIMESTAMP WITH TIME ZONE
+);
+
+-- Комментарии
+COMMENT ON TABLE requests IS 'Заявки на помощь от маломобильных граждан';
+COMMENT ON COLUMN requests.location IS 'Координаты места, где нужна помощь (WGS84, SRID 4326)';
+COMMENT ON COLUMN requests.urgency IS 'Срочность: low, medium, high, urgent';
+COMMENT ON COLUMN requests.assigned_volunteer_id IS 'Волонтёр, который взял заявку в работу';
+COMMENT ON COLUMN requests.contact_notes IS 'Дополнительная информация: код домофона, этаж и т.д.';
+COMMENT ON COLUMN requests.deleted_at IS 'Soft delete - дата удаления заявки';
+
+-- Индексы
+CREATE INDEX idx_requests_requester_id ON requests(requester_id) WHERE deleted_at IS NULL;
+CREATE INDEX idx_requests_assigned_volunteer_id ON requests(assigned_volunteer_id) WHERE assigned_volunteer_id IS NOT NULL;
+CREATE INDEX idx_requests_status ON requests(status) WHERE deleted_at IS NULL;
+CREATE INDEX idx_requests_type_id ON requests(request_type_id);
+CREATE INDEX idx_requests_created_at ON requests(created_at DESC);
+CREATE INDEX idx_requests_urgency ON requests(urgency) WHERE deleted_at IS NULL;
+CREATE INDEX idx_requests_deleted_at ON requests(deleted_at) WHERE deleted_at IS NULL;
+
+-- =========================================
+-- ТАБЛИЦА: request_status_history - История изменения статусов
+-- =========================================
+CREATE TABLE request_status_history (
+ id BIGSERIAL PRIMARY KEY,
+ request_id BIGINT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
+ from_status request_status,
+ to_status request_status NOT NULL,
+ changed_by BIGINT NOT NULL REFERENCES users(id),
+ comment TEXT,
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+COMMENT ON TABLE request_status_history IS 'Полная история изменения статусов заявок для аудита';
+COMMENT ON COLUMN request_status_history.from_status IS 'Предыдущий статус (NULL при создании)';
+
+-- Индекс для быстрого получения истории по заявке
+CREATE INDEX idx_request_status_history_request_id ON request_status_history(request_id);
+CREATE INDEX idx_request_status_history_created_at ON request_status_history(created_at DESC);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS request_status_history CASCADE;
+DROP TABLE IF EXISTS requests CASCADE;
+DROP TYPE IF EXISTS request_status CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00006_create_volunteer_responses_table.sql b/migrations/00006_create_volunteer_responses_table.sql
new file mode 100644
index 0000000..2f3c007
--- /dev/null
+++ b/migrations/00006_create_volunteer_responses_table.sql
@@ -0,0 +1,60 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ENUM: response_status - Статусы отклика
+-- =========================================
+CREATE TYPE response_status AS ENUM (
+ 'pending', -- Ожидает рассмотрения заявителем
+ 'accepted', -- Принят (волонтёр взял заявку)
+ 'rejected', -- Отклонён заявителем
+ 'cancelled' -- Отменён волонтёром
+);
+
+COMMENT ON TYPE response_status IS 'Статусы отклика волонтёра на заявку';
+
+-- =========================================
+-- ТАБЛИЦА: volunteer_responses - Отклики волонтёров на заявки
+-- =========================================
+CREATE TABLE volunteer_responses (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Связи
+ request_id BIGINT NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
+ volunteer_id BIGINT NOT NULL REFERENCES users(id),
+
+ -- Статус и сообщение
+ status response_status DEFAULT 'pending',
+ message TEXT,
+
+ -- Временные метки
+ responded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ accepted_at TIMESTAMP WITH TIME ZONE,
+ rejected_at TIMESTAMP WITH TIME ZONE,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
+ -- Ограничение: один волонтёр может откликнуться на заявку только один раз
+ UNIQUE(request_id, volunteer_id)
+);
+
+-- Комментарии
+COMMENT ON TABLE volunteer_responses IS 'Отклики волонтёров на заявки помощи';
+COMMENT ON COLUMN volunteer_responses.message IS 'Сообщение волонтёра при отклике (опционально)';
+COMMENT ON COLUMN volunteer_responses.responded_at IS 'Время создания отклика';
+
+-- Индексы
+CREATE INDEX idx_volunteer_responses_request_id ON volunteer_responses(request_id);
+CREATE INDEX idx_volunteer_responses_volunteer_id ON volunteer_responses(volunteer_id);
+CREATE INDEX idx_volunteer_responses_status ON volunteer_responses(status);
+CREATE INDEX idx_volunteer_responses_volunteer_status ON volunteer_responses(volunteer_id, status);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS volunteer_responses CASCADE;
+DROP TYPE IF EXISTS response_status CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00007_create_ratings_table.sql b/migrations/00007_create_ratings_table.sql
new file mode 100644
index 0000000..c8c8fd6
--- /dev/null
+++ b/migrations/00007_create_ratings_table.sql
@@ -0,0 +1,43 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТАБЛИЦА: ratings - Рейтинги волонтёров
+-- =========================================
+CREATE TABLE ratings (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Связи
+ volunteer_response_id BIGINT NOT NULL UNIQUE REFERENCES volunteer_responses(id),
+ volunteer_id BIGINT NOT NULL REFERENCES users(id),
+ requester_id BIGINT NOT NULL REFERENCES users(id),
+ request_id BIGINT NOT NULL REFERENCES requests(id),
+
+ -- Оценка
+ rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
+ comment TEXT,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Комментарии
+COMMENT ON TABLE ratings IS 'Рейтинги волонтёров за выполненную помощь';
+COMMENT ON COLUMN ratings.rating IS 'Оценка от 1 до 5 звёзд';
+COMMENT ON COLUMN ratings.volunteer_response_id IS 'Связь с откликом (один рейтинг на один отклик)';
+COMMENT ON COLUMN ratings.volunteer_id IS 'Денормализация для быстрого доступа';
+COMMENT ON COLUMN ratings.requester_id IS 'Кто оставил рейтинг';
+
+-- Индексы
+CREATE INDEX idx_ratings_volunteer_id ON ratings(volunteer_id);
+CREATE INDEX idx_ratings_request_id ON ratings(request_id);
+CREATE INDEX idx_ratings_requester_id ON ratings(requester_id);
+CREATE INDEX idx_ratings_created_at ON ratings(created_at DESC);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS ratings CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00008_create_complaints_and_blocks_tables.sql b/migrations/00008_create_complaints_and_blocks_tables.sql
new file mode 100644
index 0000000..d919db2
--- /dev/null
+++ b/migrations/00008_create_complaints_and_blocks_tables.sql
@@ -0,0 +1,110 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ENUM: complaint_status - Статусы жалобы
+-- =========================================
+CREATE TYPE complaint_status AS ENUM (
+ 'pending', -- Ожидает рассмотрения
+ 'in_review', -- На рассмотрении модератором
+ 'resolved', -- Разрешена
+ 'rejected' -- Отклонена
+);
+
+COMMENT ON TYPE complaint_status IS 'Статусы жизненного цикла жалобы';
+
+-- =========================================
+-- ENUM: complaint_type - Типы жалоб
+-- =========================================
+CREATE TYPE complaint_type AS ENUM (
+ 'inappropriate_behavior', -- Неподобающее поведение
+ 'no_show', -- Не явился
+ 'fraud', -- Мошенничество
+ 'spam', -- Спам
+ 'other' -- Другое
+);
+
+COMMENT ON TYPE complaint_type IS 'Типы жалоб на пользователей';
+
+-- =========================================
+-- ТАБЛИЦА: complaints - Жалобы
+-- =========================================
+CREATE TABLE complaints (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Связи
+ complainant_id BIGINT NOT NULL REFERENCES users(id), -- Кто жалуется
+ defendant_id BIGINT NOT NULL REFERENCES users(id), -- На кого жалуются
+ request_id BIGINT REFERENCES requests(id), -- Связанная заявка (опционально)
+
+ -- Содержание жалобы
+ type complaint_type NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NOT NULL,
+
+ -- Статус и обработка
+ status complaint_status DEFAULT 'pending',
+ moderator_id BIGINT REFERENCES users(id),
+ moderator_comment TEXT,
+ resolved_at TIMESTAMP WITH TIME ZONE,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Комментарии
+COMMENT ON TABLE complaints IS 'Жалобы пользователей друг на друга';
+COMMENT ON COLUMN complaints.complainant_id IS 'Пользователь, подающий жалобу';
+COMMENT ON COLUMN complaints.defendant_id IS 'Пользователь, на которого жалуются';
+
+-- Индексы
+CREATE INDEX idx_complaints_defendant_id ON complaints(defendant_id);
+CREATE INDEX idx_complaints_complainant_id ON complaints(complainant_id);
+CREATE INDEX idx_complaints_status ON complaints(status);
+CREATE INDEX idx_complaints_type ON complaints(type);
+CREATE INDEX idx_complaints_moderator_id ON complaints(moderator_id) WHERE moderator_id IS NOT NULL;
+
+-- =========================================
+-- ТАБЛИЦА: user_blocks - Блокировки пользователей
+-- =========================================
+CREATE TABLE user_blocks (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Связи
+ user_id BIGINT NOT NULL REFERENCES users(id),
+ blocked_by BIGINT NOT NULL REFERENCES users(id),
+ complaint_id BIGINT REFERENCES complaints(id), -- Связанная жалоба (опционально)
+
+ -- Детали блокировки
+ reason TEXT NOT NULL,
+ blocked_until TIMESTAMP WITH TIME ZONE, -- NULL = бессрочная блокировка
+
+ -- Статус
+ is_active BOOLEAN DEFAULT TRUE,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+ unblocked_at TIMESTAMP WITH TIME ZONE,
+ unblocked_by BIGINT REFERENCES users(id)
+);
+
+-- Комментарии
+COMMENT ON TABLE user_blocks IS 'Блокировки пользователей модераторами';
+COMMENT ON COLUMN user_blocks.blocked_until IS 'Дата окончания блокировки (NULL = бессрочная)';
+COMMENT ON COLUMN user_blocks.is_active IS 'Активна ли блокировка в данный момент';
+
+-- Индексы
+CREATE INDEX idx_user_blocks_user_id_active ON user_blocks(user_id, is_active) WHERE is_active = TRUE;
+CREATE INDEX idx_user_blocks_blocked_by ON user_blocks(blocked_by);
+CREATE INDEX idx_user_blocks_created_at ON user_blocks(created_at DESC);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS user_blocks CASCADE;
+DROP TABLE IF EXISTS complaints CASCADE;
+DROP TYPE IF EXISTS complaint_type CASCADE;
+DROP TYPE IF EXISTS complaint_status CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00009_create_moderator_actions_table.sql b/migrations/00009_create_moderator_actions_table.sql
new file mode 100644
index 0000000..8b5022e
--- /dev/null
+++ b/migrations/00009_create_moderator_actions_table.sql
@@ -0,0 +1,60 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ENUM: moderator_action_type - Типы действий модератора
+-- =========================================
+CREATE TYPE moderator_action_type AS ENUM (
+ 'approve_request', -- Одобрение заявки
+ 'reject_request', -- Отклонение заявки
+ 'block_user', -- Блокировка пользователя
+ 'unblock_user', -- Разблокировка пользователя
+ 'resolve_complaint', -- Разрешение жалобы
+ 'reject_complaint', -- Отклонение жалобы
+ 'edit_request', -- Редактирование заявки
+ 'delete_request' -- Удаление заявки
+);
+
+COMMENT ON TYPE moderator_action_type IS 'Типы действий модераторов для аудита';
+
+-- =========================================
+-- ТАБЛИЦА: moderator_actions - Логи действий модераторов
+-- =========================================
+CREATE TABLE moderator_actions (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Модератор
+ moderator_id BIGINT NOT NULL REFERENCES users(id),
+ action_type moderator_action_type NOT NULL,
+
+ -- Целевые объекты (опционально, зависит от типа действия)
+ target_user_id BIGINT REFERENCES users(id),
+ target_request_id BIGINT REFERENCES requests(id),
+ target_complaint_id BIGINT REFERENCES complaints(id),
+
+ -- Детали действия
+ comment TEXT,
+ metadata JSONB, -- Дополнительные данные в формате JSON
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Комментарии
+COMMENT ON TABLE moderator_actions IS 'Полный аудит всех действий модераторов в системе';
+COMMENT ON COLUMN moderator_actions.metadata IS 'Дополнительные данные в JSON (изменённые поля, причины и т.д.)';
+
+-- Индексы
+CREATE INDEX idx_moderator_actions_moderator_id ON moderator_actions(moderator_id);
+CREATE INDEX idx_moderator_actions_action_type ON moderator_actions(action_type);
+CREATE INDEX idx_moderator_actions_created_at ON moderator_actions(created_at DESC);
+CREATE INDEX idx_moderator_actions_target_user_id ON moderator_actions(target_user_id) WHERE target_user_id IS NOT NULL;
+CREATE INDEX idx_moderator_actions_target_request_id ON moderator_actions(target_request_id) WHERE target_request_id IS NOT NULL;
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS moderator_actions CASCADE;
+DROP TYPE IF EXISTS moderator_action_type CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00010_create_auth_tables.sql b/migrations/00010_create_auth_tables.sql
new file mode 100644
index 0000000..4c1ca54
--- /dev/null
+++ b/migrations/00010_create_auth_tables.sql
@@ -0,0 +1,82 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТАБЛИЦА: refresh_tokens - JWT Refresh токены
+-- =========================================
+CREATE TABLE refresh_tokens (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Пользователь
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+
+ -- Токен
+ token VARCHAR(512) NOT NULL UNIQUE,
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+
+ -- Метаданные запроса
+ user_agent TEXT,
+ ip_address INET,
+
+ -- Отзыв токена
+ revoked BOOLEAN DEFAULT FALSE,
+ revoked_at TIMESTAMP WITH TIME ZONE,
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Комментарии
+COMMENT ON TABLE refresh_tokens IS 'Refresh токены для JWT аутентификации';
+COMMENT ON COLUMN refresh_tokens.token IS 'Хеш refresh токена';
+COMMENT ON COLUMN refresh_tokens.revoked IS 'Токен отозван (для принудительного логаута)';
+
+-- Индексы
+CREATE UNIQUE INDEX idx_refresh_tokens_token ON refresh_tokens(token) WHERE revoked = FALSE;
+CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id) WHERE revoked = FALSE;
+CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
+
+-- =========================================
+-- ТАБЛИЦА: user_sessions - Активные сессии пользователей
+-- =========================================
+CREATE TABLE user_sessions (
+ id BIGSERIAL PRIMARY KEY,
+
+ -- Пользователь
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ session_token VARCHAR(512) NOT NULL UNIQUE,
+
+ -- Связь с refresh токеном
+ refresh_token_id BIGINT REFERENCES refresh_tokens(id),
+
+ -- Сессия
+ expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+ last_activity_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
+
+ -- Метаданные
+ user_agent TEXT,
+ ip_address INET,
+ device_info JSONB, -- Информация об устройстве в JSON
+
+ -- Аудит
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Комментарии
+COMMENT ON TABLE user_sessions IS 'Активные сессии пользователей для отслеживания активности';
+COMMENT ON COLUMN user_sessions.device_info IS 'Информация об устройстве: ОС, браузер, версия и т.д.';
+COMMENT ON COLUMN user_sessions.last_activity_at IS 'Последняя активность пользователя в сессии';
+
+-- Индексы
+CREATE UNIQUE INDEX idx_user_sessions_token ON user_sessions(session_token);
+CREATE INDEX idx_user_sessions_user_id ON user_sessions(user_id);
+CREATE INDEX idx_user_sessions_expires_at ON user_sessions(expires_at);
+CREATE INDEX idx_user_sessions_last_activity ON user_sessions(last_activity_at DESC);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE IF EXISTS user_sessions CASCADE;
+DROP TABLE IF EXISTS refresh_tokens CASCADE;
+-- +goose StatementEnd
diff --git a/migrations/00011_create_indexes_part1.sql b/migrations/00011_create_indexes_part1.sql
new file mode 100644
index 0000000..4dd019e
--- /dev/null
+++ b/migrations/00011_create_indexes_part1.sql
@@ -0,0 +1,58 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ДОПОЛНИТЕЛЬНЫЕ ИНДЕКСЫ ДЛЯ ОПТИМИЗАЦИИ
+-- =========================================
+
+-- Индексы для users
+CREATE INDEX idx_users_volunteer_rating ON users(volunteer_rating DESC) WHERE volunteer_rating > 0 AND deleted_at IS NULL;
+CREATE INDEX idx_users_completed_requests ON users(completed_requests_count DESC) WHERE completed_requests_count > 0 AND deleted_at IS NULL;
+CREATE INDEX idx_users_created_at ON users(created_at DESC);
+
+-- Составные индексы для requests (для сложных запросов)
+CREATE INDEX idx_requests_status_created ON requests(status, created_at DESC) WHERE deleted_at IS NULL;
+CREATE INDEX idx_requests_requester_status ON requests(requester_id, status) WHERE deleted_at IS NULL;
+CREATE INDEX idx_requests_volunteer_status ON requests(assigned_volunteer_id, status) WHERE assigned_volunteer_id IS NOT NULL;
+CREATE INDEX idx_requests_type_status ON requests(request_type_id, status) WHERE deleted_at IS NULL;
+
+-- Индексы для volunteer_responses
+CREATE INDEX idx_volunteer_responses_created_at ON volunteer_responses(created_at DESC);
+CREATE INDEX idx_volunteer_responses_request_status ON volunteer_responses(request_id, status);
+
+-- Индексы для ratings
+CREATE INDEX idx_ratings_volunteer_rating ON ratings(volunteer_id, rating DESC);
+
+-- Индексы для complaints
+CREATE INDEX idx_complaints_status_created ON complaints(status, created_at DESC);
+CREATE INDEX idx_complaints_defendant_status ON complaints(defendant_id, status);
+
+-- Индексы для user_blocks
+CREATE INDEX idx_user_blocks_blocked_until ON user_blocks(blocked_until) WHERE is_active = TRUE AND blocked_until IS NOT NULL;
+
+-- Полнотекстовый поиск для requests
+CREATE INDEX idx_requests_title_trgm ON requests USING gin(title gin_trgm_ops);
+CREATE INDEX idx_requests_description_trgm ON requests USING gin(description gin_trgm_ops);
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+DROP INDEX IF EXISTS idx_requests_description_trgm;
+DROP INDEX IF EXISTS idx_requests_title_trgm;
+DROP INDEX IF EXISTS idx_user_blocks_blocked_until;
+DROP INDEX IF EXISTS idx_complaints_defendant_status;
+DROP INDEX IF EXISTS idx_complaints_status_created;
+DROP INDEX IF EXISTS idx_ratings_volunteer_rating;
+DROP INDEX IF EXISTS idx_volunteer_responses_request_status;
+DROP INDEX IF EXISTS idx_volunteer_responses_created_at;
+DROP INDEX IF EXISTS idx_requests_type_status;
+DROP INDEX IF EXISTS idx_requests_volunteer_status;
+DROP INDEX IF EXISTS idx_requests_requester_status;
+DROP INDEX IF EXISTS idx_requests_status_created;
+DROP INDEX IF EXISTS idx_users_created_at;
+DROP INDEX IF EXISTS idx_users_completed_requests;
+DROP INDEX IF EXISTS idx_users_volunteer_rating;
+
+-- +goose StatementEnd
diff --git a/migrations/00012_create_indexes_part2_gist.sql b/migrations/00012_create_indexes_part2_gist.sql
new file mode 100644
index 0000000..77625a2
--- /dev/null
+++ b/migrations/00012_create_indexes_part2_gist.sql
@@ -0,0 +1,43 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- КРИТИЧЕСКИЕ GIST ИНДЕКСЫ ДЛЯ ГЕОПОИСКА
+-- =========================================
+
+-- GIST индекс для геолокации пользователей (волонтёров)
+-- Используется для поиска волонтёров рядом с заявкой
+CREATE INDEX idx_users_location_gist ON users USING GIST(location)
+WHERE location IS NOT NULL AND deleted_at IS NULL;
+
+-- GIST индекс для геолокации заявок
+-- Используется для поиска заявок рядом с волонтёром
+CREATE INDEX idx_requests_location_gist ON requests USING GIST(location)
+WHERE deleted_at IS NULL;
+
+-- Составной GIST индекс для геолокации + статус заявки
+-- Критично для алгоритма матчинга: поиск только одобренных заявок рядом
+CREATE INDEX idx_requests_location_status_gist ON requests USING GIST(location)
+WHERE status = 'approved' AND deleted_at IS NULL;
+
+-- GIST индекс для геолокации активных заявок
+-- Используется для поиска заявок, готовых к выполнению
+CREATE INDEX idx_requests_location_active_gist ON requests USING GIST(location)
+WHERE status IN ('approved', 'in_progress') AND deleted_at IS NULL;
+
+COMMENT ON INDEX idx_users_location_gist IS 'GIST индекс для быстрого геопоиска волонтёров';
+COMMENT ON INDEX idx_requests_location_gist IS 'GIST индекс для быстрого геопоиска всех заявок';
+COMMENT ON INDEX idx_requests_location_status_gist IS 'GIST индекс для поиска одобренных заявок (алгоритм матчинга)';
+COMMENT ON INDEX idx_requests_location_active_gist IS 'GIST индекс для поиска активных заявок';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+DROP INDEX IF EXISTS idx_requests_location_active_gist;
+DROP INDEX IF EXISTS idx_requests_location_status_gist;
+DROP INDEX IF EXISTS idx_requests_location_gist;
+DROP INDEX IF EXISTS idx_users_location_gist;
+
+-- +goose StatementEnd
diff --git a/migrations/00013_create_functions.sql b/migrations/00013_create_functions.sql
new file mode 100644
index 0000000..7bfd402
--- /dev/null
+++ b/migrations/00013_create_functions.sql
@@ -0,0 +1,158 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ФУНКЦИЯ: find_requests_nearby - Геопоиск заявок
+-- =========================================
+CREATE OR REPLACE FUNCTION find_requests_nearby(
+ lat DOUBLE PRECISION,
+ lon DOUBLE PRECISION,
+ radius_meters INTEGER DEFAULT 5000,
+ req_status request_status DEFAULT 'approved'
+)
+RETURNS TABLE (
+ id BIGINT,
+ title VARCHAR(255),
+ description TEXT,
+ address TEXT,
+ distance_meters DOUBLE PRECISION,
+ urgency VARCHAR(20),
+ created_at TIMESTAMP WITH TIME ZONE
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ r.id,
+ r.title,
+ r.description,
+ r.address,
+ ST_Distance(
+ r.location,
+ ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography
+ ) as distance_meters,
+ r.urgency,
+ r.created_at
+ FROM requests r
+ WHERE
+ r.status = req_status
+ AND r.deleted_at IS NULL
+ AND ST_DWithin(
+ r.location,
+ ST_SetSRID(ST_MakePoint(lon, lat), 4326)::geography,
+ radius_meters
+ )
+ ORDER BY distance_meters;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION find_requests_nearby IS 'Поиск заявок в радиусе от точки с возвратом расстояния в метрах';
+
+-- =========================================
+-- ФУНКЦИЯ: cleanup_expired_tokens - Очистка истёкших токенов
+-- =========================================
+CREATE OR REPLACE FUNCTION cleanup_expired_tokens()
+RETURNS INTEGER AS $$
+DECLARE
+ deleted_count INTEGER;
+BEGIN
+ -- Удаляем истёкшие refresh токены
+ DELETE FROM refresh_tokens
+ WHERE expires_at < CURRENT_TIMESTAMP
+ AND revoked = FALSE;
+
+ GET DIAGNOSTICS deleted_count = ROW_COUNT;
+
+ -- Удаляем истёкшие сессии
+ DELETE FROM user_sessions
+ WHERE expires_at < CURRENT_TIMESTAMP;
+
+ RETURN deleted_count;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION cleanup_expired_tokens IS 'Удаление истёкших токенов и сессий. Рекомендуется запускать по расписанию';
+
+-- =========================================
+-- ФУНКЦИЯ: calculate_distance_meters - Расчёт расстояния между точками
+-- =========================================
+CREATE OR REPLACE FUNCTION calculate_distance_meters(
+ lat1 DOUBLE PRECISION,
+ lon1 DOUBLE PRECISION,
+ lat2 DOUBLE PRECISION,
+ lon2 DOUBLE PRECISION
+)
+RETURNS DOUBLE PRECISION AS $$
+BEGIN
+ RETURN ST_Distance(
+ ST_SetSRID(ST_MakePoint(lon1, lat1), 4326)::geography,
+ ST_SetSRID(ST_MakePoint(lon2, lat2), 4326)::geography
+ );
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+COMMENT ON FUNCTION calculate_distance_meters IS 'Расчёт расстояния между двумя точками в метрах';
+
+-- =========================================
+-- ФУНКЦИЯ: get_user_permissions - Получение прав пользователя
+-- =========================================
+CREATE OR REPLACE FUNCTION get_user_permissions(p_user_id BIGINT)
+RETURNS TABLE (
+ permission_name VARCHAR(100),
+ resource VARCHAR(50),
+ action VARCHAR(50)
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT DISTINCT
+ p.name,
+ p.resource,
+ p.action
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = p_user_id
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION get_user_permissions IS 'Получение всех разрешений пользователя через его роли';
+
+-- =========================================
+-- ФУНКЦИЯ: has_permission - Проверка наличия разрешения
+-- =========================================
+CREATE OR REPLACE FUNCTION has_permission(
+ p_user_id BIGINT,
+ p_permission_name VARCHAR(100)
+)
+RETURNS BOOLEAN AS $$
+BEGIN
+ RETURN EXISTS(
+ SELECT 1
+ FROM users u
+ JOIN user_roles ur ON ur.user_id = u.id
+ JOIN role_permissions rp ON rp.role_id = ur.role_id
+ JOIN permissions p ON p.id = rp.permission_id
+ WHERE u.id = p_user_id
+ AND p.name = p_permission_name
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+ );
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION has_permission IS 'Быстрая проверка наличия конкретного разрешения у пользователя';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+DROP FUNCTION IF EXISTS has_permission;
+DROP FUNCTION IF EXISTS get_user_permissions;
+DROP FUNCTION IF EXISTS calculate_distance_meters;
+DROP FUNCTION IF EXISTS cleanup_expired_tokens;
+DROP FUNCTION IF EXISTS find_requests_nearby;
+
+-- +goose StatementEnd
diff --git a/migrations/00014_create_triggers.sql b/migrations/00014_create_triggers.sql
new file mode 100644
index 0000000..2239f42
--- /dev/null
+++ b/migrations/00014_create_triggers.sql
@@ -0,0 +1,198 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ТРИГГЕР: Автообновление updated_at
+-- =========================================
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Применяем триггер к таблицам с updated_at
+CREATE TRIGGER update_users_updated_at
+ BEFORE UPDATE ON users
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_requests_updated_at
+ BEFORE UPDATE ON requests
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_volunteer_responses_updated_at
+ BEFORE UPDATE ON volunteer_responses
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_ratings_updated_at
+ BEFORE UPDATE ON ratings
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_complaints_updated_at
+ BEFORE UPDATE ON complaints
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON FUNCTION update_updated_at_column IS 'Автоматическое обновление поля updated_at при изменении записи';
+
+-- =========================================
+-- ТРИГГЕР: Обновление рейтинга волонтёра
+-- =========================================
+CREATE OR REPLACE FUNCTION update_volunteer_rating()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Обновляем средний рейтинг и количество выполненных заявок
+ UPDATE users
+ SET
+ volunteer_rating = (
+ SELECT COALESCE(ROUND(AVG(rating)::numeric, 2), 0)
+ FROM ratings
+ WHERE volunteer_id = NEW.volunteer_id
+ ),
+ completed_requests_count = (
+ SELECT COUNT(*)
+ FROM ratings
+ WHERE volunteer_id = NEW.volunteer_id
+ )
+ WHERE id = NEW.volunteer_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Триггеры для INSERT и UPDATE рейтингов
+CREATE TRIGGER update_volunteer_rating_on_insert
+ AFTER INSERT ON ratings
+ FOR EACH ROW
+ EXECUTE FUNCTION update_volunteer_rating();
+
+CREATE TRIGGER update_volunteer_rating_on_update
+ AFTER UPDATE ON ratings
+ FOR EACH ROW
+ WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.volunteer_id IS DISTINCT FROM NEW.volunteer_id)
+ EXECUTE FUNCTION update_volunteer_rating();
+
+COMMENT ON FUNCTION update_volunteer_rating IS 'Автоматический пересчёт рейтинга волонтёра при добавлении/изменении оценки';
+
+-- =========================================
+-- ТРИГГЕР: Синхронизация статуса блокировки пользователя
+-- =========================================
+CREATE OR REPLACE FUNCTION sync_user_block_status()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- При INSERT или UPDATE активной блокировки
+ IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') AND NEW.is_active = TRUE THEN
+ UPDATE users SET is_blocked = TRUE WHERE id = NEW.user_id;
+
+ -- При DELETE или деактивации блокировки
+ ELSIF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.is_active = FALSE) THEN
+ -- Проверяем, есть ли другие активные блокировки
+ UPDATE users
+ SET is_blocked = EXISTS(
+ SELECT 1
+ FROM user_blocks
+ WHERE user_id = COALESCE(NEW.user_id, OLD.user_id)
+ AND is_active = TRUE
+ AND id != COALESCE(NEW.id, OLD.id)
+ )
+ WHERE id = COALESCE(NEW.user_id, OLD.user_id);
+ END IF;
+
+ RETURN COALESCE(NEW, OLD);
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER sync_user_block_status_trigger
+ AFTER INSERT OR UPDATE OR DELETE ON user_blocks
+ FOR EACH ROW
+ EXECUTE FUNCTION sync_user_block_status();
+
+COMMENT ON FUNCTION sync_user_block_status IS 'Синхронизация флага is_blocked в users при изменении блокировок';
+
+-- =========================================
+-- ТРИГГЕР: Автоматическое создание записи в истории статусов
+-- =========================================
+CREATE OR REPLACE FUNCTION log_request_status_change()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- При создании заявки
+ IF TG_OP = 'INSERT' THEN
+ INSERT INTO request_status_history (request_id, from_status, to_status, changed_by)
+ VALUES (NEW.id, NULL, NEW.status, NEW.requester_id);
+
+ -- При изменении статуса
+ ELSIF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN
+ INSERT INTO request_status_history (request_id, from_status, to_status, changed_by)
+ VALUES (
+ NEW.id,
+ OLD.status,
+ NEW.status,
+ COALESCE(NEW.moderated_by, NEW.assigned_volunteer_id, NEW.requester_id)
+ );
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER log_request_status_change_trigger
+ AFTER INSERT OR UPDATE ON requests
+ FOR EACH ROW
+ EXECUTE FUNCTION log_request_status_change();
+
+COMMENT ON FUNCTION log_request_status_change IS 'Автоматическое логирование всех изменений статусов заявок';
+
+-- =========================================
+-- ТРИГГЕР: Проверка истечения временной блокировки
+-- =========================================
+CREATE OR REPLACE FUNCTION check_block_expiration()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Если блокировка временная и истекла, деактивируем её
+ IF NEW.blocked_until IS NOT NULL AND NEW.blocked_until < CURRENT_TIMESTAMP THEN
+ NEW.is_active = FALSE;
+ NEW.unblocked_at = CURRENT_TIMESTAMP;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER check_block_expiration_trigger
+ BEFORE INSERT OR UPDATE ON user_blocks
+ FOR EACH ROW
+ WHEN (NEW.blocked_until IS NOT NULL)
+ EXECUTE FUNCTION check_block_expiration();
+
+COMMENT ON FUNCTION check_block_expiration IS 'Автоматическая деактивация истёкших временных блокировок';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+-- Удаляем триггеры
+DROP TRIGGER IF EXISTS check_block_expiration_trigger ON user_blocks;
+DROP TRIGGER IF EXISTS log_request_status_change_trigger ON requests;
+DROP TRIGGER IF EXISTS sync_user_block_status_trigger ON user_blocks;
+DROP TRIGGER IF EXISTS update_volunteer_rating_on_update ON ratings;
+DROP TRIGGER IF EXISTS update_volunteer_rating_on_insert ON ratings;
+DROP TRIGGER IF EXISTS update_complaints_updated_at ON complaints;
+DROP TRIGGER IF EXISTS update_ratings_updated_at ON ratings;
+DROP TRIGGER IF EXISTS update_volunteer_responses_updated_at ON volunteer_responses;
+DROP TRIGGER IF EXISTS update_requests_updated_at ON requests;
+DROP TRIGGER IF EXISTS update_users_updated_at ON users;
+
+-- Удаляем функции
+DROP FUNCTION IF EXISTS check_block_expiration;
+DROP FUNCTION IF EXISTS log_request_status_change;
+DROP FUNCTION IF EXISTS sync_user_block_status;
+DROP FUNCTION IF EXISTS update_volunteer_rating;
+DROP FUNCTION IF EXISTS update_updated_at_column;
+
+-- +goose StatementEnd
diff --git a/migrations/00015_create_matching_functions.sql b/migrations/00015_create_matching_functions.sql
new file mode 100644
index 0000000..4b77434
--- /dev/null
+++ b/migrations/00015_create_matching_functions.sql
@@ -0,0 +1,246 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ФУНКЦИЯ: match_requests_for_volunteer
+-- Алгоритм матчинга заявок для волонтёра
+-- =========================================
+CREATE OR REPLACE FUNCTION match_requests_for_volunteer(
+ volunteer_user_id BIGINT,
+ max_distance_meters INTEGER DEFAULT 10000,
+ limit_count INTEGER DEFAULT 20
+)
+RETURNS TABLE (
+ request_id BIGINT,
+ title VARCHAR(255),
+ description TEXT,
+ address TEXT,
+ city VARCHAR(100),
+ distance_meters DOUBLE PRECISION,
+ urgency VARCHAR(20),
+ request_type_name VARCHAR(100),
+ requester_name VARCHAR(255),
+ created_at TIMESTAMP WITH TIME ZONE,
+ match_score DOUBLE PRECISION
+) AS $$
+DECLARE
+ v_location GEOGRAPHY;
+ v_rating NUMERIC;
+ v_completed_count INTEGER;
+BEGIN
+ -- Получаем данные волонтёра
+ SELECT location, volunteer_rating, completed_requests_count
+ INTO v_location, v_rating, v_completed_count
+ FROM users
+ WHERE id = volunteer_user_id
+ AND deleted_at IS NULL
+ AND is_blocked = FALSE;
+
+ -- Проверяем, что волонтёр существует и имеет геолокацию
+ IF v_location IS NULL THEN
+ RAISE EXCEPTION 'Volunteer location not set or user not found';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ r.id as request_id,
+ r.title,
+ r.description,
+ r.address,
+ r.city,
+ ST_Distance(r.location, v_location) as distance_meters,
+ r.urgency,
+ rt.name as request_type_name,
+ (u.first_name || ' ' || u.last_name) as requester_name,
+ r.created_at,
+ -- Расчёт score для сортировки (чем выше, тем лучше подходит)
+ (
+ -- Фактор 1: Близость (50% веса)
+ -- Чем ближе, тем выше score
+ (1000000.0 / GREATEST(ST_Distance(r.location, v_location), 100)) * 0.5 +
+
+ -- Фактор 2: Срочность (30% веса)
+ (CASE r.urgency
+ WHEN 'urgent' THEN 100
+ WHEN 'high' THEN 70
+ WHEN 'medium' THEN 40
+ WHEN 'low' THEN 20
+ ELSE 30
+ END) * 0.3 +
+
+ -- Фактор 3: Давность заявки (20% веса)
+ -- Старые заявки получают больший приоритет
+ (EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - r.created_at)) / 3600) * 0.2
+ ) as match_score
+
+ FROM requests r
+ JOIN request_types rt ON rt.id = r.request_type_id
+ JOIN users u ON u.id = r.requester_id
+
+ WHERE
+ -- Только одобренные заявки
+ r.status = 'approved'
+ AND r.deleted_at IS NULL
+
+ -- Заявка ещё не взята
+ AND r.assigned_volunteer_id IS NULL
+
+ -- Волонтёр ещё не откликался на эту заявку
+ AND NOT EXISTS (
+ SELECT 1
+ FROM volunteer_responses vr
+ WHERE vr.request_id = r.id
+ AND vr.volunteer_id = volunteer_user_id
+ )
+
+ -- В пределах указанного радиуса
+ AND ST_DWithin(r.location, v_location, max_distance_meters)
+
+ -- Заявитель не заблокирован
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+
+ -- Сортировка по score (лучшие подходят первыми)
+ ORDER BY match_score DESC
+
+ LIMIT limit_count;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION match_requests_for_volunteer IS 'Алгоритм матчинга: подбирает подходящие заявки для волонтёра на основе геолокации, срочности и давности. Факторы: близость (50%), срочность (30%), давность (20%)';
+
+-- =========================================
+-- ФУНКЦИЯ: find_volunteers_for_request
+-- Поиск подходящих волонтёров для заявки
+-- =========================================
+CREATE OR REPLACE FUNCTION find_volunteers_for_request(
+ p_request_id BIGINT,
+ max_distance_meters INTEGER DEFAULT 10000,
+ min_rating NUMERIC DEFAULT 0.0,
+ limit_count INTEGER DEFAULT 20
+)
+RETURNS TABLE (
+ volunteer_id BIGINT,
+ volunteer_name VARCHAR(255),
+ volunteer_rating NUMERIC,
+ completed_requests_count INTEGER,
+ distance_meters DOUBLE PRECISION,
+ match_score DOUBLE PRECISION
+) AS $$
+DECLARE
+ r_location GEOGRAPHY;
+ r_urgency VARCHAR(20);
+BEGIN
+ -- Получаем данные заявки
+ SELECT location, urgency
+ INTO r_location, r_urgency
+ FROM requests
+ WHERE id = p_request_id
+ AND deleted_at IS NULL;
+
+ IF r_location IS NULL THEN
+ RAISE EXCEPTION 'Request not found or has no location';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ u.id as volunteer_id,
+ (u.first_name || ' ' || u.last_name) as volunteer_name,
+ u.volunteer_rating,
+ u.completed_requests_count,
+ ST_Distance(u.location, r_location) as distance_meters,
+ -- Score для сортировки волонтёров
+ (
+ -- Близость (40%)
+ (1000000.0 / GREATEST(ST_Distance(u.location, r_location), 100)) * 0.4 +
+
+ -- Рейтинг волонтёра (40%)
+ (u.volunteer_rating * 20) * 0.4 +
+
+ -- Опыт (количество выполненных заявок) (20%)
+ (LEAST(u.completed_requests_count, 50) * 2) * 0.2
+ ) as match_score
+
+ FROM users u
+ -- Проверяем, что у пользователя есть роль волонтёра
+ WHERE EXISTS (
+ SELECT 1
+ FROM user_roles ur
+ JOIN roles r ON r.id = ur.role_id
+ WHERE ur.user_id = u.id
+ AND r.name = 'volunteer'
+ )
+ -- Активный и не заблокированный
+ AND u.deleted_at IS NULL
+ AND u.is_blocked = FALSE
+
+ -- Есть геолокация
+ AND u.location IS NOT NULL
+
+ -- Минимальный рейтинг
+ AND u.volunteer_rating >= min_rating
+
+ -- В пределах радиуса
+ AND ST_DWithin(u.location, r_location, max_distance_meters)
+
+ -- Волонтёр ещё не откликался на эту заявку
+ AND NOT EXISTS (
+ SELECT 1
+ FROM volunteer_responses vr
+ WHERE vr.request_id = p_request_id
+ AND vr.volunteer_id = u.id
+ )
+
+ ORDER BY match_score DESC
+ LIMIT limit_count;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION find_volunteers_for_request IS 'Поиск подходящих волонтёров для заявки на основе близости, рейтинга и опыта';
+
+-- =========================================
+-- ФУНКЦИЯ: get_volunteer_statistics
+-- Статистика волонтёра
+-- =========================================
+CREATE OR REPLACE FUNCTION get_volunteer_statistics(p_volunteer_id BIGINT)
+RETURNS TABLE (
+ total_responses INTEGER,
+ accepted_responses INTEGER,
+ completed_requests INTEGER,
+ average_rating NUMERIC,
+ total_ratings INTEGER,
+ acceptance_rate NUMERIC
+) AS $$
+BEGIN
+ RETURN QUERY
+ SELECT
+ COUNT(DISTINCT vr.id)::INTEGER as total_responses,
+ COUNT(DISTINCT CASE WHEN vr.status = 'accepted' THEN vr.id END)::INTEGER as accepted_responses,
+ COUNT(DISTINCT r.id)::INTEGER as completed_requests,
+ COALESCE(ROUND(AVG(r.rating), 2), 0) as average_rating,
+ COUNT(DISTINCT r.id)::INTEGER as total_ratings,
+ CASE
+ WHEN COUNT(DISTINCT vr.id) > 0
+ THEN ROUND((COUNT(DISTINCT CASE WHEN vr.status = 'accepted' THEN vr.id END)::NUMERIC / COUNT(DISTINCT vr.id)::NUMERIC) * 100, 2)
+ ELSE 0
+ END as acceptance_rate
+ FROM users u
+ LEFT JOIN volunteer_responses vr ON vr.volunteer_id = u.id
+ LEFT JOIN ratings r ON r.volunteer_id = u.id
+ WHERE u.id = p_volunteer_id
+ AND u.deleted_at IS NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION get_volunteer_statistics IS 'Получение детальной статистики волонтёра';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+DROP FUNCTION IF EXISTS get_volunteer_statistics;
+DROP FUNCTION IF EXISTS find_volunteers_for_request;
+DROP FUNCTION IF EXISTS match_requests_for_volunteer;
+
+-- +goose StatementEnd
diff --git a/migrations/00016_seed_initial_data.sql b/migrations/00016_seed_initial_data.sql
new file mode 100644
index 0000000..adf5d53
--- /dev/null
+++ b/migrations/00016_seed_initial_data.sql
@@ -0,0 +1,187 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- НАЧАЛЬНЫЕ ДАННЫЕ: Роли
+-- =========================================
+INSERT INTO roles (name, description) VALUES
+ ('requester', 'Маломобильный гражданин - может создавать заявки на помощь'),
+ ('volunteer', 'Волонтёр - может откликаться на заявки и оказывать помощь'),
+ ('moderator', 'Модератор - управляет системой, модерирует заявки и жалобы'),
+ ('admin', 'Администратор - полный доступ ко всем функциям системы')
+ON CONFLICT (name) DO NOTHING;
+
+-- =========================================
+-- НАЧАЛЬНЫЕ ДАННЫЕ: Разрешения
+-- =========================================
+
+-- Разрешения для заявок
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('request.create', 'request', 'create', 'Создание заявок на помощь'),
+ ('request.read', 'request', 'read', 'Просмотр заявок'),
+ ('request.read_all', 'request', 'read', 'Просмотр всех заявок (включая чужие)'),
+ ('request.update_own', 'request', 'update', 'Редактирование своих заявок'),
+ ('request.delete_own', 'request', 'delete', 'Удаление своих заявок'),
+ ('request.moderate', 'request', 'moderate', 'Модерация заявок (одобрение/отклонение)'),
+ ('request.delete_any', 'request', 'delete', 'Удаление любых заявок')
+ON CONFLICT (name) DO NOTHING;
+
+-- Разрешения для откликов волонтёров
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('volunteer_response.create', 'volunteer_response', 'create', 'Отклик на заявки'),
+ ('volunteer_response.read', 'volunteer_response', 'read', 'Просмотр откликов'),
+ ('volunteer_response.cancel', 'volunteer_response', 'delete', 'Отмена своего отклика')
+ON CONFLICT (name) DO NOTHING;
+
+-- Разрешения для рейтингов
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('rating.create', 'rating', 'create', 'Оставление рейтинга волонтёру'),
+ ('rating.read', 'rating', 'read', 'Просмотр рейтингов'),
+ ('rating.read_all', 'rating', 'read', 'Просмотр всех рейтингов')
+ON CONFLICT (name) DO NOTHING;
+
+-- Разрешения для жалоб
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('complaint.create', 'complaint', 'create', 'Подача жалобы на пользователя'),
+ ('complaint.read_own', 'complaint', 'read', 'Просмотр своих жалоб'),
+ ('complaint.moderate', 'complaint', 'moderate', 'Обработка жалоб модератором'),
+ ('complaint.read_all', 'complaint', 'read', 'Просмотр всех жалоб')
+ON CONFLICT (name) DO NOTHING;
+
+-- Разрешения для пользователей
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('user.read_own', 'user', 'read', 'Просмотр своего профиля'),
+ ('user.update_own', 'user', 'update', 'Редактирование своего профиля'),
+ ('user.read_all', 'user', 'read', 'Просмотр всех пользователей'),
+ ('user.block', 'user', 'block', 'Блокировка пользователей'),
+ ('user.unblock', 'user', 'unblock', 'Разблокировка пользователей')
+ON CONFLICT (name) DO NOTHING;
+
+-- Разрешения для модераторских функций
+INSERT INTO permissions (name, resource, action, description) VALUES
+ ('moderator.view_logs', 'moderator_action', 'read', 'Просмотр логов модераторов'),
+ ('moderator.view_statistics', 'statistics', 'read', 'Просмотр статистики системы')
+ON CONFLICT (name) DO NOTHING;
+
+-- =========================================
+-- СВЯЗИ: Роли и Разрешения
+-- =========================================
+
+-- Разрешения для роли "requester" (маломобильный гражданин)
+INSERT INTO role_permissions (role_id, permission_id)
+SELECT
+ (SELECT id FROM roles WHERE name = 'requester'),
+ p.id
+FROM permissions p
+WHERE p.name IN (
+ 'request.create',
+ 'request.read',
+ 'request.update_own',
+ 'request.delete_own',
+ 'rating.create',
+ 'rating.read',
+ 'complaint.create',
+ 'complaint.read_own',
+ 'user.read_own',
+ 'user.update_own'
+)
+ON CONFLICT DO NOTHING;
+
+-- Разрешения для роли "volunteer" (волонтёр)
+INSERT INTO role_permissions (role_id, permission_id)
+SELECT
+ (SELECT id FROM roles WHERE name = 'volunteer'),
+ p.id
+FROM permissions p
+WHERE p.name IN (
+ 'request.read',
+ 'volunteer_response.create',
+ 'volunteer_response.read',
+ 'volunteer_response.cancel',
+ 'rating.read',
+ 'complaint.create',
+ 'complaint.read_own',
+ 'user.read_own',
+ 'user.update_own'
+)
+ON CONFLICT DO NOTHING;
+
+-- Разрешения для роли "moderator" (модератор)
+INSERT INTO role_permissions (role_id, permission_id)
+SELECT
+ (SELECT id FROM roles WHERE name = 'moderator'),
+ p.id
+FROM permissions p
+WHERE p.name IN (
+ 'request.read_all',
+ 'request.moderate',
+ 'request.delete_any',
+ 'volunteer_response.read',
+ 'rating.read_all',
+ 'complaint.moderate',
+ 'complaint.read_all',
+ 'user.read_all',
+ 'user.block',
+ 'user.unblock',
+ 'moderator.view_logs',
+ 'moderator.view_statistics'
+)
+ON CONFLICT DO NOTHING;
+
+-- Разрешения для роли "admin" (администратор) - все разрешения
+INSERT INTO role_permissions (role_id, permission_id)
+SELECT
+ (SELECT id FROM roles WHERE name = 'admin'),
+ p.id
+FROM permissions p
+ON CONFLICT DO NOTHING;
+
+-- =========================================
+-- НАЧАЛЬНЫЕ ДАННЫЕ: Типы заявок
+-- =========================================
+INSERT INTO request_types (name, description, icon, is_active) VALUES
+ ('groceries', 'Покупка продуктов питания', 'shopping-cart', TRUE),
+ ('medicine', 'Покупка медикаментов и средств гигиены', 'medical', TRUE),
+ ('tech_help', 'Помощь с техникой и электроникой', 'laptop', TRUE),
+ ('household', 'Помощь по хозяйству', 'home', TRUE),
+ ('documents', 'Помощь с документами', 'file-text', TRUE),
+ ('other', 'Другая помощь', 'help-circle', TRUE)
+ON CONFLICT (name) DO NOTHING;
+
+-- =========================================
+-- ВЫВОД ИНФОРМАЦИИ О СОЗДАННЫХ ДАННЫХ
+-- =========================================
+DO $$
+DECLARE
+ roles_count INTEGER;
+ permissions_count INTEGER;
+ role_permissions_count INTEGER;
+ request_types_count INTEGER;
+BEGIN
+ SELECT COUNT(*) INTO roles_count FROM roles;
+ SELECT COUNT(*) INTO permissions_count FROM permissions;
+ SELECT COUNT(*) INTO role_permissions_count FROM role_permissions;
+ SELECT COUNT(*) INTO request_types_count FROM request_types;
+
+ RAISE NOTICE '===========================================';
+ RAISE NOTICE 'Начальные данные успешно загружены:';
+ RAISE NOTICE '===========================================';
+ RAISE NOTICE 'Ролей: %', roles_count;
+ RAISE NOTICE 'Разрешений: %', permissions_count;
+ RAISE NOTICE 'Связей роли-разрешения: %', role_permissions_count;
+ RAISE NOTICE 'Типов заявок: %', request_types_count;
+ RAISE NOTICE '===========================================';
+END $$;
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+-- Удаляем начальные данные (в обратном порядке из-за внешних ключей)
+DELETE FROM role_permissions;
+DELETE FROM request_types;
+DELETE FROM permissions;
+DELETE FROM roles;
+
+-- +goose StatementEnd
diff --git a/migrations/00017_add_moderation_trigger.sql b/migrations/00017_add_moderation_trigger.sql
new file mode 100644
index 0000000..049b1e6
--- /dev/null
+++ b/migrations/00017_add_moderation_trigger.sql
@@ -0,0 +1,82 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ФУНКЦИЯ: Автоматический аудит действий модератора
+-- =========================================
+CREATE OR REPLACE FUNCTION audit_moderation_action()
+RETURNS TRIGGER AS $$
+BEGIN
+ -- Проверяем, изменились ли поля модерации
+ IF (OLD.moderated_by IS DISTINCT FROM NEW.moderated_by) OR
+ (OLD.moderated_at IS DISTINCT FROM NEW.moderated_at) THEN
+
+ -- Определяем тип действия на основе статуса
+ IF NEW.status = 'approved' AND OLD.status = 'pending_moderation' THEN
+ INSERT INTO moderator_actions (
+ moderator_id,
+ action_type,
+ target_request_id,
+ comment,
+ metadata
+ ) VALUES (
+ NEW.moderated_by,
+ 'approve_request',
+ NEW.id,
+ NEW.moderation_comment,
+ jsonb_build_object(
+ 'previous_status', OLD.status::text,
+ 'new_status', NEW.status::text,
+ 'request_title', NEW.title,
+ 'requester_id', NEW.requester_id
+ )
+ );
+ ELSIF NEW.status = 'rejected' AND OLD.status = 'pending_moderation' THEN
+ INSERT INTO moderator_actions (
+ moderator_id,
+ action_type,
+ target_request_id,
+ comment,
+ metadata
+ ) VALUES (
+ NEW.moderated_by,
+ 'reject_request',
+ NEW.id,
+ NEW.moderation_comment,
+ jsonb_build_object(
+ 'previous_status', OLD.status::text,
+ 'new_status', NEW.status::text,
+ 'request_title', NEW.title,
+ 'requester_id', NEW.requester_id,
+ 'rejection_reason', NEW.moderation_comment
+ )
+ );
+ END IF;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION audit_moderation_action() IS 'Автоматически создает записи в moderator_actions при модерации заявок';
+
+-- =========================================
+-- ТРИГГЕР: Аудит модерации заявок
+-- =========================================
+CREATE TRIGGER trigger_audit_request_moderation
+ AFTER UPDATE ON requests
+ FOR EACH ROW
+ WHEN (OLD.moderated_by IS DISTINCT FROM NEW.moderated_by OR
+ OLD.moderated_at IS DISTINCT FROM NEW.moderated_at)
+ EXECUTE FUNCTION audit_moderation_action();
+
+COMMENT ON TRIGGER trigger_audit_request_moderation ON requests IS
+ 'Автоматически логирует действия модератора в таблицу moderator_actions';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TRIGGER IF EXISTS trigger_audit_request_moderation ON requests;
+DROP FUNCTION IF EXISTS audit_moderation_action();
+-- +goose StatementEnd
diff --git a/migrations/00018_create_business_procedures.sql b/migrations/00018_create_business_procedures.sql
new file mode 100644
index 0000000..c6213a3
--- /dev/null
+++ b/migrations/00018_create_business_procedures.sql
@@ -0,0 +1,306 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- ФУНКЦИЯ: accept_volunteer_response
+-- Принятие отклика волонтера с назначением на заявку
+-- =========================================
+CREATE OR REPLACE FUNCTION accept_volunteer_response(
+ p_response_id BIGINT,
+ p_requester_id BIGINT
+)
+RETURNS TABLE (
+ success BOOLEAN,
+ message TEXT,
+ out_request_id BIGINT,
+ out_volunteer_id BIGINT
+) AS $$
+DECLARE
+ v_request_id BIGINT;
+ v_volunteer_id BIGINT;
+ v_request_status request_status;
+ v_response_status VARCHAR(20);
+ v_assigned_volunteer_id BIGINT;
+ v_request_requester_id BIGINT;
+BEGIN
+ -- Получаем информацию об отклике и связанной заявке
+ SELECT
+ vr.request_id,
+ vr.volunteer_id,
+ vr.status,
+ r.status,
+ r.assigned_volunteer_id,
+ r.requester_id
+ INTO
+ v_request_id,
+ v_volunteer_id,
+ v_response_status,
+ v_request_status,
+ v_assigned_volunteer_id,
+ v_request_requester_id
+ FROM volunteer_responses vr
+ JOIN requests r ON r.id = vr.request_id
+ WHERE vr.id = p_response_id
+ AND r.deleted_at IS NULL;
+
+ -- Проверка: отклик существует
+ IF v_request_id IS NULL THEN
+ RETURN QUERY SELECT FALSE, 'Volunteer response not found'::TEXT, NULL::BIGINT, NULL::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка принадлежит заявителю
+ IF v_request_requester_id != p_requester_id THEN
+ RETURN QUERY SELECT FALSE, 'You are not the owner of this request'::TEXT, v_request_id, v_volunteer_id;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка в статусе 'approved'
+ IF v_request_status != 'approved' THEN
+ RETURN QUERY SELECT FALSE, format('Request must be in approved status, current status: %s', v_request_status)::TEXT, v_request_id, v_volunteer_id;
+ RETURN;
+ END IF;
+
+ -- Проверка: отклик в статусе 'pending'
+ IF v_response_status != 'pending' THEN
+ RETURN QUERY SELECT FALSE, format('Response must be in pending status, current status: %s', v_response_status)::TEXT, v_request_id, v_volunteer_id;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка еще не взята другим волонтером
+ IF v_assigned_volunteer_id IS NOT NULL THEN
+ RETURN QUERY SELECT FALSE, 'Request already assigned to another volunteer'::TEXT, v_request_id, v_volunteer_id;
+ RETURN;
+ END IF;
+
+ -- Все проверки пройдены, выполняем операцию
+
+ -- 1. Принимаем отклик
+ UPDATE volunteer_responses SET
+ status = 'accepted',
+ accepted_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = p_response_id;
+
+ -- 2. Назначаем волонтера на заявку и меняем статус
+ UPDATE requests SET
+ assigned_volunteer_id = v_volunteer_id,
+ status = 'in_progress',
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = v_request_id;
+
+ -- 3. Отклоняем все остальные отклики на эту заявку
+ UPDATE volunteer_responses SET
+ status = 'rejected',
+ rejected_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE request_id = v_request_id
+ AND id != p_response_id
+ AND status = 'pending';
+
+ -- Триггер log_request_status_change автоматически создаст запись в request_status_history
+
+ RETURN QUERY SELECT TRUE, 'Volunteer response accepted successfully'::TEXT, v_request_id, v_volunteer_id;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION accept_volunteer_response IS 'Атомарное принятие отклика волонтера: обновление статусов заявки и отклика, отклонение остальных откликов. Предотвращает race conditions.';
+
+-- =========================================
+-- ФУНКЦИЯ: complete_request_with_rating
+-- Завершение заявки с обязательным выставлением рейтинга
+-- =========================================
+CREATE OR REPLACE FUNCTION complete_request_with_rating(
+ p_request_id BIGINT,
+ p_requester_id BIGINT,
+ p_rating INTEGER,
+ p_comment TEXT DEFAULT NULL
+)
+RETURNS TABLE (
+ success BOOLEAN,
+ message TEXT,
+ out_rating_id BIGINT
+) AS $$
+DECLARE
+ v_request_status request_status;
+ v_volunteer_id BIGINT;
+ v_requester_id BIGINT;
+ v_response_id BIGINT;
+ v_rating_id BIGINT;
+BEGIN
+ -- Получаем информацию о заявке
+ SELECT
+ r.status,
+ r.assigned_volunteer_id,
+ r.requester_id
+ INTO
+ v_request_status,
+ v_volunteer_id,
+ v_requester_id
+ FROM requests r
+ WHERE r.id = p_request_id
+ AND r.deleted_at IS NULL;
+
+ -- Проверка: заявка существует
+ IF v_request_status IS NULL THEN
+ RETURN QUERY SELECT FALSE, 'Request not found'::TEXT, 0::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка принадлежит заявителю
+ IF v_requester_id != p_requester_id THEN
+ RETURN QUERY SELECT FALSE, 'You are not the owner of this request'::TEXT, 0::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка в статусе 'in_progress'
+ IF v_request_status != 'in_progress' THEN
+ RETURN QUERY SELECT FALSE, format('Request must be in in_progress status, current status: %s', v_request_status)::TEXT, 0::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Проверка: есть назначенный волонтер
+ IF v_volunteer_id IS NULL THEN
+ RETURN QUERY SELECT FALSE, 'Request has no assigned volunteer'::TEXT, 0::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Проверка: рейтинг от 1 до 5
+ IF p_rating < 1 OR p_rating > 5 THEN
+ RETURN QUERY SELECT FALSE, 'Rating must be between 1 and 5'::TEXT, 0::BIGINT;
+ RETURN;
+ END IF;
+
+ -- Все проверки пройдены, выполняем операцию
+
+ -- 1. Завершаем заявку
+ UPDATE requests SET
+ status = 'completed',
+ completed_at = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = p_request_id;
+
+ -- 2. Получаем ID отклика (для связи с рейтингом)
+ SELECT id INTO v_response_id
+ FROM volunteer_responses
+ WHERE request_id = p_request_id
+ AND volunteer_id = v_volunteer_id
+ AND status = 'accepted'
+ LIMIT 1;
+
+ -- 3. Создаем рейтинг
+ INSERT INTO ratings (
+ volunteer_response_id,
+ volunteer_id,
+ requester_id,
+ request_id,
+ rating,
+ comment
+ ) VALUES (
+ v_response_id,
+ v_volunteer_id,
+ p_requester_id,
+ p_request_id,
+ p_rating,
+ p_comment
+ ) RETURNING id INTO v_rating_id;
+
+ -- Триггер update_volunteer_rating автоматически пересчитает рейтинг волонтера
+ -- Триггер log_request_status_change автоматически создаст запись в request_status_history
+
+ RETURN QUERY SELECT TRUE, 'Request completed and rating saved successfully'::TEXT, v_rating_id;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION complete_request_with_rating IS 'Атомарное завершение заявки с обязательным выставлением рейтинга волонтеру. Триггер автоматически пересчитает средний рейтинг волонтера.';
+
+-- =========================================
+-- ФУНКЦИЯ: moderate_request
+-- Универсальная функция модерации заявок
+-- =========================================
+CREATE OR REPLACE FUNCTION moderate_request(
+ p_request_id BIGINT,
+ p_moderator_id BIGINT,
+ p_action TEXT, -- 'approve' или 'reject'
+ p_comment TEXT DEFAULT NULL
+)
+RETURNS TABLE (
+ success BOOLEAN,
+ message TEXT
+) AS $$
+DECLARE
+ v_request_status request_status;
+ v_has_permission BOOLEAN;
+ v_new_status request_status;
+BEGIN
+ -- Проверка: модератор имеет право модерировать
+ SELECT has_permission(p_moderator_id, 'moderate_requests') INTO v_has_permission;
+
+ IF NOT v_has_permission THEN
+ RETURN QUERY SELECT FALSE, 'You do not have permission to moderate requests'::TEXT;
+ RETURN;
+ END IF;
+
+ -- Получаем текущий статус заявки
+ SELECT status INTO v_request_status
+ FROM requests
+ WHERE id = p_request_id
+ AND deleted_at IS NULL;
+
+ -- Проверка: заявка существует
+ IF v_request_status IS NULL THEN
+ RETURN QUERY SELECT FALSE, 'Request not found'::TEXT;
+ RETURN;
+ END IF;
+
+ -- Проверка: заявка в статусе 'pending_moderation'
+ IF v_request_status != 'pending_moderation' THEN
+ RETURN QUERY SELECT FALSE, format('Request must be in pending_moderation status, current status: %s', v_request_status)::TEXT;
+ RETURN;
+ END IF;
+
+ -- Определяем новый статус
+ IF p_action = 'approve' THEN
+ v_new_status := 'approved';
+ ELSIF p_action = 'reject' THEN
+ v_new_status := 'rejected';
+
+ -- При отклонении комментарий обязателен
+ IF p_comment IS NULL OR trim(p_comment) = '' THEN
+ RETURN QUERY SELECT FALSE, 'Comment is required when rejecting a request'::TEXT;
+ RETURN;
+ END IF;
+ ELSE
+ RETURN QUERY SELECT FALSE, format('Invalid action: %s. Must be approve or reject', p_action)::TEXT;
+ RETURN;
+ END IF;
+
+ -- Все проверки пройдены, выполняем модерацию
+
+ UPDATE requests SET
+ status = v_new_status,
+ moderated_by = p_moderator_id,
+ moderated_at = CURRENT_TIMESTAMP,
+ moderation_comment = p_comment,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = p_request_id;
+
+ -- Триггер audit_moderation_action автоматически создаст запись в moderator_actions
+ -- Триггер log_request_status_change автоматически создаст запись в request_status_history
+
+ RETURN QUERY SELECT TRUE, format('Request %s successfully', p_action)::TEXT;
+END;
+$$ LANGUAGE plpgsql;
+
+COMMENT ON FUNCTION moderate_request IS 'Универсальная функция модерации заявок с проверкой прав, валидацией статуса и автоматическим аудитом через триггеры.';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+DROP FUNCTION IF EXISTS moderate_request;
+DROP FUNCTION IF EXISTS complete_request_with_rating;
+DROP FUNCTION IF EXISTS accept_volunteer_response;
+
+-- +goose StatementEnd
diff --git a/migrations/00019_split_full_name.sql b/migrations/00019_split_full_name.sql
new file mode 100644
index 0000000..9ca2945
--- /dev/null
+++ b/migrations/00019_split_full_name.sql
@@ -0,0 +1,58 @@
+-- +goose Up
+-- +goose StatementBegin
+
+-- =========================================
+-- Разделение full_name на first_name и last_name
+-- =========================================
+
+-- Добавляем новые колонки
+ALTER TABLE users
+ ADD COLUMN first_name VARCHAR(100),
+ ADD COLUMN last_name VARCHAR(100);
+
+-- Копируем данные из full_name в новые поля
+-- Разделяем по первому пробелу
+UPDATE users
+SET
+ first_name = CASE
+ WHEN position(' ' in full_name) > 0
+ THEN split_part(full_name, ' ', 1)
+ ELSE full_name
+ END,
+ last_name = CASE
+ WHEN position(' ' in full_name) > 0
+ THEN substring(full_name from position(' ' in full_name) + 1)
+ ELSE ''
+ END
+WHERE full_name IS NOT NULL;
+
+-- Делаем новые поля обязательными
+ALTER TABLE users
+ ALTER COLUMN first_name SET NOT NULL,
+ ALTER COLUMN last_name SET NOT NULL;
+
+-- Удаляем старую колонку
+ALTER TABLE users DROP COLUMN full_name;
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+-- Восстанавливаем full_name
+ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
+
+-- Объединяем имя и фамилию обратно
+UPDATE users
+SET full_name = first_name || ' ' || last_name
+WHERE first_name IS NOT NULL AND last_name IS NOT NULL;
+
+-- Делаем full_name обязательным
+ALTER TABLE users ALTER COLUMN full_name SET NOT NULL;
+
+-- Удаляем новые колонки
+ALTER TABLE users
+ DROP COLUMN first_name,
+ DROP COLUMN last_name;
+
+-- +goose StatementEnd
diff --git a/ogen.yaml b/ogen.yaml
new file mode 100644
index 0000000..35d559d
--- /dev/null
+++ b/ogen.yaml
@@ -0,0 +1,7 @@
+generator:
+ features:
+ enable:
+ - "paths/server"
+ - "client/request/validation"
+ - "server/response/validation"
+ disable_all: true
\ No newline at end of file
diff --git a/sqlc.yaml b/sqlc.yaml
new file mode 100644
index 0000000..b4c73ea
--- /dev/null
+++ b/sqlc.yaml
@@ -0,0 +1,57 @@
+version: "2"
+sql:
+ - engine: "postgresql"
+ queries:
+ - "internal/database/queries/auth.sql"
+ - "internal/database/queries/users.sql"
+ - "internal/database/queries/rbac.sql"
+ - "internal/database/queries/requests.sql"
+ - "internal/database/queries/geospatial.sql"
+ - "internal/database/queries/responses.sql"
+ schema: "migrations"
+ gen:
+ go:
+ package: "database"
+ out: "internal/database"
+ sql_package: "pgx/v5"
+ emit_json_tags: true
+ emit_prepared_queries: false
+ emit_interface: true
+ emit_empty_slices: true
+ emit_all_enum_values: true
+ emit_exported_queries: true
+ json_tags_case_style: "snake"
+ overrides:
+ # Обработка JSONB
+ - db_type: "jsonb"
+ go_type:
+ import: "encoding/json"
+ type: "json.RawMessage"
+
+ # Сетевые типы
+ - db_type: "inet"
+ go_type: "string"
+
+ # Хранимые процедуры
+ - column: "r.success"
+ go_type: "bool"
+ - column: "r.message"
+ go_type: "string"
+ - column: "r.request_id"
+ go_type: "int64"
+ - column: "r.volunteer_id"
+ go_type: "int64"
+ - column: "r.rating_id"
+ go_type: "int64"
+
+ rename:
+ id: "ID"
+ user_id: "UserID"
+ request_id: "RequestID"
+ role_id: "RoleID"
+ permission_id: "PermissionID"
+ r_success: "Success"
+ r_message: "Message"
+ r_out_request_id: "RequestID"
+ r_out_volunteer_id: "VolunteerID"
+ r_out_rating_id: "RatingID"
diff --git a/tests/e2e/api_test.go b/tests/e2e/api_test.go
new file mode 100644
index 0000000..5e72856
--- /dev/null
+++ b/tests/e2e/api_test.go
@@ -0,0 +1,1361 @@
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "git.kirlllll.ru/volontery/backend/internal/api"
+ "git.kirlllll.ru/volontery/backend/internal/config"
+ "git.kirlllll.ru/volontery/backend/internal/database"
+ "git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
+ "git.kirlllll.ru/volontery/backend/internal/repository"
+ "git.kirlllll.ru/volontery/backend/internal/service"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/stretchr/testify/suite"
+)
+
+// APITestSuite - набор E2E тестов для API
+type APITestSuite struct {
+ suite.Suite
+ server *api.Server
+ db *pgxpool.Pool
+ queries *database.Queries
+ cfg *config.Config
+ accessToken string
+ userID int64
+}
+
+// SetupSuite запускается один раз перед всеми тестами
+func (suite *APITestSuite) SetupSuite() {
+ // Загрузка конфигурации
+ cfg, err := config.Load()
+ require.NoError(suite.T(), err)
+ suite.cfg = cfg
+
+ // Подключение к БД
+ ctx := context.Background()
+ db, err := pgxpool.New(ctx, cfg.DatabaseURL)
+ require.NoError(suite.T(), err)
+ suite.db = db
+
+ suite.queries = database.New(db)
+
+ // Инициализация зависимостей
+ jwtManager := jwt.NewManager(cfg.JWTSecret, cfg.JWTAccessTokenTTL, cfg.JWTRefreshTokenTTL)
+ require.NoError(suite.T(), err)
+
+ authRepo := repository.NewAuthRepository(suite.queries)
+ userRepo := repository.NewUserRepository(suite.queries)
+ rbacRepo := repository.NewRBACRepository(suite.queries)
+ authService := service.NewAuthService(userRepo, authRepo, rbacRepo, jwtManager)
+ userService := service.NewUserService(userRepo, rbacRepo)
+ requestRepo := repository.NewRequestRepository(suite.queries)
+ requestService := service.NewRequestService(requestRepo)
+
+ // Создание сервера
+ suite.server = api.NewServer(cfg, authService, userService, requestService, jwtManager)
+}
+
+// TearDownSuite запускается один раз после всех тестов
+func (suite *APITestSuite) TearDownSuite() {
+ if suite.db != nil {
+ suite.db.Close()
+ }
+}
+
+// TestAPITestSuite - точка входа для запуска набора тестов
+func TestAPITestSuite(t *testing.T) {
+ suite.Run(t, new(APITestSuite))
+}
+
+// Helper функции
+
+// createModerator создает пользователя с ролью moderator
+func (suite *APITestSuite) createModerator() (userID int64, token string) {
+ // Создаем пользователя
+ email := fmt.Sprintf("moderator_%d@example.com", time.Now().UnixNano())
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Moderator",
+ "last_name": "User",
+ }
+
+ _, regBody := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+ var authResp map[string]interface{}
+ err := json.Unmarshal(regBody, &authResp)
+ require.NoError(suite.T(), err)
+
+ token = authResp["access_token"].(string)
+ user := authResp["user"].(map[string]interface{})
+ userID = int64(user["id"].(float64))
+
+ // Получаем роль moderator
+ role, err := suite.queries.GetRoleByName(context.Background(), "moderator")
+ require.NoError(suite.T(), err)
+
+ // Назначаем роль модератора
+ _, err = suite.queries.AssignRoleToUser(context.Background(), database.AssignRoleToUserParams{
+ UserID: userID,
+ RoleID: role.ID,
+ AssignedBy: pgtype.Int8{
+ Int64: userID,
+ Valid: true,
+ }, // self-assigned for testing
+ })
+ require.NoError(suite.T(), err)
+
+ return userID, token
+}
+
+// createApprovedRequest создает заявку и одобряет её
+func (suite *APITestSuite) createApprovedRequest(requesterToken string) int64 {
+ // Получаем типы заявок
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ require.NotEmpty(suite.T(), types)
+
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ // Создаем заявку
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": fmt.Sprintf("Test Request %d", time.Now().UnixNano()),
+ "description": "Test description",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test Address",
+ "city": "Москва",
+ "urgency": "medium",
+ }
+
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, requesterToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Создаем модератора и одобряем заявку
+ moderatorID, moderatorToken := suite.createModerator()
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/approve", requestID)
+ approveReq := map[string]interface{}{
+ "comment": "Approved for testing",
+ }
+ resp, _ := suite.makeRequest("POST", url, approveReq, moderatorToken)
+ require.Equal(suite.T(), http.StatusOK, resp.StatusCode, "Failed to approve request, moderator_id=%d", moderatorID)
+
+ return requestID
+}
+
+// createVolunteerWithResponse создает волонтера и отклик
+func (suite *APITestSuite) createVolunteerWithResponse(requestID int64) (volunteerID int64, responseID int64, token string) {
+ // Создаем волонтера
+ email := fmt.Sprintf("volunteer_%d@example.com", time.Now().UnixNano())
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Volunteer",
+ "last_name": "User",
+ }
+
+ _, regBody := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+ var authResp map[string]interface{}
+ json.Unmarshal(regBody, &authResp)
+ token = authResp["access_token"].(string)
+ user := authResp["user"].(map[string]interface{})
+ volunteerID = int64(user["id"].(float64))
+
+ // Создаем отклик
+ responseReq := map[string]interface{}{
+ "message": "I can help with this request",
+ }
+ url := fmt.Sprintf("/api/v1/requests/%d/responses", requestID)
+ _, respBody := suite.makeRequest("POST", url, responseReq, token)
+ var response map[string]interface{}
+ json.Unmarshal(respBody, &response)
+ responseID = int64(response["id"].(float64))
+
+ return volunteerID, responseID, token
+}
+
+// checkModeratorAction проверяет наличие записи в moderator_actions
+func (suite *APITestSuite) checkModeratorAction(moderatorID, requestID int64, actionType string) {
+ actions, err := suite.queries.GetModeratorActionsByRequest(context.Background(), pgtype.Int8{
+ Int64: requestID,
+ Valid: true,
+ })
+ require.NoError(suite.T(), err)
+
+ found := false
+ for _, action := range actions {
+ if action.ModeratorID == moderatorID && string(action.ActionType) == actionType {
+ found = true
+ break
+ }
+ }
+ assert.True(suite.T(), found, "Moderator action not found: moderator_id=%d, request_id=%d, action_type=%s", moderatorID, requestID, actionType)
+}
+
+// checkRequestStatusHistory проверяет историю статусов
+func (suite *APITestSuite) checkRequestStatusHistory(requestID int64, expectedStatuses []string) {
+ history, err := suite.queries.GetRequestStatusHistory(context.Background(), requestID)
+ require.NoError(suite.T(), err)
+
+ // Получаем список статусов из истории (в обратном порядке - от новых к старым)
+ var actualStatuses []string
+ for i := len(history) - 1; i >= 0; i-- {
+ actualStatuses = append(actualStatuses, string(history[i].ToStatus))
+ }
+
+ // Проверяем, что все ожидаемые статусы присутствуют
+ for _, expectedStatus := range expectedStatuses {
+ found := false
+ for _, actualStatus := range actualStatuses {
+ if actualStatus == expectedStatus {
+ found = true
+ break
+ }
+ }
+ assert.True(suite.T(), found, "Expected status %s not found in history for request %d. Actual: %v", expectedStatus, requestID, actualStatuses)
+ }
+}
+
+func (suite *APITestSuite) makeRequest(method, path string, body interface{}, token string) (*http.Response, []byte) {
+ var bodyReader io.Reader
+ if body != nil {
+ jsonBody, err := json.Marshal(body)
+ require.NoError(suite.T(), err)
+ bodyReader = bytes.NewReader(jsonBody)
+ }
+
+ req := httptest.NewRequest(method, path, bodyReader)
+ if bodyReader != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ w := httptest.NewRecorder()
+ suite.server.ServeHTTP(w, req)
+
+ resp := w.Result()
+ respBody, err := io.ReadAll(resp.Body)
+ require.NoError(suite.T(), err)
+ defer resp.Body.Close()
+
+ return resp, respBody
+}
+
+// Test 1: Health Check
+func (suite *APITestSuite) Test01_HealthCheck() {
+ suite.T().Log("Testing: GET /health")
+
+ resp, body := suite.makeRequest("GET", "/health", nil, "")
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+ assert.Equal(suite.T(), "OK", string(body))
+}
+
+// Test 2: User Registration
+func (suite *APITestSuite) Test02_Register() {
+ suite.T().Log("Testing: POST /api/v1/auth/register")
+
+ registerReq := map[string]interface{}{
+ "email": fmt.Sprintf("test_%d@example.com", time.Now().Unix()),
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Test",
+ "last_name": "User",
+ "phone": "+79001234567",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "city": "Москва",
+ "bio": "Test bio",
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+
+ assert.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
+ assert.Equal(suite.T(), "application/json", resp.Header.Get("Content-Type"))
+
+ var authResp map[string]interface{}
+ err := json.Unmarshal(body, &authResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), authResp, "access_token")
+ assert.Contains(suite.T(), authResp, "refresh_token")
+ assert.Contains(suite.T(), authResp, "expires_in")
+ assert.Contains(suite.T(), authResp, "user")
+
+ // Сохраняем токен для последующих тестов
+ suite.accessToken = authResp["access_token"].(string)
+
+ user := authResp["user"].(map[string]interface{})
+ suite.userID = int64(user["id"].(float64))
+}
+
+// Test 3: Login
+func (suite *APITestSuite) Test03_Login() {
+ suite.T().Log("Testing: POST /api/v1/auth/login")
+
+ // Сначала регистрируем пользователя
+ email := fmt.Sprintf("login_test_%d@example.com", time.Now().Unix())
+ password := "SecureP@ssw0rd123"
+
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": password,
+ "first_name": "Login",
+ "last_name": "Test User",
+ }
+
+ suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+
+ // Теперь логинимся
+ loginReq := map[string]interface{}{
+ "email": email,
+ "password": password,
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/login", loginReq, "")
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var authResp map[string]interface{}
+ err := json.Unmarshal(body, &authResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), authResp, "access_token")
+ assert.Contains(suite.T(), authResp, "refresh_token")
+}
+
+// Test 4: Login with wrong credentials
+func (suite *APITestSuite) Test04_Login_WrongCredentials() {
+ suite.T().Log("Testing: POST /api/v1/auth/login (invalid credentials)")
+
+ loginReq := map[string]interface{}{
+ "email": "nonexistent@example.com",
+ "password": "wrongpassword",
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/login", loginReq, "")
+
+ assert.Equal(suite.T(), http.StatusUnauthorized, resp.StatusCode)
+
+ var errorResp map[string]interface{}
+ err := json.Unmarshal(body, &errorResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), errorResp, "error")
+}
+
+// Test 5: Get current user info
+func (suite *APITestSuite) Test05_AuthMe() {
+ suite.T().Log("Testing: GET /api/v1/auth/me")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/auth/me", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var userInfo map[string]interface{}
+ err := json.Unmarshal(body, &userInfo)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), userInfo, "id")
+ assert.Contains(suite.T(), userInfo, "email")
+ assert.Equal(suite.T(), float64(suite.userID), userInfo["id"].(float64))
+}
+
+// Test 6: Get current user profile
+func (suite *APITestSuite) Test06_GetMyProfile() {
+ suite.T().Log("Testing: GET /api/v1/users/me")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/users/me", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var profile map[string]interface{}
+ err := json.Unmarshal(body, &profile)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), profile, "id")
+ assert.Contains(suite.T(), profile, "email")
+ assert.Contains(suite.T(), profile, "first_name")
+ assert.Contains(suite.T(), profile, "last_name")
+ assert.Contains(suite.T(), profile, "created_at")
+}
+
+// Test 7: Update profile
+func (suite *APITestSuite) Test07_UpdateProfile() {
+ suite.T().Log("Testing: PATCH /api/v1/users/me")
+
+ updateReq := map[string]interface{}{
+ "first_name": "Updated",
+ "last_name": "Name",
+ "city": "Санкт-Петербург",
+ }
+
+ resp, body := suite.makeRequest("PATCH", "/api/v1/users/me", updateReq, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err := json.Unmarshal(body, &result)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), result, "message")
+}
+
+// Test 8: Update location
+func (suite *APITestSuite) Test08_UpdateLocation() {
+ suite.T().Log("Testing: POST /api/v1/users/me/location")
+
+ locationReq := map[string]interface{}{
+ "latitude": 59.9343,
+ "longitude": 30.3351,
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/users/me/location", locationReq, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err := json.Unmarshal(body, &result)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), result, "message")
+}
+
+// Test 9: Get user roles
+func (suite *APITestSuite) Test09_GetMyRoles() {
+ suite.T().Log("Testing: GET /api/v1/users/me/roles")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/users/me/roles", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var roles []map[string]interface{}
+ err := json.Unmarshal(body, &roles)
+ require.NoError(suite.T(), err)
+
+ // Пользователь должен иметь хотя бы одну роль
+ assert.NotEmpty(suite.T(), roles)
+}
+
+// Test 10: Get user permissions
+func (suite *APITestSuite) Test10_GetMyPermissions() {
+ suite.T().Log("Testing: GET /api/v1/users/me/permissions")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/users/me/permissions", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var permissions []map[string]interface{}
+ err := json.Unmarshal(body, &permissions)
+ require.NoError(suite.T(), err)
+
+ // Пользователь должен иметь разрешения
+ assert.NotEmpty(suite.T(), permissions)
+}
+
+// Test 11: Get request types
+func (suite *APITestSuite) Test11_GetRequestTypes() {
+ suite.T().Log("Testing: GET /api/v1/request-types")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var types []map[string]interface{}
+ err := json.Unmarshal(body, &types)
+ require.NoError(suite.T(), err)
+
+ // Должны быть типы заявок в БД
+ assert.NotEmpty(suite.T(), types)
+
+ // Проверяем структуру
+ if len(types) > 0 {
+ assert.Contains(suite.T(), types[0], "id")
+ assert.Contains(suite.T(), types[0], "name")
+ }
+}
+
+// Test 12: Create request
+func (suite *APITestSuite) Test12_CreateRequest() {
+ suite.T().Log("Testing: POST /api/v1/requests")
+
+ // Получаем типы заявок
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ require.NotEmpty(suite.T(), types)
+
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Нужна помощь с покупкой продуктов",
+ "description": "Прошу помочь купить продукты в ближайшем магазине. Список прилагается.",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "ул. Тверская, д. 1, кв. 10",
+ "city": "Москва",
+ "urgency": "high",
+ "contact_phone": "+79001234567",
+ "contact_notes": "Код домофона: 123",
+ "desired_completion_date": time.Now().Add(24 * time.Hour).Format(time.RFC3339),
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
+
+ var request map[string]interface{}
+ err := json.Unmarshal(body, &request)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), request, "id")
+ assert.Contains(suite.T(), request, "title")
+ assert.Contains(suite.T(), request, "status")
+}
+
+// Test 13: Get my requests
+func (suite *APITestSuite) Test13_GetMyRequests() {
+ suite.T().Log("Testing: GET /api/v1/requests/my")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/requests/my?limit=10&offset=0", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var requests []map[string]interface{}
+ err := json.Unmarshal(body, &requests)
+ require.NoError(suite.T(), err)
+
+ // Должна быть хотя бы одна заявка (созданная в предыдущем тесте)
+ assert.NotEmpty(suite.T(), requests)
+}
+
+// Test 14: Find nearby requests
+func (suite *APITestSuite) Test14_FindNearbyRequests() {
+ suite.T().Log("Testing: GET /api/v1/requests/nearby")
+
+ url := "/api/v1/requests/nearby?lat=55.751244&lon=37.618423&radius=5000&limit=10&offset=0"
+ resp, body := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ if resp.StatusCode != http.StatusOK {
+ suite.T().Logf("Error response body: %s", string(body))
+ }
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var requests []map[string]interface{}
+ err := json.Unmarshal(body, &requests)
+ require.NoError(suite.T(), err)
+
+ // Проверяем наличие distance_meters в ответе
+ if len(requests) > 0 {
+ assert.Contains(suite.T(), requests[0], "distance_meters")
+ }
+}
+
+// Test 15: Find requests in bounds
+func (suite *APITestSuite) Test15_FindRequestsInBounds() {
+ suite.T().Log("Testing: GET /api/v1/requests/bounds")
+
+ url := "/api/v1/requests/bounds?min_lon=37.6&min_lat=55.7&max_lon=37.7&max_lat=55.8"
+ resp, body := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var requests []map[string]interface{}
+ err := json.Unmarshal(body, &requests)
+ require.NoError(suite.T(), err)
+
+ // Ответ должен быть массивом
+ assert.NotNil(suite.T(), requests)
+}
+
+// Test 16: Get request by ID
+func (suite *APITestSuite) Test16_GetRequestByID() {
+ suite.T().Log("Testing: GET /api/v1/requests/{id}")
+
+ // Получаем список своих заявок
+ _, body := suite.makeRequest("GET", "/api/v1/requests/my", nil, suite.accessToken)
+ var requests []map[string]interface{}
+ json.Unmarshal(body, &requests)
+ require.NotEmpty(suite.T(), requests)
+
+ requestID := int64(requests[0]["id"].(float64))
+
+ // Получаем заявку по ID
+ url := fmt.Sprintf("/api/v1/requests/%d", requestID)
+ resp, body := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var request map[string]interface{}
+ err := json.Unmarshal(body, &request)
+ require.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), float64(requestID), request["id"].(float64))
+ assert.Contains(suite.T(), request, "title")
+ assert.Contains(suite.T(), request, "description")
+}
+
+// Test 17: Create volunteer response
+func (suite *APITestSuite) Test17_CreateVolunteerResponse() {
+ suite.T().Log("Testing: POST /api/v1/requests/{id}/responses")
+
+ // Создаем нового волонтера
+ email := fmt.Sprintf("volunteer_%d@example.com", time.Now().Unix())
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Volunteer",
+ "last_name": "User",
+ }
+
+ _, regBody := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+ var authResp map[string]interface{}
+ json.Unmarshal(regBody, &authResp)
+ volunteerToken := authResp["access_token"].(string)
+
+ // Получаем список заявок
+ _, body := suite.makeRequest("GET", "/api/v1/requests/my", nil, suite.accessToken)
+ var requests []map[string]interface{}
+ json.Unmarshal(body, &requests)
+ require.NotEmpty(suite.T(), requests)
+
+ requestID := int64(requests[0]["id"].(float64))
+
+ // Создаем отклик
+ responseReq := map[string]interface{}{
+ "message": "Готов помочь завтра после 15:00",
+ }
+
+ url := fmt.Sprintf("/api/v1/requests/%d/responses", requestID)
+ resp, body := suite.makeRequest("POST", url, responseReq, volunteerToken)
+
+ assert.Equal(suite.T(), http.StatusCreated, resp.StatusCode)
+
+ var response map[string]interface{}
+ err := json.Unmarshal(body, &response)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), response, "id")
+ assert.Contains(suite.T(), response, "volunteer_id")
+ assert.Contains(suite.T(), response, "status")
+}
+
+// Test 18: Get request responses
+func (suite *APITestSuite) Test18_GetRequestResponses() {
+ suite.T().Log("Testing: GET /api/v1/requests/{id}/responses")
+
+ // Получаем список своих заявок
+ _, body := suite.makeRequest("GET", "/api/v1/requests/my", nil, suite.accessToken)
+ var requests []map[string]interface{}
+ json.Unmarshal(body, &requests)
+ require.NotEmpty(suite.T(), requests)
+
+ requestID := int64(requests[0]["id"].(float64))
+
+ // Получаем отклики
+ url := fmt.Sprintf("/api/v1/requests/%d/responses", requestID)
+ resp, body := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var responses []map[string]interface{}
+ err := json.Unmarshal(body, &responses)
+ require.NoError(suite.T(), err)
+
+ // Должен быть хотя бы один отклик (созданный в предыдущем тесте)
+ assert.NotEmpty(suite.T(), responses)
+
+ if len(responses) > 0 {
+ assert.Contains(suite.T(), responses[0], "volunteer_id")
+ assert.Contains(suite.T(), responses[0], "status")
+ }
+}
+
+// Test 19: Refresh token
+func (suite *APITestSuite) Test19_RefreshToken() {
+ suite.T().Log("Testing: POST /api/v1/auth/refresh")
+
+ // Регистрируем нового пользователя
+ email := fmt.Sprintf("refresh_test_%d@example.com", time.Now().Unix())
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Refresh",
+ "last_name": "Test User",
+ }
+
+ _, regBody := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+ var authResp map[string]interface{}
+ json.Unmarshal(regBody, &authResp)
+ refreshToken := authResp["refresh_token"].(string)
+
+ // Обновляем токен
+ refreshReq := map[string]interface{}{
+ "refresh_token": refreshToken,
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/refresh", refreshReq, "")
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var newAuthResp map[string]interface{}
+ err := json.Unmarshal(body, &newAuthResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), newAuthResp, "access_token")
+ assert.Contains(suite.T(), newAuthResp, "refresh_token")
+ assert.NotEqual(suite.T(), refreshToken, newAuthResp["refresh_token"].(string))
+}
+
+// Test 20: Logout
+func (suite *APITestSuite) Test20_Logout() {
+ suite.T().Log("Testing: POST /api/v1/auth/logout")
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/logout", nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err := json.Unmarshal(body, &result)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), result, "message")
+}
+
+// Test 21: Unauthorized access
+func (suite *APITestSuite) Test21_UnauthorizedAccess() {
+ suite.T().Log("Testing: Unauthorized access to protected endpoint")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/users/me", nil, "")
+
+ assert.Equal(suite.T(), http.StatusUnauthorized, resp.StatusCode)
+
+ var errorResp map[string]interface{}
+ err := json.Unmarshal(body, &errorResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), errorResp, "error")
+}
+
+// Test 22: Invalid token
+func (suite *APITestSuite) Test22_InvalidToken() {
+ suite.T().Log("Testing: Invalid token")
+
+ resp, body := suite.makeRequest("GET", "/api/v1/users/me", nil, "invalid.token.here")
+
+ assert.Equal(suite.T(), http.StatusUnauthorized, resp.StatusCode)
+
+ var errorResp map[string]interface{}
+ err := json.Unmarshal(body, &errorResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), errorResp, "error")
+}
+
+// Test 23: Get user by ID
+func (suite *APITestSuite) Test23_GetUserByID() {
+ suite.T().Log("Testing: GET /api/v1/users/{id}")
+
+ url := fmt.Sprintf("/api/v1/users/%d", suite.userID)
+ resp, body := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var profile map[string]interface{}
+ err := json.Unmarshal(body, &profile)
+ require.NoError(suite.T(), err)
+
+ assert.Equal(suite.T(), float64(suite.userID), profile["id"].(float64))
+ assert.Contains(suite.T(), profile, "email")
+ assert.Contains(suite.T(), profile, "first_name")
+ assert.Contains(suite.T(), profile, "last_name")
+}
+
+// Test 24: Invalid request body
+func (suite *APITestSuite) Test24_InvalidRequestBody() {
+ suite.T().Log("Testing: Invalid request body")
+
+ // Пытаемся зарегистрироваться с невалидными данными
+ registerReq := map[string]interface{}{
+ "email": "invalid-email", // невалидный email
+ }
+
+ resp, body := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+
+ assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode)
+
+ var errorResp map[string]interface{}
+ err := json.Unmarshal(body, &errorResp)
+ require.NoError(suite.T(), err)
+
+ assert.Contains(suite.T(), errorResp, "error")
+}
+
+// Test 25: Pagination
+func (suite *APITestSuite) Test25_Pagination() {
+ suite.T().Log("Testing: Pagination parameters")
+
+ // Тест с разными параметрами пагинации
+ testCases := []struct {
+ limit int
+ offset int
+ }{
+ {10, 0},
+ {5, 5},
+ {20, 0},
+ }
+
+ for _, tc := range testCases {
+ url := fmt.Sprintf("/api/v1/requests/my?limit=%d&offset=%d", tc.limit, tc.offset)
+ resp, _ := suite.makeRequest("GET", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+ }
+}
+
+// ============================================================================
+// ТЕСТЫ МОДЕРАЦИИ (Test26-Test32)
+// ============================================================================
+
+// Test 26: Full moderation workflow
+func (suite *APITestSuite) Test26_ModerationWorkflow() {
+ suite.T().Log("Testing: Full moderation workflow")
+
+ // 1. Создаем заявку (автоматически pending_moderation)
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ require.NotEmpty(suite.T(), types)
+
+ requestTypeID := int64(types[0]["id"].(float64))
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Moderation Test Request",
+ "description": "This request needs moderation",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test Address",
+ }
+
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // status - это объект NullRequestStatus {request_status: "...", valid: true}
+ statusObj := request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "pending_moderation", statusObj["request_status"])
+
+ // 2. Создаем модератора
+ moderatorID, moderatorToken := suite.createModerator()
+
+ // 3. Получаем список заявок на модерации
+ resp, body := suite.makeRequest("GET", "/api/v1/moderation/requests/pending?limit=10&offset=0", nil, moderatorToken)
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var pendingRequests []map[string]interface{}
+ json.Unmarshal(body, &pendingRequests)
+ assert.NotEmpty(suite.T(), pendingRequests)
+
+ // 4. Одобряем заявку
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/approve", requestID)
+ approveReq := map[string]interface{}{
+ "comment": "Request looks good",
+ }
+ resp, _ = suite.makeRequest("POST", url, approveReq, moderatorToken)
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ // 5. Проверяем статус заявки
+ url = fmt.Sprintf("/api/v1/requests/%d", requestID)
+ resp, body = suite.makeRequest("GET", url, nil, suite.accessToken)
+ json.Unmarshal(body, &request)
+ statusObj = request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "approved", statusObj["request_status"])
+
+ // 6. Проверяем запись в moderator_actions
+ suite.checkModeratorAction(moderatorID, requestID, "approve_request")
+
+ // 7. Проверяем историю статусов
+ suite.checkRequestStatusHistory(requestID, []string{"pending_moderation", "approved"})
+}
+
+// Test 27: Approve request
+func (suite *APITestSuite) Test27_ModerationApprove() {
+ suite.T().Log("Testing: POST /api/v1/moderation/requests/{id}/approve")
+
+ // Создаем заявку
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Approve Test",
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Модератор одобряет
+ moderatorID, moderatorToken := suite.createModerator()
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/approve", requestID)
+ approveReq := map[string]interface{}{}
+ resp, body := suite.makeRequest("POST", url, approveReq, moderatorToken)
+
+ if resp.StatusCode != http.StatusOK {
+ suite.T().Logf("Approve failed with status %d, body: %s", resp.StatusCode, string(body))
+ }
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ // Проверяем статус
+ url = fmt.Sprintf("/api/v1/requests/%d", requestID)
+ _, body = suite.makeRequest("GET", url, nil, suite.accessToken)
+ json.Unmarshal(body, &request)
+ statusObj := request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "approved", statusObj["request_status"])
+
+ suite.checkModeratorAction(moderatorID, requestID, "approve_request")
+}
+
+// Test 28: Reject request
+func (suite *APITestSuite) Test28_ModerationReject() {
+ suite.T().Log("Testing: POST /api/v1/moderation/requests/{id}/reject")
+
+ // Создаем заявку
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Reject Test",
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Модератор отклоняет
+ moderatorID, moderatorToken := suite.createModerator()
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/reject", requestID)
+ rejectReq := map[string]interface{}{
+ "comment": "Does not meet requirements",
+ }
+ resp, _ := suite.makeRequest("POST", url, rejectReq, moderatorToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ // Проверяем статус
+ url = fmt.Sprintf("/api/v1/requests/%d", requestID)
+ _, body = suite.makeRequest("GET", url, nil, suite.accessToken)
+ json.Unmarshal(body, &request)
+ statusObj := request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "rejected", statusObj["request_status"])
+
+ suite.checkModeratorAction(moderatorID, requestID, "reject_request")
+}
+
+// Test 29: Reject without comment (should fail)
+func (suite *APITestSuite) Test29_ModerationRejectWithoutComment() {
+ suite.T().Log("Testing: Reject request without comment")
+
+ // Создаем заявку
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Reject No Comment Test",
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Модератор пытается отклонить без комментария
+ _, moderatorToken := suite.createModerator()
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/reject", requestID)
+ rejectReq := map[string]interface{}{
+ "comment": "",
+ }
+ resp, _ := suite.makeRequest("POST", url, rejectReq, moderatorToken)
+
+ assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode)
+}
+
+// Test 30: Moderation without permission (should fail)
+func (suite *APITestSuite) Test30_ModerationWithoutPermission() {
+ suite.T().Log("Testing: Moderation without permission")
+
+ // Создаем заявку
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "No Permission Test",
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Обычный пользователь пытается модерировать
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/approve", requestID)
+ resp, _ := suite.makeRequest("POST", url, nil, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusForbidden, resp.StatusCode)
+}
+
+// Test 31: Get pending moderation requests
+func (suite *APITestSuite) Test31_GetPendingModerationRequests() {
+ suite.T().Log("Testing: GET /api/v1/moderation/requests/pending")
+
+ // Создаем несколько заявок
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ for i := 0; i < 3; i++ {
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": fmt.Sprintf("Pending Request %d", i),
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ }
+
+ // Модератор получает список
+ _, moderatorToken := suite.createModerator()
+ resp, body := suite.makeRequest("GET", "/api/v1/moderation/requests/pending?limit=10&offset=0", nil, moderatorToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var requests []map[string]interface{}
+ json.Unmarshal(body, &requests)
+ assert.NotEmpty(suite.T(), requests)
+
+ // Проверяем, что все заявки в статусе pending_moderation
+ for _, req := range requests {
+ statusObj := req["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "pending_moderation", statusObj["request_status"])
+ }
+}
+
+// Test 32: Get my moderated requests
+func (suite *APITestSuite) Test32_GetMyModeratedRequests() {
+ suite.T().Log("Testing: GET /api/v1/moderation/requests/my")
+
+ // Создаем модератора
+ moderatorID, moderatorToken := suite.createModerator()
+
+ // Создаем и модерируем несколько заявок
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ moderatedCount := 0
+ for i := 0; i < 2; i++ {
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": fmt.Sprintf("Moderated Request %d", i),
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Одобряем
+ url := fmt.Sprintf("/api/v1/moderation/requests/%d/approve", requestID)
+ approveReq := map[string]interface{}{}
+ resp, _ := suite.makeRequest("POST", url, approveReq, moderatorToken)
+ if resp.StatusCode == http.StatusOK {
+ moderatedCount++
+ }
+ }
+
+ // Получаем историю модерации
+ resp, body := suite.makeRequest("GET", "/api/v1/moderation/requests/my?limit=10&offset=0", nil, moderatorToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var requests []map[string]interface{}
+ json.Unmarshal(body, &requests)
+ assert.NotEmpty(suite.T(), requests)
+
+ // Проверяем, что все заявки модерированы этим модератором
+ for _, req := range requests {
+ if req["moderated_by"] != nil {
+ assert.Equal(suite.T(), float64(moderatorID), req["moderated_by"].(float64))
+ }
+ }
+}
+
+// ============================================================================
+// Тесты хранимых процедур
+// ============================================================================
+
+// Test33_AcceptVolunteerResponse проверяет успешное принятие отклика волонтера
+func (suite *APITestSuite) Test33_AcceptVolunteerResponse() {
+ // Создаем заявку и одобряем её
+ requestID := suite.createApprovedRequest(suite.accessToken)
+
+ // Создаем волонтера и отклик
+ volunteerID, responseID, _ := suite.createVolunteerWithResponse(requestID)
+
+ // Принимаем отклик через процедуру
+ url := fmt.Sprintf("/api/v1/requests/%d/responses/%d/accept", requestID, responseID)
+ resp, body := suite.makeRequest("POST", url, nil, suite.accessToken)
+
+ if resp.StatusCode != http.StatusOK {
+ suite.T().Logf("Accept response failed. Status: %d, Body: %s", resp.StatusCode, string(body))
+ }
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err := json.Unmarshal(body, &result)
+ require.NoError(suite.T(), err)
+
+ // Проверяем результат процедуры
+ assert.True(suite.T(), result["success"].(bool))
+ assert.Contains(suite.T(), result["message"].(string), "successfully")
+ assert.Equal(suite.T(), float64(requestID), result["request_id"].(float64))
+ assert.Equal(suite.T(), float64(volunteerID), result["volunteer_id"].(float64))
+
+ // Проверяем, что заявка перешла в статус in_progress
+ resp2, body2 := suite.makeRequest("GET", fmt.Sprintf("/api/v1/requests/%d", requestID), nil, suite.accessToken)
+ assert.Equal(suite.T(), http.StatusOK, resp2.StatusCode)
+
+ var request map[string]interface{}
+ json.Unmarshal(body2, &request)
+ statusObj := request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "in_progress", statusObj["request_status"])
+ assert.Equal(suite.T(), float64(volunteerID), request["assigned_volunteer_id"].(float64))
+
+ // Проверяем историю статусов
+ suite.checkRequestStatusHistory(requestID, []string{"approved", "in_progress"})
+}
+
+// Test34_AcceptVolunteerResponseValidation проверяет валидацию процедуры accept_volunteer_response
+func (suite *APITestSuite) Test34_AcceptVolunteerResponseValidation() {
+ // Создаем заявку от первого пользователя
+ requestID := suite.createApprovedRequest(suite.accessToken)
+
+ // Создаем волонтера с откликом
+ _, responseID, _ := suite.createVolunteerWithResponse(requestID)
+
+ // Создаем второго пользователя (не владелец заявки)
+ email := fmt.Sprintf("other_user_%d@example.com", time.Now().UnixNano())
+ registerReq := map[string]interface{}{
+ "email": email,
+ "password": "SecureP@ssw0rd123",
+ "first_name": "Other",
+ "last_name": "User",
+ }
+ _, regBody := suite.makeRequest("POST", "/api/v1/auth/register", registerReq, "")
+ var authResp map[string]interface{}
+ json.Unmarshal(regBody, &authResp)
+ otherUserToken := authResp["access_token"].(string)
+
+ // Попытка принять отклик другим пользователем (не владельцем заявки)
+ url := fmt.Sprintf("/api/v1/requests/%d/responses/%d/accept", requestID, responseID)
+ resp, body := suite.makeRequest("POST", url, nil, otherUserToken)
+
+ // Должна быть ошибка, так как пользователь не владелец заявки
+ assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode)
+
+ var result map[string]interface{}
+ json.Unmarshal(body, &result)
+
+ // Процедура возвращает success: false
+ if result["success"] != nil {
+ assert.False(suite.T(), result["success"].(bool))
+ assert.Contains(suite.T(), result["message"].(string), "not the owner")
+ } else {
+ // Или ошибка от обработчика
+ assert.Contains(suite.T(), result["error"].(string), "owner")
+ }
+}
+
+// Test35_CompleteRequestWithRating проверяет успешное завершение заявки с рейтингом
+func (suite *APITestSuite) Test35_CompleteRequestWithRating() {
+ // Создаем заявку, одобряем, принимаем отклик волонтера
+ requestID := suite.createApprovedRequest(suite.accessToken)
+ volunteerID, responseID, _ := suite.createVolunteerWithResponse(requestID)
+
+ // Принимаем отклик
+ acceptURL := fmt.Sprintf("/api/v1/requests/%d/responses/%d/accept", requestID, responseID)
+ suite.makeRequest("POST", acceptURL, nil, suite.accessToken)
+
+ // Завершаем заявку с рейтингом
+ completeReq := map[string]interface{}{
+ "rating": 5,
+ "comment": "Отличная работа!",
+ }
+ completeURL := fmt.Sprintf("/api/v1/requests/%d/complete", requestID)
+ resp, body := suite.makeRequest("POST", completeURL, completeReq, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusOK, resp.StatusCode)
+
+ var result map[string]interface{}
+ err := json.Unmarshal(body, &result)
+ require.NoError(suite.T(), err)
+
+ // Проверяем результат процедуры
+ assert.True(suite.T(), result["success"].(bool))
+ assert.Contains(suite.T(), result["message"].(string), "successfully")
+ assert.NotNil(suite.T(), result["rating_id"])
+
+ // Проверяем, что заявка завершена
+ resp2, body2 := suite.makeRequest("GET", fmt.Sprintf("/api/v1/requests/%d", requestID), nil, suite.accessToken)
+ assert.Equal(suite.T(), http.StatusOK, resp2.StatusCode)
+
+ var request map[string]interface{}
+ json.Unmarshal(body2, &request)
+ statusObj := request["status"].(map[string]interface{})
+ assert.Equal(suite.T(), "completed", statusObj["request_status"])
+
+ // completed_at может быть null, если поле не заполнилось
+ if request["completed_at"] == nil {
+ suite.T().Logf("Request response: %s", string(body2))
+ }
+ assert.NotNil(suite.T(), request["completed_at"])
+
+ // Проверяем, что рейтинг создан
+ ratingID := int64(result["rating_id"].(float64))
+ rating, err := suite.queries.GetRatingByResponseID(context.Background(), responseID)
+ require.NoError(suite.T(), err)
+ assert.Equal(suite.T(), ratingID, rating.ID)
+ assert.Equal(suite.T(), int32(5), rating.Rating)
+ assert.Equal(suite.T(), volunteerID, rating.VolunteerID)
+
+ // Проверяем историю статусов
+ suite.checkRequestStatusHistory(requestID, []string{"approved", "in_progress", "completed"})
+}
+
+// Test36_CompleteRequestWithRatingValidation проверяет валидацию рейтинга
+func (suite *APITestSuite) Test36_CompleteRequestWithRatingValidation() {
+ // Создаем заявку в статусе in_progress
+ requestID := suite.createApprovedRequest(suite.accessToken)
+ _, responseID, _ := suite.createVolunteerWithResponse(requestID)
+
+ acceptURL := fmt.Sprintf("/api/v1/requests/%d/responses/%d/accept", requestID, responseID)
+ suite.makeRequest("POST", acceptURL, nil, suite.accessToken)
+
+ // Попытка завершить с неправильным рейтингом (вне диапазона 1-5)
+ completeReq := map[string]interface{}{
+ "rating": 10, // Неверный рейтинг
+ }
+ completeURL := fmt.Sprintf("/api/v1/requests/%d/complete", requestID)
+ resp, body := suite.makeRequest("POST", completeURL, completeReq, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode)
+
+ var errorResp map[string]interface{}
+ json.Unmarshal(body, &errorResp)
+ assert.Contains(suite.T(), errorResp["error"].(string), "between 1 and 5")
+
+ // Попытка с рейтингом 0
+ completeReq2 := map[string]interface{}{
+ "rating": 0,
+ }
+ resp2, body2 := suite.makeRequest("POST", completeURL, completeReq2, suite.accessToken)
+
+ assert.Equal(suite.T(), http.StatusBadRequest, resp2.StatusCode)
+
+ var errorResp2 map[string]interface{}
+ json.Unmarshal(body2, &errorResp2)
+ assert.Contains(suite.T(), errorResp2["error"].(string), "between 1 and 5")
+}
+
+// Test37_CompleteRequestNotInProgress проверяет, что нельзя завершить заявку не в статусе in_progress
+func (suite *APITestSuite) Test37_CompleteRequestNotInProgress() {
+ // Создаем заявку (будет в статусе pending_moderation)
+ _, body := suite.makeRequest("GET", "/api/v1/request-types", nil, "")
+ var types []map[string]interface{}
+ json.Unmarshal(body, &types)
+ requestTypeID := int64(types[0]["id"].(float64))
+
+ createReq := map[string]interface{}{
+ "request_type_id": requestTypeID,
+ "title": "Request to complete",
+ "description": "Test",
+ "latitude": 55.751244,
+ "longitude": 37.618423,
+ "address": "Test",
+ }
+ _, respBody := suite.makeRequest("POST", "/api/v1/requests", createReq, suite.accessToken)
+ var request map[string]interface{}
+ json.Unmarshal(respBody, &request)
+ requestID := int64(request["id"].(float64))
+
+ // Попытка завершить заявку в статусе pending_moderation
+ completeReq := map[string]interface{}{
+ "rating": 5,
+ }
+ completeURL := fmt.Sprintf("/api/v1/requests/%d/complete", requestID)
+ resp, body := suite.makeRequest("POST", completeURL, completeReq, suite.accessToken)
+
+ // Процедура должна вернуть ошибку
+ assert.Equal(suite.T(), http.StatusBadRequest, resp.StatusCode)
+
+ var result map[string]interface{}
+ json.Unmarshal(body, &result)
+
+ // Хранимая процедура возвращает success: false, которое обработчик преобразует в error
+ if result["error"] != nil {
+ // Проверяем сообщение об ошибке содержит информацию о статусе
+ assert.Contains(suite.T(), result["error"].(string), "in_progress")
+ } else {
+ suite.T().Logf("Response: %s", string(body))
+ suite.T().Fatal("Expected error in response")
+ }
+}