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