Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3b03a935 |
42
.env.example
Normal file
42
.env.example
Normal file
@@ -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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
.idea
|
||||||
|
|||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -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
|
||||||
9
.idea/backend.iml
generated
Normal file
9
.idea/backend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="volontery_db@localhost" uuid="bf0b32ed-6f53-4eb9-879d-a67703269075">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5432/volontery_db</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/data_source_mapping.xml
generated
Normal file
7
.idea/data_source_mapping.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="bf0b32ed-6f53-4eb9-879d-a67703269075" />
|
||||||
|
<file url="file://$PROJECT_DIR$/.idea/queries/Query_1.sql" value="bf0b32ed-6f53-4eb9-879d-a67703269075" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
11
.idea/go.imports.xml
generated
Normal file
11
.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="github.com/pkg/errors" />
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.idea/misc.xml
generated
Normal file
19
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectInspectionProfilesVisibleTreeState">
|
||||||
|
<entry key="Project Default">
|
||||||
|
<profile-state>
|
||||||
|
<expanded-state>
|
||||||
|
<State>
|
||||||
|
<id>Gitlab CI inspections</id>
|
||||||
|
</State>
|
||||||
|
</expanded-state>
|
||||||
|
<selected-state>
|
||||||
|
<State>
|
||||||
|
<id>User defined</id>
|
||||||
|
</State>
|
||||||
|
</selected-state>
|
||||||
|
</profile-state>
|
||||||
|
</entry>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
170
Makefile
Normal file
170
Makefile
Normal file
@@ -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"
|
||||||
BIN
bin/volontery-api
Executable file
BIN
bin/volontery-api
Executable file
Binary file not shown.
125
cmd/api/main.go
Normal file
125
cmd/api/main.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -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
|
||||||
1469
docs/openapi.yaml
Normal file
1469
docs/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
50
go.mod
Normal file
50
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
106
go.sum
Normal file
106
go.sum
Normal file
@@ -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=
|
||||||
117
internal/api/handlers/auth.go
Normal file
117
internal/api/handlers/auth.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
28
internal/api/handlers/helpers.go
Normal file
28
internal/api/handlers/helpers.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
460
internal/api/handlers/requests.go
Normal file
460
internal/api/handlers/requests.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
308
internal/api/handlers/users.go
Normal file
308
internal/api/handlers/users.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
25
internal/api/helpers.go
Normal file
25
internal/api/helpers.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
107
internal/api/middleware/auth.go
Normal file
107
internal/api/middleware/auth.go
Normal file
@@ -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 <token>"
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/api/middleware/common.go
Normal file
94
internal/api/middleware/common.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
106
internal/api/middleware/rbac.go
Normal file
106
internal/api/middleware/rbac.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/api/router.go
Normal file
140
internal/api/router.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
139
internal/config/config.go
Normal file
139
internal/config/config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
548
internal/database/auth.sql.go
Normal file
548
internal/database/auth.sql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
32
internal/database/db.go
Normal file
32
internal/database/db.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/database/geography.go
Normal file
43
internal/database/geography.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
412
internal/database/geospatial.sql.go
Normal file
412
internal/database/geospatial.sql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
530
internal/database/models.go
Normal file
530
internal/database/models.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
185
internal/database/querier.go
Normal file
185
internal/database/querier.go
Normal file
@@ -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)
|
||||||
194
internal/database/queries/auth.sql
Normal file
194
internal/database/queries/auth.sql
Normal file
@@ -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';
|
||||||
151
internal/database/queries/geospatial.sql
Normal file
151
internal/database/queries/geospatial.sql
Normal file
@@ -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;
|
||||||
102
internal/database/queries/rbac.sql
Normal file
102
internal/database/queries/rbac.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
339
internal/database/queries/requests.sql
Normal file
339
internal/database/queries/requests.sql
Normal file
@@ -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;
|
||||||
192
internal/database/queries/responses.sql
Normal file
192
internal/database/queries/responses.sql
Normal file
@@ -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);
|
||||||
137
internal/database/queries/users.sql
Normal file
137
internal/database/queries/users.sql
Normal file
@@ -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;
|
||||||
352
internal/database/rbac.sql.go
Normal file
352
internal/database/rbac.sql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
1030
internal/database/requests.sql.go
Normal file
1030
internal/database/requests.sql.go
Normal file
File diff suppressed because it is too large
Load Diff
713
internal/database/responses.sql.go
Normal file
713
internal/database/responses.sql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
413
internal/database/users.sql.go
Normal file
413
internal/database/users.sql.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
113
internal/pkg/jwt/jwt.go
Normal file
113
internal/pkg/jwt/jwt.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
36
internal/pkg/password/password.go
Normal file
36
internal/pkg/password/password.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
73
internal/repository/auth_repository.go
Normal file
73
internal/repository/auth_repository.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
60
internal/repository/rbac_repository.go
Normal file
60
internal/repository/rbac_repository.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
26
internal/repository/repository.go
Normal file
26
internal/repository/repository.go
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/repository/request_repository.go
Normal file
165
internal/repository/request_repository.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
78
internal/repository/user_repository.go
Normal file
78
internal/repository/user_repository.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
246
internal/service/auth_service.go
Normal file
246
internal/service/auth_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
27
internal/service/helpers.go
Normal file
27
internal/service/helpers.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
202
internal/service/request_service.go
Normal file
202
internal/service/request_service.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
96
internal/service/user_service.go
Normal file
96
internal/service/user_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
22
migrations/00001_enable_extensions.sql
Normal file
22
migrations/00001_enable_extensions.sql
Normal file
@@ -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
|
||||||
56
migrations/00002_create_base_dictionaries.sql
Normal file
56
migrations/00002_create_base_dictionaries.sql
Normal file
@@ -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
|
||||||
59
migrations/00003_create_users_table.sql
Normal file
59
migrations/00003_create_users_table.sql
Normal file
@@ -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
|
||||||
44
migrations/00004_create_rbac_tables.sql
Normal file
44
migrations/00004_create_rbac_tables.sql
Normal file
@@ -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
|
||||||
103
migrations/00005_create_requests_table.sql
Normal file
103
migrations/00005_create_requests_table.sql
Normal file
@@ -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
|
||||||
60
migrations/00006_create_volunteer_responses_table.sql
Normal file
60
migrations/00006_create_volunteer_responses_table.sql
Normal file
@@ -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
|
||||||
43
migrations/00007_create_ratings_table.sql
Normal file
43
migrations/00007_create_ratings_table.sql
Normal file
@@ -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
|
||||||
110
migrations/00008_create_complaints_and_blocks_tables.sql
Normal file
110
migrations/00008_create_complaints_and_blocks_tables.sql
Normal file
@@ -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
|
||||||
60
migrations/00009_create_moderator_actions_table.sql
Normal file
60
migrations/00009_create_moderator_actions_table.sql
Normal file
@@ -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
|
||||||
82
migrations/00010_create_auth_tables.sql
Normal file
82
migrations/00010_create_auth_tables.sql
Normal file
@@ -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
|
||||||
58
migrations/00011_create_indexes_part1.sql
Normal file
58
migrations/00011_create_indexes_part1.sql
Normal file
@@ -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
|
||||||
43
migrations/00012_create_indexes_part2_gist.sql
Normal file
43
migrations/00012_create_indexes_part2_gist.sql
Normal file
@@ -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
|
||||||
158
migrations/00013_create_functions.sql
Normal file
158
migrations/00013_create_functions.sql
Normal file
@@ -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
|
||||||
198
migrations/00014_create_triggers.sql
Normal file
198
migrations/00014_create_triggers.sql
Normal file
@@ -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
|
||||||
246
migrations/00015_create_matching_functions.sql
Normal file
246
migrations/00015_create_matching_functions.sql
Normal file
@@ -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
|
||||||
187
migrations/00016_seed_initial_data.sql
Normal file
187
migrations/00016_seed_initial_data.sql
Normal file
@@ -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
|
||||||
82
migrations/00017_add_moderation_trigger.sql
Normal file
82
migrations/00017_add_moderation_trigger.sql
Normal file
@@ -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
|
||||||
306
migrations/00018_create_business_procedures.sql
Normal file
306
migrations/00018_create_business_procedures.sql
Normal file
@@ -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
|
||||||
58
migrations/00019_split_full_name.sql
Normal file
58
migrations/00019_split_full_name.sql
Normal file
@@ -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
|
||||||
7
ogen.yaml
Normal file
7
ogen.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
generator:
|
||||||
|
features:
|
||||||
|
enable:
|
||||||
|
- "paths/server"
|
||||||
|
- "client/request/validation"
|
||||||
|
- "server/response/validation"
|
||||||
|
disable_all: true
|
||||||
57
sqlc.yaml
Normal file
57
sqlc.yaml
Normal file
@@ -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"
|
||||||
1361
tests/e2e/api_test.go
Normal file
1361
tests/e2e/api_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user