initial commit

This commit is contained in:
2025-11-29 00:28:21 +05:00
parent 46229acc82
commit ec3b03a935
76 changed files with 13492 additions and 0 deletions

42
.env.example Normal file
View 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
View File

@@ -25,3 +25,4 @@ go.work.sum
# env file # env file
.env .env
.idea

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

125
cmd/api/main.go Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

50
go.mod Normal file
View 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
View 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=

View 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,
})
}

View 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})
}

View 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
}

View 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
View 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)
}

View 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))
})
}
}

View 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)
})
}

View 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
View 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
View 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
}

View 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
View 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,
}
}

View 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,
}
}

View 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
View 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"`
}

View 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)

View 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';

View 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;

View 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
);

View 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;

View 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);

View 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;

View 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
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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
View 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
}

View 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
}

View 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)
}

View 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)
}

View 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),
}
}

View 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
}

View 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)
}

View 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
}

View 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}
}

View 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)
}

View 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
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,7 @@
generator:
features:
enable:
- "paths/server"
- "client/request/validation"
- "server/response/validation"
disable_all: true

57
sqlc.yaml Normal file
View 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

File diff suppressed because it is too large Load Diff