commit 2d49f69be6915b77646823b62a607cd2b899ba94 Author: Dev Date: Sat Sep 13 06:48:55 2025 +0300 committtttt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5224b83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +/backend/bin/ +/backend/support + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log files +*.log +logs/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Frontend build artifacts +frontend/dist/ +frontend/build/ +frontend/node_modules/ + +# Kubernetes secrets +secrets/ +*.secret +*.key +*.pem +*.crt + +# Temporary files +tmp/ +temp/ +*.tmp +*.temp +.claude \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f33b1f --- /dev/null +++ b/README.md @@ -0,0 +1,288 @@ +# AI-Powered Support System + +A hybrid AI-powered customer support system that combines OpenAI's GPT-4 for complex queries with local LLM (Ollama) for simple queries. The system includes sentiment analysis, real-time chat, and a knowledge base for efficient customer support. + +## Features + +- **Hybrid AI System**: Combines OpenAI's GPT-4 for complex queries with local LLM (Ollama) for simple queries +- **Intelligent Routing**: Automatically routes queries to the appropriate AI model based on complexity +- **Sentiment Analysis**: Analyzes customer sentiment and escalates to human agents when needed +- **Real-time Chat**: WebSocket-based real-time communication with typing indicators and read receipts +- **Knowledge Base**: FAQ system with automatic suggestions based on conversation context +- **User Authentication**: JWT-based authentication with role-based access control +- **Admin Dashboard**: Analytics and management interface for administrators +- **Conversation History**: Search and review past conversations +- **Scalable Architecture**: Built with Go, PostgreSQL, and Redis for high performance + +## Technology Stack + +### Backend +- **Go**: Programming language +- **Gin**: Web framework +- **PostgreSQL**: Primary database +- **Redis**: Caching and session management +- **JWT**: Authentication +- **GORM**: ORM for database operations +- **Logrus**: Structured logging +- **Viper**: Configuration management + +### AI Integration +- **OpenAI API**: For complex queries +- **Ollama**: For local LLM integration +- **Sentiment Analysis**: For analyzing customer sentiment + +### Frontend (To be implemented) +- **React/Vue.js**: Frontend framework +- **WebSocket**: Real-time communication +- **Material-UI/Ant Design**: UI components + +### Deployment +- **Docker**: Containerization +- **Docker Compose**: Local development +- **Kubernetes**: Production deployment +- **CI/CD**: Automated testing and deployment + +## Project Structure + +``` +support/ +├── backend/ # Backend Go application +│ ├── cmd/ # Application entry point +│ │ └── main.go # Main application file +│ ├── internal/ # Internal application packages +│ │ ├── ai/ # AI service +│ │ ├── auth/ # Authentication service +│ │ ├── conversation/ # Conversation service +│ │ ├── database/ # Database connection and migrations +│ │ ├── handlers/ # HTTP handlers +│ │ ├── knowledge/ # Knowledge base service +│ │ ├── models/ # Data models +│ │ └── routes/ # Route definitions +│ ├── pkg/ # Public library packages +│ │ ├── config/ # Configuration management +│ │ └── logger/ # Logging utilities +│ ├── go.mod # Go module file +│ ├── go.sum # Go module checksums +│ └── Dockerfile # Docker configuration for backend +├── frontend/ # Frontend React/Vue.js application (to be implemented) +├── docs/ # Documentation +├── deployment/ # Kubernetes deployment configurations (to be implemented) +├── docker-compose.yml # Docker Compose configuration +├── .gitignore # Git ignore file +└── README.md # This file +``` + +## Getting Started + +### Prerequisites + +- Go 1.21 or higher +- Docker and Docker Compose +- PostgreSQL (if not using Docker) +- Redis (if not using Docker) +- OpenAI API key (for GPT-4 integration) + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/support.git + cd support + ``` + +2. Set up environment variables: + ```bash + cp backend/.env.example backend/.env + ``` + Edit the `.env` file with your configuration, including your OpenAI API key. + +3. Start the services using Docker Compose: + ```bash + docker-compose up -d + ``` + +4. Wait for all services to start up. You can check the logs with: + ```bash + docker-compose logs -f + ``` + +### Development + +#### Backend Development + +1. Navigate to the backend directory: + ```bash + cd backend + ``` + +2. Install dependencies: + ```bash + go mod download + ``` + +3. Run the application: + ```bash + go run cmd/main.go + ``` + +The backend API will be available at `http://localhost:8080`. + +#### Frontend Development (To be implemented) + +1. Navigate to the frontend directory: + ```bash + cd frontend + ``` + +2. Install dependencies: + ```bash + npm install + ``` + +3. Run the development server: + ```bash + npm run dev + ``` + +The frontend will be available at `http://localhost:3000`. + +## API Documentation + +### Authentication Endpoints + +- `POST /api/v1/public/login` - User login +- `POST /api/v1/public/register` - User registration + +### User Endpoints + +- `GET /api/v1/user/profile` - Get user profile +- `PUT /api/v1/user/profile` - Update user profile +- `PUT /api/v1/user/change-password` - Change password + +### Admin Endpoints + +- `GET /api/v1/admin/users` - List all users +- `GET /api/v1/admin/users/:id` - Get user by ID +- `PUT /api/v1/admin/users/:id` - Update user +- `DELETE /api/v1/admin/users/:id` - Delete user + +### Conversation Endpoints + +- `GET /api/v1/conversations` - List conversations +- `POST /api/v1/conversations` - Create new conversation +- `GET /api/v1/conversations/:id` - Get conversation by ID +- `PUT /api/v1/conversations/:id` - Update conversation +- `DELETE /api/v1/conversations/:id` - Delete conversation +- `GET /api/v1/conversations/:id/stats` - Get conversation statistics + +### Message Endpoints + +- `GET /api/v1/conversations/:id/messages` - Get messages in a conversation +- `POST /api/v1/conversations/:id/messages` - Send a message +- `PUT /api/v1/conversations/:id/messages/:messageId` - Update a message +- `DELETE /api/v1/conversations/:id/messages/:messageId` - Delete a message +- `POST /api/v1/conversations/:id/ai` - Send message with AI response + +### Knowledge Base Endpoints + +- `GET /api/v1/knowledge` - Get knowledge base entries +- `GET /api/v1/knowledge/search` - Search knowledge base +- `GET /api/v1/knowledge/categories` - Get knowledge categories +- `GET /api/v1/knowledge/tags` - Get knowledge tags +- `GET /api/v1/knowledge/popular` - Get popular knowledge entries +- `GET /api/v1/knowledge/recent` - Get recent knowledge entries +- `GET /api/v1/knowledge/best-match` - Find best match for a query +- `GET /api/v1/knowledge/stats` - Get knowledge base statistics +- `GET /api/v1/knowledge/:id` - Get knowledge base entry by ID +- `POST /api/v1/knowledge/:id/rate` - Rate knowledge base entry + +#### Admin Knowledge Base Endpoints + +- `POST /api/v1/admin/knowledge` - Create knowledge base entry +- `PUT /api/v1/admin/knowledge/:id` - Update knowledge base entry +- `DELETE /api/v1/admin/knowledge/:id` - Delete knowledge base entry + +### AI Endpoints + +- `POST /api/v1/ai/query` - Query AI service +- `POST /api/v1/ai/analyze-complexity` - Analyze complexity of a prompt +- `GET /api/v1/ai/models` - Get available AI models +- `POST /api/v1/ai/openai` - Query OpenAI directly +- `POST /api/v1/ai/ollama` - Query Ollama directly + +## Testing + +### Backend Testing + +1. Navigate to the backend directory: + ```bash + cd backend + ``` + +2. Run tests: + ```bash + go test ./... + ``` + +### Frontend Testing (To be implemented) + +1. Navigate to the frontend directory: + ```bash + cd frontend + ``` + +2. Run tests: + ```bash + npm test + ``` + +## Deployment + +### Docker Deployment + +1. Build the Docker image: + ```bash + docker build -t support-backend ./backend + ``` + +2. Run the container: + ```bash + docker run -p 8080:8080 support-backend + ``` + +### Docker Compose Deployment + +1. Start all services: + ```bash + docker-compose up -d + ``` + +2. Stop all services: + ```bash + docker-compose down + ``` + +### Kubernetes Deployment (To be implemented) + +1. Apply the Kubernetes configurations: + ```bash + kubectl apply -f deployment/ + ``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add some amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Contact + +Your Name - [@yourusername](https://twitter.com/yourusername) - email@example.com + +Project Link: [https://github.com/yourusername/support](https://github.com/yourusername/support) \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fcbe6bf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,41 @@ +# Server Configuration +SERVER_HOST=0.0.0.0 +SERVER_PORT=8080 +SERVER_READ_TIMEOUT=30s +SERVER_WRITE_TIMEOUT=30s + +# Database Configuration +DB_HOST=localhost +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=support +DB_PORT=5432 +DB_SSLMODE=disable +DB_TIMEZONE=UTC + +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + +# Authentication Configuration +AUTH_JWT_SECRET=your-secret-key-change-in-production +AUTH_JWT_EXPIRATION_HOURS=24 + +# AI Configuration +AI_OPENAI_API_KEY=your-openai-api-key +AI_OPENAI_MODEL=gpt-4 +AI_OPENAI_MAX_TOKENS=4000 +AI_OPENAI_TEMPERATURE=0.7 +AI_OPENAI_TOP_P=1.0 + +AI_LOCAL_ENDPOINT=http://localhost:11434 +AI_LOCAL_MODEL=llama2 +AI_LOCAL_MAX_TOKENS=2000 +AI_LOCAL_TEMPERATURE=0.7 +AI_LOCAL_TOP_P=1.0 + +# Logging Configuration +LOG_LEVEL=info +LOG_FORMAT=json \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..153cbb5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,39 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +# Set working directory +WORKDIR /app + +# Install git and certificates +RUN apk add --no-cache git ca-certificates tzdata + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/main.go + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates tzdata + +# Set working directory +WORKDIR /root/ + +# Copy binary from builder +COPY --from=builder /app/main . +COPY --from=builder /app/.env.example . + +# Expose port +EXPOSE 8080 + +# Command to run the application +CMD ["./main"] \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..277ec07 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,125 @@ +.PHONY: build run test clean deps docker-build docker-run docker-compose-up docker-compose-down + +# Variables +BINARY_NAME=support +BINARY_UNIX=$(BINARY_NAME)_unix +VERSION?=1.0.0 +BUILD_TIME?=$(shell date -u '+%Y-%m-%d_%H:%M:%S') +COMMIT?=$(shell git rev-parse --short HEAD) + +# Go build flags +LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.Commit=$(COMMIT)" + +# Default target +all: deps build + +# Install dependencies +deps: + go mod download + go mod tidy + +# Build the application +build: + go build $(LDFLAGS) -o $(BINARY_NAME) cmd/main.go + +# Build for Linux +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY_UNIX) cmd/main.go + +# Run the application +run: + go run cmd/main.go + +# Run tests +test: + go test -v ./... + +# Run tests with coverage +test-coverage: + go test -v -cover ./... + +# Clean build artifacts +clean: + go clean + rm -f $(BINARY_NAME) + rm -f $(BINARY_UNIX) + +# Build Docker image +docker-build: + docker build -t $(BINARY_NAME):$(VERSION) . + +# Run Docker container +docker-run: + docker run -p 8080:8080 $(BINARY_NAME):$(VERSION) + +# Start Docker Compose +docker-compose-up: + docker-compose up -d + +# Stop Docker Compose +docker-compose-down: + docker-compose down + +# View Docker Compose logs +docker-compose-logs: + docker-compose logs -f + +# Run database migrations +migrate-up: + go run cmd/migrate.go up + +# Rollback database migrations +migrate-down: + go run cmd/migrate.go down + +# Seed database +seed-db: + go run cmd/seed.go + +# Format code +fmt: + go fmt ./... + +# Lint code +lint: + golangci-lint run + +# Security check +security: + gosec ./... + +# Generate API documentation +docs: + swag init -g cmd/main.go + +# Install development tools +install-tools: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest + go install github.com/swaggo/swag/cmd/swag@latest + +# Help target +help: + @echo "Available targets:" + @echo " all - Install dependencies and build the application" + @echo " deps - Install dependencies" + @echo " build - Build the application" + @echo " build-linux - Build the application for Linux" + @echo " run - Run the application" + @echo " test - Run tests" + @echo " test-coverage - Run tests with coverage" + @echo " clean - Clean build artifacts" + @echo " docker-build - Build Docker image" + @echo " docker-run - Run Docker container" + @echo " docker-compose-up - Start Docker Compose" + @echo " docker-compose-down - Stop Docker Compose" + @echo " docker-compose-logs - View Docker Compose logs" + @echo " migrate-up - Run database migrations" + @echo " migrate-down - Rollback database migrations" + @echo " seed-db - Seed database" + @echo " fmt - Format code" + @echo " lint - Lint code" + @echo " security - Security check" + @echo " docs - Generate API documentation" + @echo " install-tools - Install development tools" + @echo " help - Show this help message" \ No newline at end of file diff --git a/backend/cmd/main/main.go b/backend/cmd/main/main.go new file mode 100644 index 0000000..2db0891 --- /dev/null +++ b/backend/cmd/main/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "customer-support-system/internal/database" + "customer-support-system/internal/routes" + "customer-support-system/pkg/config" + "customer-support-system/pkg/logger" +) + +func main() { + // Load configuration + if err := config.LoadConfig(); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize logger + logger.InitLogger() + + // Connect to database + if err := database.Connect(); err != nil { + logger.WithError(err).Fatal("Failed to connect to database") + } + + // Run migrations + if err := database.AutoMigrate(); err != nil { + logger.WithError(err).Fatal("Failed to run database migrations") + } + + // Seed database with initial data + if err := database.SeedDatabase(); err != nil { + logger.WithError(err).Fatal("Failed to seed database") + } + + // Set up routes + router := routes.SetupRoutes() + + // Create HTTP server + server := &http.Server{ + Addr: config.AppConfig.Server.GetServerAddress(), + Handler: router, + ReadTimeout: config.AppConfig.Server.ReadTimeout, + WriteTimeout: config.AppConfig.Server.WriteTimeout, + } + + // Start server in a goroutine + go func() { + logger.WithField("address", server.Addr).Info("Starting server") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.WithError(err).Fatal("Failed to start server") + } + }() + + // Set up graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + logger.Info("Shutting down server...") + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Shutdown server + if err := server.Shutdown(ctx); err != nil { + logger.WithError(err).Fatal("Server forced to shutdown") + } + + // Close database connection + if err := database.Close(); err != nil { + logger.WithError(err).Error("Failed to close database connection") + } + + logger.Info("Server exited") +} diff --git a/backend/cmd/migrate/migrate.go b/backend/cmd/migrate/migrate.go new file mode 100644 index 0000000..9f34aeb --- /dev/null +++ b/backend/cmd/migrate/migrate.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "customer-support-system/internal/database" + "customer-support-system/pkg/config" + "customer-support-system/pkg/logger" +) + +func main() { + // Load configuration + if err := config.LoadConfig(); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize logger + logger.InitLogger() + + // Connect to database + if err := database.Connect(); err != nil { + logger.WithError(err).Fatal("Failed to connect to database") + } + + // Parse command line arguments + action := flag.String("action", "up", "Migration action (up or down)") + flag.Parse() + + // Run migrations based on action + switch *action { + case "up": + if err := database.AutoMigrate(); err != nil { + logger.WithError(err).Fatal("Failed to run database migrations") + } + fmt.Println("Database migrations completed successfully") + case "down": + // Note: GORM doesn't support automatic rollback of migrations + // In a real application, you would need to implement a more sophisticated migration system + fmt.Println("Automatic rollback is not supported with GORM") + fmt.Println("Please manually revert the database schema changes") + default: + fmt.Printf("Unknown action: %s\n", *action) + fmt.Println("Usage: go run cmd/migrate.go -action=[up|down]") + os.Exit(1) + } + + // Close database connection + if err := database.Close(); err != nil { + logger.WithError(err).Error("Failed to close database connection") + } +} diff --git a/backend/cmd/seed/seed.go b/backend/cmd/seed/seed.go new file mode 100644 index 0000000..23d4c2b --- /dev/null +++ b/backend/cmd/seed/seed.go @@ -0,0 +1,36 @@ +package main + +import ( + "log" + + "customer-support-system/internal/database" + "customer-support-system/pkg/config" + "customer-support-system/pkg/logger" +) + +func main() { + // Load configuration + if err := config.LoadConfig(); err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Initialize logger + logger.InitLogger() + + // Connect to database + if err := database.Connect(); err != nil { + logger.WithError(err).Fatal("Failed to connect to database") + } + + // Seed database with initial data + if err := database.SeedDatabase(); err != nil { + logger.WithError(err).Fatal("Failed to seed database") + } + + logger.Info("Database seeded successfully") + + // Close database connection + if err := database.Close(); err != nil { + logger.WithError(err).Error("Failed to close database connection") + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b0baf71 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,60 @@ +module customer-support-system + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/viper v1.16.0 + github.com/stretchr/testify v1.8.4 + golang.org/x/crypto v0.14.0 + gorm.io/driver/postgres v1.5.2 + gorm.io/gorm v1.25.4 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/afero v1.9.5 // indirect + github.com/spf13/cast v1.5.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e3d6594 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,566 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= +github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +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-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= +github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= +github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= +github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc= +github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1FofRzQg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= +gorm.io/gorm v1.25.4 h1:iyNd8fNAe8W9dvtlgeRI5zSVZPsq3OpcTu37cYcpCmw= +gorm.io/gorm v1.25.4/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/backend/internal/ai/ai.go b/backend/internal/ai/ai.go new file mode 100644 index 0000000..3e51a07 --- /dev/null +++ b/backend/internal/ai/ai.go @@ -0,0 +1,394 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "customer-support-system/internal/models" + "customer-support-system/pkg/config" + "customer-support-system/pkg/logger" +) + +// OpenAIRequest represents a request to the OpenAI API +type OpenAIRequest struct { + Model string `json:"model"` + Messages []OpenAIMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// OpenAIMessage represents a message in the OpenAI API +type OpenAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// OpenAIResponse represents a response from the OpenAI API +type OpenAIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` + Usage OpenAIUsage `json:"usage"` +} + +// OpenAIChoice represents a choice in the OpenAI API response +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// OpenAIUsage represents usage statistics in the OpenAI API response +type OpenAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// OllamaRequest represents a request to the Ollama API +type OllamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// OllamaResponse represents a response from the Ollama API +type OllamaResponse struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + Response string `json:"response"` + Done bool `json:"done"` +} + +// AIService handles AI operations +type AIService struct { + openAIConfig config.APIConfig + localConfig config.APIConfig + httpClient *http.Client +} + +// NewAIService creates a new AI service +func NewAIService() *AIService { + return &AIService{ + openAIConfig: config.AppConfig.AI.OpenAI, + localConfig: config.AppConfig.AI.Local, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// QueryOpenAI sends a query to the OpenAI API +func (s *AIService) QueryOpenAI(ctx context.Context, prompt string, conversationHistory []models.Message) (string, error) { + startTime := time.Now() + + // Convert conversation history to OpenAI messages + messages := s.convertToOpenAIMessages(prompt, conversationHistory) + + // Create request + req := OpenAIRequest{ + Model: s.openAIConfig.Model, + Messages: messages, + MaxTokens: s.openAIConfig.MaxTokens, + Temperature: s.openAIConfig.Temperature, + TopP: s.openAIConfig.TopP, + } + + // Marshal request to JSON + reqBody, err := json.Marshal(req) + if err != nil { + logger.WithError(err).Error("Failed to marshal OpenAI request") + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/chat/completions", bytes.NewBuffer(reqBody)) + if err != nil { + logger.WithError(err).Error("Failed to create OpenAI HTTP request") + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.openAIConfig.APIKey)) + + // Send request + resp, err := s.httpClient.Do(httpReq) + if err != nil { + logger.WithError(err).Error("Failed to send OpenAI request") + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + logger.WithError(err).Error("Failed to read OpenAI response") + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Check status code + if resp.StatusCode != http.StatusOK { + logger.WithField("status_code", resp.StatusCode). + WithField("response", string(respBody)). + Error("OpenAI API returned non-200 status code") + return "", fmt.Errorf("OpenAI API returned status code %d: %s", resp.StatusCode, string(respBody)) + } + + // Parse response + var openAIResp OpenAIResponse + if err := json.Unmarshal(respBody, &openAIResp); err != nil { + logger.WithError(err).Error("Failed to parse OpenAI response") + return "", fmt.Errorf("failed to parse response: %w", err) + } + + // Extract response text + if len(openAIResp.Choices) == 0 { + logger.Error("OpenAI API returned no choices") + return "", fmt.Errorf("OpenAI API returned no choices") + } + + responseText := openAIResp.Choices[0].Message.Content + + // Log AI interaction + duration := time.Since(startTime) + logger.LogAIInteraction( + s.openAIConfig.Model, + len(prompt), + len(responseText), + duration, + true, + nil, + ) + + return responseText, nil +} + +// QueryOllama sends a query to the Ollama API +func (s *AIService) QueryOllama(ctx context.Context, prompt string, conversationHistory []models.Message) (string, error) { + startTime := time.Now() + + // Create request + req := OllamaRequest{ + Model: s.localConfig.Model, + Prompt: s.buildOllamaPrompt(prompt, conversationHistory), + Stream: false, + Options: map[string]any{ + "temperature": s.localConfig.Temperature, + "top_p": s.localConfig.TopP, + "num_predict": s.localConfig.MaxTokens, + }, + } + + // Marshal request to JSON + reqBody, err := json.Marshal(req) + if err != nil { + logger.WithError(err).Error("Failed to marshal Ollama request") + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/api/generate", s.localConfig.Endpoint), bytes.NewBuffer(reqBody)) + if err != nil { + logger.WithError(err).Error("Failed to create Ollama HTTP request") + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + httpReq.Header.Set("Content-Type", "application/json") + + // Send request + resp, err := s.httpClient.Do(httpReq) + if err != nil { + logger.WithError(err).Error("Failed to send Ollama request") + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + // Read response body + respBody, err := io.ReadAll(resp.Body) + if err != nil { + logger.WithError(err).Error("Failed to read Ollama response") + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Check status code + if resp.StatusCode != http.StatusOK { + logger.WithField("status_code", resp.StatusCode). + WithField("response", string(respBody)). + Error("Ollama API returned non-200 status code") + return "", fmt.Errorf("Ollama API returned status code %d: %s", resp.StatusCode, string(respBody)) + } + + // Parse response + var ollamaResp OllamaResponse + if err := json.Unmarshal(respBody, &ollamaResp); err != nil { + logger.WithError(err).Error("Failed to parse Ollama response") + return "", fmt.Errorf("failed to parse response: %w", err) + } + + // Extract response text + responseText := ollamaResp.Response + + // Log AI interaction + duration := time.Since(startTime) + logger.LogAIInteraction( + s.localConfig.Model, + len(prompt), + len(responseText), + duration, + true, + nil, + ) + + return responseText, nil +} + +// Query sends a query to the appropriate AI model based on complexity +func (s *AIService) Query(ctx context.Context, prompt string, conversationHistory []models.Message, complexity int) (string, error) { + // Determine which AI model to use based on complexity + if complexity >= 7 { // High complexity, use OpenAI + return s.QueryOpenAI(ctx, prompt, conversationHistory) + } else { // Low to medium complexity, use local LLM + return s.QueryOllama(ctx, prompt, conversationHistory) + } +} + +// AnalyzeComplexity analyzes the complexity of a prompt +func (s *AIService) AnalyzeComplexity(prompt string) int { + // Simple heuristic for complexity analysis + // In a real implementation, this would be more sophisticated + + complexity := 0 + + // Length factor + if len(prompt) > 100 { + complexity += 2 + } + if len(prompt) > 200 { + complexity += 2 + } + + // Question type factor + if strings.Contains(prompt, "?") { + complexity += 1 + } + + // Technical terms factor + technicalTerms := []string{"API", "database", "server", "code", "programming", "software", "algorithm"} + for _, term := range technicalTerms { + if strings.Contains(strings.ToLower(prompt), strings.ToLower(term)) { + complexity += 1 + } + } + + // Multiple questions factor + questionCount := strings.Count(prompt, "?") + if questionCount > 1 { + complexity += questionCount - 1 + } + + // Cap complexity at 10 + if complexity > 10 { + complexity = 10 + } + + return complexity +} + +// convertToOpenAIMessages converts conversation history to OpenAI messages +func (s *AIService) convertToOpenAIMessages(prompt string, conversationHistory []models.Message) []OpenAIMessage { + messages := []OpenAIMessage{} + + // Add system message + messages = append(messages, OpenAIMessage{ + Role: "system", + Content: "You are a helpful customer support assistant. Provide clear, concise, and accurate answers to customer questions.", + }) + + // Add conversation history + for _, msg := range conversationHistory { + role := "user" + if msg.IsAI { + role = "assistant" + } + messages = append(messages, OpenAIMessage{ + Role: role, + Content: msg.Content, + }) + } + + // Add current prompt + messages = append(messages, OpenAIMessage{ + Role: "user", + Content: prompt, + }) + + return messages +} + +// buildOllamaPrompt builds a prompt for Ollama from conversation history +func (s *AIService) buildOllamaPrompt(prompt string, conversationHistory []models.Message) string { + var builder strings.Builder + + // Add system instruction + builder.WriteString("You are a helpful customer support assistant. Provide clear, concise, and accurate answers to customer questions.\n\n") + + // Add conversation history + for _, msg := range conversationHistory { + if msg.IsAI { + builder.WriteString("Assistant: ") + } else { + builder.WriteString("User: ") + } + builder.WriteString(msg.Content) + builder.WriteString("\n\n") + } + + // Add current prompt + builder.WriteString("User: ") + builder.WriteString(prompt) + builder.WriteString("\n\nAssistant: ") + + return builder.String() +} + +// GetAvailableModels returns the available AI models +func (s *AIService) GetAvailableModels() []models.AIModel { + return []models.AIModel{ + { + Name: "OpenAI GPT-4", + Type: "openai", + Model: s.openAIConfig.Model, + MaxTokens: s.openAIConfig.MaxTokens, + Temperature: s.openAIConfig.Temperature, + TopP: s.openAIConfig.TopP, + Active: true, + Priority: 2, + Description: "OpenAI's GPT-4 model for complex queries", + }, + { + Name: "Local LLaMA", + Type: "local", + Model: s.localConfig.Model, + Endpoint: s.localConfig.Endpoint, + MaxTokens: s.localConfig.MaxTokens, + Temperature: s.localConfig.Temperature, + TopP: s.localConfig.TopP, + Active: true, + Priority: 1, + Description: "Local LLaMA model for simple queries", + }, + } +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..e5e2e7a --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,362 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "customer-support-system/internal/database" + "customer-support-system/internal/models" + "customer-support-system/pkg/config" + "customer-support-system/pkg/logger" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserNotFound = errors.New("user not found") + ErrUserInactive = errors.New("user is inactive") + ErrAccountLocked = errors.New("account is locked") + ErrTokenExpired = errors.New("token has expired") + ErrInvalidToken = errors.New("invalid token") +) + +// AuthService handles authentication operations +type AuthService struct { + db *gorm.DB +} + +// NewAuthService creates a new authentication service +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{db: db} +} + +// Login authenticates a user and returns a JWT token +func (s *AuthService) Login(username, password, clientIP string) (*models.LoginResponse, error) { + // Find user by username + var user models.User + if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + logger.WithField("username", username).Warn("Login attempt with non-existent username") + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to find user: %w", err) + } + + // Check if user is active + if !user.Active { + logger.WithField("user_id", user.ID).Warn("Login attempt by inactive user") + return nil, ErrUserInactive + } + + // Check password + if !user.ComparePassword(password) { + logger.WithField("user_id", user.ID).Warn("Login attempt with incorrect password") + return nil, ErrInvalidCredentials + } + + // Generate JWT token + token, err := s.GenerateJWTToken(user.ID) + if err != nil { + logger.WithError(err).WithField("user_id", user.ID).Error("Failed to generate JWT token") + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + // Log successful login + logger.LogAuthEvent("login", fmt.Sprintf("%d", user.ID), clientIP, true, nil) + + return &models.LoginResponse{ + Token: token, + User: user.ToSafeUser(), + }, nil +} + +// Register creates a new user +func (s *AuthService) Register(req *models.CreateUserRequest) (*models.SafeUser, error) { + // Check if username already exists + var existingUser models.User + if err := s.db.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { + return nil, fmt.Errorf("username already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to check username: %w", err) + } + + // Check if email already exists + if err := s.db.Where("email = ?", req.Email).First(&existingUser).Error; err == nil { + return nil, fmt.Errorf("email already exists") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to check email: %w", err) + } + + // Create new user + user := models.User{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + FirstName: req.FirstName, + LastName: req.LastName, + Role: req.Role, + Active: true, + } + + if err := s.db.Create(&user).Error; err != nil { + logger.WithError(err).WithField("username", req.Username).Error("Failed to create user") + return nil, fmt.Errorf("failed to create user: %w", err) + } + + // Log user registration + logger.WithField("user_id", user.ID).Info("User registered successfully") + + safeUser := user.ToSafeUser() + return &safeUser, nil +} + +// GenerateJWTToken generates a JWT token for a user +func (s *AuthService) GenerateJWTToken(userID uint) (string, error) { + // Get JWT configuration + jwtConfig := config.AppConfig.JWT + + // Create claims + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Hour * time.Duration(jwtConfig.ExpirationHours)).Unix(), + "iat": time.Now().Unix(), + "iss": jwtConfig.Issuer, + "aud": jwtConfig.Audience, + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign token + tokenString, err := token.SignedString(jwtConfig.GetJWTSigningKey()) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return tokenString, nil +} + +// ValidateJWTToken validates a JWT token and returns the user ID +func (s *AuthService) ValidateJWTToken(tokenString string) (uint, error) { + // Get JWT configuration + jwtConfig := config.AppConfig.JWT + + // Parse token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtConfig.GetJWTSigningKey(), nil + }) + + if err != nil { + return 0, fmt.Errorf("failed to parse token: %w", err) + } + + // Validate claims + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + // Check expiration + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + return 0, ErrTokenExpired + } + } + + // Get user ID + userID, ok := claims["user_id"].(float64) + if !ok { + return 0, ErrInvalidToken + } + + return uint(userID), nil + } + + return 0, ErrInvalidToken +} + +// GetUserByID returns a user by ID +func (s *AuthService) GetUserByID(userID uint) (*models.User, error) { + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return &user, nil +} + +// UpdateUser updates a user +func (s *AuthService) UpdateUser(userID uint, req *models.UpdateUserRequest) (*models.SafeUser, error) { + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + + // Update fields + if req.FirstName != "" { + user.FirstName = req.FirstName + } + if req.LastName != "" { + user.LastName = req.LastName + } + if req.Email != "" { + user.Email = req.Email + } + if req.Active != nil { + user.Active = *req.Active + } + if req.Role != "" { + user.Role = req.Role + } + + if err := s.db.Save(&user).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to update user") + return nil, fmt.Errorf("failed to update user: %w", err) + } + + // Log user update + logger.WithField("user_id", userID).Info("User updated successfully") + + safeUser := user.ToSafeUser() + return &safeUser, nil +} + +// ChangePassword changes a user's password +func (s *AuthService) ChangePassword(userID uint, req *models.ChangePasswordRequest) error { + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrUserNotFound + } + return fmt.Errorf("failed to get user: %w", err) + } + + // Check current password + if !user.ComparePassword(req.CurrentPassword) { + logger.WithField("user_id", userID).Warn("Password change attempt with incorrect current password") + return ErrInvalidCredentials + } + + // Update password + user.Password = req.NewPassword + if err := s.db.Save(&user).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to change password") + return fmt.Errorf("failed to change password: %w", err) + } + + // Log password change + logger.WithField("user_id", userID).Info("Password changed successfully") + + return nil +} + +// HashPassword hashes a password using bcrypt +func HashPassword(password string) (string, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(hashedPassword), nil +} + +// CheckPassword checks if a password matches a hashed password +func CheckPassword(password, hashedPassword string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + return err == nil +} + +// AuthMiddleware returns a gin middleware for authentication +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // Get token from Authorization header + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + c.JSON(401, gin.H{"error": "Authorization header is required"}) + c.Abort() + return + } + + // Remove "Bearer " prefix + if len(tokenString) > 7 && tokenString[:7] == "Bearer " { + tokenString = tokenString[7:] + } + + // Validate token + authService := NewAuthService(database.GetDB()) + userID, err := authService.ValidateJWTToken(tokenString) + if err != nil { + c.JSON(401, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + // Get user + user, err := authService.GetUserByID(userID) + if err != nil { + c.JSON(401, gin.H{"error": "User not found"}) + c.Abort() + return + } + + // Check if user is active + if !user.Active { + c.JSON(401, gin.H{"error": "User account is inactive"}) + c.Abort() + return + } + + // Set user ID in context + c.Set("userID", userID) + c.Set("userRole", user.Role) + c.Next() + } +} + +// RoleMiddleware returns a gin middleware for role-based authorization +func RoleMiddleware(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + // Get user role from context + userRole, exists := c.Get("userRole") + if !exists { + c.JSON(401, gin.H{"error": "User not authenticated"}) + c.Abort() + return + } + + // Check if user has required role + roleStr, ok := userRole.(string) + if !ok { + c.JSON(500, gin.H{"error": "Invalid user role"}) + c.Abort() + return + } + + // Check if user role is in allowed roles + allowed := false + for _, role := range roles { + if roleStr == role { + allowed = true + break + } + } + + if !allowed { + c.JSON(403, gin.H{"error": "Insufficient permissions"}) + c.Abort() + return + } + + c.Next() + } +} diff --git a/backend/internal/conversation/conversation.go b/backend/internal/conversation/conversation.go new file mode 100644 index 0000000..bcda6e3 --- /dev/null +++ b/backend/internal/conversation/conversation.go @@ -0,0 +1,436 @@ +package conversation + +import ( + "context" + "database/sql" + "fmt" + "time" + + "gorm.io/gorm" + + "customer-support-system/internal/ai" + "customer-support-system/internal/database" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// ConversationService handles conversation operations +type ConversationService struct { + db *gorm.DB + aiService *ai.AIService +} + +// NewConversationService creates a new conversation service +func NewConversationService() *ConversationService { + return &ConversationService{ + db: database.GetDB(), + aiService: ai.NewAIService(), + } +} + +// CreateConversation creates a new conversation +func (s *ConversationService) CreateConversation(userID uint, req *models.CreateConversationRequest) (*models.Conversation, error) { + conversation := models.Conversation{ + Title: req.Title, + UserID: userID, + Department: req.Department, + Priority: req.Priority, + Tags: req.Tags, + Status: "active", + } + + if err := s.db.Create(&conversation).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to create conversation") + return nil, fmt.Errorf("failed to create conversation: %w", err) + } + + logger.WithField("conversation_id", conversation.ID).Info("Conversation created successfully") + + return &conversation, nil +} + +// GetConversation retrieves a conversation by ID +func (s *ConversationService) GetConversation(conversationID uint, userID uint) (*models.Conversation, error) { + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + + return &conversation, nil +} + +// ListConversations retrieves a list of conversations for a user +func (s *ConversationService) ListConversations(userID uint, page, pageSize int, status string) (*models.ConversationListResponse, error) { + var conversations []models.Conversation + var total int64 + + query := s.db.Model(&models.Conversation{}).Where("user_id = ?", userID) + + if status != "" { + query = query.Where("status = ?", status) + } + + // Get total count + if err := query.Count(&total).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to count conversations") + return nil, fmt.Errorf("failed to count conversations: %w", err) + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := query.Order("last_message_at DESC").Offset(offset).Limit(pageSize).Find(&conversations).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to list conversations") + return nil, fmt.Errorf("failed to list conversations: %w", err) + } + + return &models.ConversationListResponse{ + Conversations: conversations, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// UpdateConversation updates a conversation +func (s *ConversationService) UpdateConversation(conversationID uint, userID uint, req *models.UpdateConversationRequest) (*models.Conversation, error) { + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + + // Update fields + if req.Title != "" { + conversation.Title = req.Title + } + if req.Status != "" { + conversation.Status = req.Status + } + if req.Department != "" { + conversation.Department = req.Department + } + if req.Priority != "" { + conversation.Priority = req.Priority + } + if req.Tags != "" { + conversation.Tags = req.Tags + } + if req.AgentID != nil { + conversation.AgentID = req.AgentID + } + + if err := s.db.Save(&conversation).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to update conversation") + return nil, fmt.Errorf("failed to update conversation: %w", err) + } + + logger.WithField("conversation_id", conversationID).Info("Conversation updated successfully") + + return &conversation, nil +} + +// DeleteConversation deletes a conversation +func (s *ConversationService) DeleteConversation(conversationID uint, userID uint) error { + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return fmt.Errorf("failed to get conversation: %w", err) + } + + if err := s.db.Delete(&conversation).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to delete conversation") + return fmt.Errorf("failed to delete conversation: %w", err) + } + + logger.WithField("conversation_id", conversationID).Info("Conversation deleted successfully") + + return nil +} + +// CreateMessage creates a new message in a conversation +func (s *ConversationService) CreateMessage(conversationID uint, userID uint, req *models.CreateMessageRequest) (*models.Message, error) { + // Check if conversation exists and belongs to user + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + + // Create message + message := models.Message{ + ConversationID: conversationID, + UserID: userID, + Content: req.Content, + Type: req.Type, + Status: "sent", + IsAI: false, + } + + if err := s.db.Create(&message).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create message") + return nil, fmt.Errorf("failed to create message: %w", err) + } + + logger.WithField("message_id", message.ID).Info("Message created successfully") + + return &message, nil +} + +// GetMessages retrieves messages in a conversation +func (s *ConversationService) GetMessages(conversationID uint, userID uint, page, pageSize int) (*models.MessageListResponse, error) { + // Check if conversation exists and belongs to user + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + + var messages []models.Message + var total int64 + + // Get total count + if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Count(&total).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to count messages") + return nil, fmt.Errorf("failed to count messages: %w", err) + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Offset(offset).Limit(pageSize).Find(&messages).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get messages") + return nil, fmt.Errorf("failed to get messages: %w", err) + } + + return &models.MessageListResponse{ + Messages: messages, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// UpdateMessage updates a message +func (s *ConversationService) UpdateMessage(messageID uint, userID uint, req *models.UpdateMessageRequest) (*models.Message, error) { + var message models.Message + if err := s.db.Where("id = ? AND user_id = ?", messageID, userID).First(&message).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("message not found") + } + logger.WithError(err).WithField("message_id", messageID).Error("Failed to get message") + return nil, fmt.Errorf("failed to get message: %w", err) + } + + // Update fields + if req.Content != "" { + message.Content = req.Content + } + if req.Status != "" { + message.Status = req.Status + } + + if err := s.db.Save(&message).Error; err != nil { + logger.WithError(err).WithField("message_id", messageID).Error("Failed to update message") + return nil, fmt.Errorf("failed to update message: %w", err) + } + + logger.WithField("message_id", messageID).Info("Message updated successfully") + + return &message, nil +} + +// DeleteMessage deletes a message +func (s *ConversationService) DeleteMessage(messageID uint, userID uint) error { + var message models.Message + if err := s.db.Where("id = ? AND user_id = ?", messageID, userID).First(&message).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("message not found") + } + logger.WithError(err).WithField("message_id", messageID).Error("Failed to get message") + return fmt.Errorf("failed to get message: %w", err) + } + + if err := s.db.Delete(&message).Error; err != nil { + logger.WithError(err).WithField("message_id", messageID).Error("Failed to delete message") + return fmt.Errorf("failed to delete message: %w", err) + } + + logger.WithField("message_id", messageID).Info("Message deleted successfully") + + return nil +} + +// SendMessageWithAI sends a message and gets an AI response +func (s *ConversationService) SendMessageWithAI(conversationID uint, userID uint, content string) (*models.Message, *models.Message, error) { + // Check if conversation exists and belongs to user + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, nil, fmt.Errorf("failed to get conversation: %w", err) + } + + // Create user message + userMessage := models.Message{ + ConversationID: conversationID, + UserID: userID, + Content: content, + Type: "text", + Status: "sent", + IsAI: false, + } + + if err := s.db.Create(&userMessage).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create user message") + return nil, nil, fmt.Errorf("failed to create user message: %w", err) + } + + // Get conversation history + var messages []models.Message + if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Find(&messages).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation history") + return nil, nil, fmt.Errorf("failed to get conversation history: %w", err) + } + + // Analyze complexity of the message + complexity := s.aiService.AnalyzeComplexity(content) + + // Get AI response + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + aiResponse, err := s.aiService.Query(ctx, content, messages, complexity) + if err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get AI response") + return &userMessage, nil, fmt.Errorf("failed to get AI response: %w", err) + } + + // Create AI message + aiMessage := models.Message{ + ConversationID: conversationID, + UserID: userID, // AI responses are associated with the user who asked the question + Content: aiResponse, + Type: "text", + Status: "sent", + IsAI: true, + AIModel: "gpt-4", // This should be determined based on which AI model was used + } + + if err := s.db.Create(&aiMessage).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to create AI message") + return &userMessage, nil, fmt.Errorf("failed to create AI message: %w", err) + } + + logger.WithFields(map[string]interface{}{ + "conversation_id": conversationID, + "user_message_id": userMessage.ID, + "ai_message_id": aiMessage.ID, + }).Info("AI response created successfully") + + return &userMessage, &aiMessage, nil +} + +// GetConversationStats retrieves statistics for a conversation +func (s *ConversationService) GetConversationStats(conversationID uint, userID uint) (*models.ConversationStats, error) { + // Check if conversation exists and belongs to user + var conversation models.Conversation + if err := s.db.Where("id = ? AND user_id = ?", conversationID, userID).First(&conversation).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("conversation not found") + } + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get conversation") + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + + var stats models.ConversationStats + + // Get total messages count + if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Count(&stats.TotalMessages).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to count messages") + return nil, fmt.Errorf("failed to count messages: %w", err) + } + + // Get average sentiment + var avgSentiment sql.NullFloat64 + if err := s.db.Model(&models.Message{}).Where("conversation_id = ?", conversationID).Select("AVG(sentiment)").Scan(&avgSentiment).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to calculate average sentiment") + return nil, fmt.Errorf("failed to calculate average sentiment: %w", err) + } + if avgSentiment.Valid { + stats.AverageSentiment = avgSentiment.Float64 + } + + // Get first and last message timestamps + var firstMessage, lastMessage models.Message + if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").First(&firstMessage).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get first message") + return nil, fmt.Errorf("failed to get first message: %w", err) + } + stats.FirstMessageAt = firstMessage.CreatedAt + + if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at DESC").First(&lastMessage).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get last message") + return nil, fmt.Errorf("failed to get last message: %w", err) + } + stats.LastMessageAt = lastMessage.CreatedAt + + // Calculate average response time (time between user messages and AI responses) + // This is a simplified calculation and could be improved + if stats.TotalMessages > 1 { + var responseTimes []int64 + var prevMessage models.Message + isUserMessage := false + + var allMessages []models.Message + if err := s.db.Where("conversation_id = ?", conversationID).Order("created_at ASC").Find(&allMessages).Error; err != nil { + logger.WithError(err).WithField("conversation_id", conversationID).Error("Failed to get messages for response time calculation") + return nil, fmt.Errorf("failed to get messages for response time calculation: %w", err) + } + + for _, msg := range allMessages { + if !msg.IsAI { + if isUserMessage { + // Consecutive user messages, skip + prevMessage = msg + continue + } + isUserMessage = true + prevMessage = msg + } else { + if isUserMessage { + // AI response to user message, calculate response time + responseTime := msg.CreatedAt.Sub(prevMessage.CreatedAt).Seconds() + responseTimes = append(responseTimes, int64(responseTime)) + } + isUserMessage = false + } + } + + if len(responseTimes) > 0 { + var totalResponseTime int64 = 0 + for _, rt := range responseTimes { + totalResponseTime += rt + } + stats.ResponseTime = totalResponseTime / int64(len(responseTimes)) + } + } + + return &stats, nil +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..9d04de6 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,217 @@ +package database + +import ( + "fmt" + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "customer-support-system/internal/models" +) + +var DB *gorm.DB + +// Connect initializes the database connection +func Connect() error { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s", + getEnv("DB_HOST", "localhost"), + getEnv("DB_USER", "postgres"), + getEnv("DB_PASSWORD", "postgres"), + getEnv("DB_NAME", "support"), + getEnv("DB_PORT", "5432"), + getEnv("DB_SSLMODE", "disable"), + getEnv("DB_TIMEZONE", "UTC"), + ) + + newLogger := logger.New( + log.New(os.Stdout, "\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: logger.Info, + Colorful: false, + }, + ) + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: newLogger, + }) + + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + log.Println("Database connected successfully") + return nil +} + +// AutoMigrate runs the database migrations +func AutoMigrate() error { + err := DB.AutoMigrate( + &models.User{}, + &models.Conversation{}, + &models.Message{}, + &models.KnowledgeBase{}, + &models.KnowledgeBaseFeedback{}, + &models.AIModel{}, + &models.AIInteraction{}, + &models.AIFallback{}, + ) + + if err != nil { + return fmt.Errorf("failed to run database migrations: %w", err) + } + + log.Println("Database migrations completed successfully") + return nil +} + +// Close closes the database connection +func Close() error { + sqlDB, err := DB.DB() + if err != nil { + return fmt.Errorf("failed to get database instance: %w", err) + } + + err = sqlDB.Close() + if err != nil { + return fmt.Errorf("failed to close database connection: %w", err) + } + + log.Println("Database connection closed successfully") + return nil +} + +// GetDB returns the database instance +func GetDB() *gorm.DB { + return DB +} + +// getEnv gets an environment variable with a default value +func getEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// SeedDatabase seeds the database with initial data +func SeedDatabase() error { + // Create default admin user if not exists + var userCount int64 + if err := DB.Model(&models.User{}).Where("role = ?", "admin").Count(&userCount).Error; err != nil { + return fmt.Errorf("failed to count admin users: %w", err) + } + + if userCount == 0 { + adminUser := models.User{ + Username: "admin", + Email: "admin@example.com", + Password: "admin123", + FirstName: "Admin", + LastName: "User", + Role: "admin", + Active: true, + } + + if err := DB.Create(&adminUser).Error; err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + log.Println("Default admin user created") + } + + // Create default AI models if not exists + var aiModelCount int64 + if err := DB.Model(&models.AIModel{}).Count(&aiModelCount).Error; err != nil { + return fmt.Errorf("failed to count AI models: %w", err) + } + + if aiModelCount == 0 { + // Create OpenAI GPT-4 model + openAIModel := models.AIModel{ + Name: "OpenAI GPT-4", + Type: "openai", + Model: "gpt-4", + MaxTokens: 4000, + Temperature: 0.7, + TopP: 1.0, + Active: true, + Priority: 2, + Description: "OpenAI's GPT-4 model for complex queries", + } + + if err := DB.Create(&openAIModel).Error; err != nil { + return fmt.Errorf("failed to create OpenAI model: %w", err) + } + + // Create Local LLaMA model + localModel := models.AIModel{ + Name: "Local LLaMA", + Type: "local", + Model: "llama2", + Endpoint: "http://localhost:11434", + MaxTokens: 2000, + Temperature: 0.7, + TopP: 1.0, + Active: true, + Priority: 1, + Description: "Local LLaMA model for simple queries", + } + + if err := DB.Create(&localModel).Error; err != nil { + return fmt.Errorf("failed to create local model: %w", err) + } + + log.Println("Default AI models created") + } + + // Create sample knowledge base entries if not exists + var kbCount int64 + if err := DB.Model(&models.KnowledgeBase{}).Count(&kbCount).Error; err != nil { + return fmt.Errorf("failed to count knowledge base entries: %w", err) + } + + if kbCount == 0 { + sampleEntries := []models.KnowledgeBase{ + { + Question: "How do I reset my password?", + Answer: "To reset your password, click on the 'Forgot Password' link on the login page. Enter your email address and follow the instructions sent to your inbox.", + Category: "Account", + Tags: "password,reset,account", + Priority: 5, + Active: true, + }, + { + Question: "What payment methods do you accept?", + Answer: "We accept all major credit cards including Visa, Mastercard, and American Express. We also support payments through PayPal and bank transfers.", + Category: "Billing", + Tags: "payment,billing,methods", + Priority: 4, + Active: true, + }, + { + Question: "How can I contact customer support?", + Answer: "You can contact our customer support team through the chat feature on our website, by emailing support@example.com, or by calling our toll-free number at 1-800-123-4567.", + Category: "Support", + Tags: "contact,support,help", + Priority: 3, + Active: true, + }, + } + + for _, entry := range sampleEntries { + if err := DB.Create(&entry).Error; err != nil { + return fmt.Errorf("failed to create knowledge base entry: %w", err) + } + } + + log.Println("Sample knowledge base entries created") + } + + return nil +} diff --git a/backend/internal/handlers/ai.go b/backend/internal/handlers/ai.go new file mode 100644 index 0000000..90f853c --- /dev/null +++ b/backend/internal/handlers/ai.go @@ -0,0 +1,211 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gin-gonic/gin" + + "customer-support-system/internal/ai" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// AIHandler handles AI-related HTTP requests +type AIHandler struct { + aiService *ai.AIService +} + +// NewAIHandler creates a new AI handler +func NewAIHandler() *AIHandler { + return &AIHandler{ + aiService: ai.NewAIService(), + } +} + +// QueryAI handles querying the AI service +func (h *AIHandler) QueryAI(c *gin.Context) { + // Check if user is authenticated + _, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req struct { + Prompt string `json:"prompt" binding:"required"` + ConversationHistory []models.Message `json:"conversationHistory"` + Complexity int `json:"complexity"` + } + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse query AI request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // If complexity is not provided, analyze it + if req.Complexity == 0 { + req.Complexity = h.aiService.AnalyzeComplexity(req.Prompt) + } + + // Query AI + ctx := context.Background() + response, err := h.aiService.Query(ctx, req.Prompt, req.ConversationHistory, req.Complexity) + if err != nil { + logger.WithError(err).Error("Failed to query AI") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to query AI", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "AI query successful", + "data": gin.H{ + "response": response, + "complexity": req.Complexity, + }, + }) +} + +// AnalyzeComplexity handles analyzing the complexity of a prompt +func (h *AIHandler) AnalyzeComplexity(c *gin.Context) { + var req struct { + Prompt string `json:"prompt" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse analyze complexity request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Analyze complexity + complexity := h.aiService.AnalyzeComplexity(req.Prompt) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Complexity analysis successful", + "data": gin.H{ + "complexity": complexity, + }, + }) +} + +// GetAvailableModels handles getting available AI models +func (h *AIHandler) GetAvailableModels(c *gin.Context) { + // Get available models + models := h.aiService.GetAvailableModels() + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "models": models, + }, + }) +} + +// QueryOpenAI handles querying the OpenAI API +func (h *AIHandler) QueryOpenAI(c *gin.Context) { + // Check if user is authenticated + _, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req struct { + Prompt string `json:"prompt" binding:"required"` + ConversationHistory []models.Message `json:"conversationHistory"` + } + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse query OpenAI request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Query OpenAI + ctx := context.Background() + response, err := h.aiService.QueryOpenAI(ctx, req.Prompt, req.ConversationHistory) + if err != nil { + logger.WithError(err).Error("Failed to query OpenAI") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to query OpenAI", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "OpenAI query successful", + "data": gin.H{ + "response": response, + }, + }) +} + +// QueryOllama handles querying the Ollama API +func (h *AIHandler) QueryOllama(c *gin.Context) { + // Check if user is authenticated + _, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req struct { + Prompt string `json:"prompt" binding:"required"` + ConversationHistory []models.Message `json:"conversationHistory"` + } + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse query Ollama request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Query Ollama + ctx := context.Background() + response, err := h.aiService.QueryOllama(ctx, req.Prompt, req.ConversationHistory) + if err != nil { + logger.WithError(err).Error("Failed to query Ollama") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to query Ollama", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Ollama query successful", + "data": gin.H{ + "response": response, + }, + }) +} diff --git a/backend/internal/handlers/conversation.go b/backend/internal/handlers/conversation.go new file mode 100644 index 0000000..3984438 --- /dev/null +++ b/backend/internal/handlers/conversation.go @@ -0,0 +1,558 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "customer-support-system/internal/conversation" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// ConversationHandler handles conversation-related HTTP requests +type ConversationHandler struct { + conversationService *conversation.ConversationService +} + +// NewConversationHandler creates a new conversation handler +func NewConversationHandler() *ConversationHandler { + return &ConversationHandler{ + conversationService: conversation.NewConversationService(), + } +} + +// CreateConversation handles creating a new conversation +func (h *ConversationHandler) CreateConversation(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req models.CreateConversationRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse create conversation request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Create conversation + conv, err := h.conversationService.CreateConversation(userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to create conversation") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to create conversation", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "Conversation created successfully", + "data": gin.H{ + "conversation": conv, + }, + }) +} + +// GetConversation handles getting a conversation by ID +func (h *ConversationHandler) GetConversation(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + // Get conversation + conv, err := h.conversationService.GetConversation(uint(conversationID), userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to get conversation") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get conversation", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "conversation": conv, + }, + }) +} + +// ListConversations handles listing conversations for a user +func (h *ConversationHandler) ListConversations(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + status := c.DefaultQuery("status", "") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + // List conversations + response, err := h.conversationService.ListConversations(userID.(uint), page, pageSize, status) + if err != nil { + logger.WithError(err).Error("Failed to list conversations") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to list conversations", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "conversations": response.Conversations, + "total": response.Total, + "page": response.Page, + "pageSize": response.PageSize, + }, + }) +} + +// UpdateConversation handles updating a conversation +func (h *ConversationHandler) UpdateConversation(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + var req models.UpdateConversationRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse update conversation request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Update conversation + conv, err := h.conversationService.UpdateConversation(uint(conversationID), userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to update conversation") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update conversation", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Conversation updated successfully", + "data": gin.H{ + "conversation": conv, + }, + }) +} + +// DeleteConversation handles deleting a conversation +func (h *ConversationHandler) DeleteConversation(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + // Delete conversation + err = h.conversationService.DeleteConversation(uint(conversationID), userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to delete conversation") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete conversation", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Conversation deleted successfully", + }) +} + +// CreateMessage handles creating a new message in a conversation +func (h *ConversationHandler) CreateMessage(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + var req models.CreateMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse create message request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Create message + message, err := h.conversationService.CreateMessage(uint(conversationID), userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to create message") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to create message", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "Message created successfully", + "data": gin.H{ + "message": message, + }, + }) +} + +// GetMessages handles getting messages in a conversation +func (h *ConversationHandler) GetMessages(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + // Get messages + response, err := h.conversationService.GetMessages(uint(conversationID), userID.(uint), page, pageSize) + if err != nil { + logger.WithError(err).Error("Failed to get messages") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get messages", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "messages": response.Messages, + "total": response.Total, + "page": response.Page, + "pageSize": response.PageSize, + }, + }) +} + +// UpdateMessage handles updating a message +func (h *ConversationHandler) UpdateMessage(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get message ID from URL params + messageID, err := strconv.ParseUint(c.Param("messageId"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse message ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid message ID", + }) + return + } + + var req models.UpdateMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse update message request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Update message + message, err := h.conversationService.UpdateMessage(uint(messageID), userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to update message") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update message", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Message updated successfully", + "data": gin.H{ + "message": message, + }, + }) +} + +// DeleteMessage handles deleting a message +func (h *ConversationHandler) DeleteMessage(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get message ID from URL params + messageID, err := strconv.ParseUint(c.Param("messageId"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse message ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid message ID", + }) + return + } + + // Delete message + err = h.conversationService.DeleteMessage(uint(messageID), userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to delete message") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete message", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Message deleted successfully", + }) +} + +// SendMessageWithAI handles sending a message and getting an AI response +func (h *ConversationHandler) SendMessageWithAI(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + var req struct { + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse send message request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Send message and get AI response + userMessage, aiMessage, err := h.conversationService.SendMessageWithAI(uint(conversationID), userID.(uint), req.Content) + if err != nil { + logger.WithError(err).Error("Failed to send message with AI") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to send message with AI", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "Message sent and AI response received", + "data": gin.H{ + "userMessage": userMessage, + "aiMessage": aiMessage, + }, + }) +} + +// GetConversationStats handles getting statistics for a conversation +func (h *ConversationHandler) GetConversationStats(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get conversation ID from URL params + conversationID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse conversation ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid conversation ID", + }) + return + } + + // Get conversation stats + stats, err := h.conversationService.GetConversationStats(uint(conversationID), userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to get conversation stats") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get conversation stats", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "stats": stats, + }, + }) +} diff --git a/backend/internal/handlers/knowledge.go b/backend/internal/handlers/knowledge.go new file mode 100644 index 0000000..bf1a3e2 --- /dev/null +++ b/backend/internal/handlers/knowledge.go @@ -0,0 +1,475 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "customer-support-system/internal/knowledge" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// KnowledgeHandler handles knowledge base-related HTTP requests +type KnowledgeHandler struct { + knowledgeService *knowledge.KnowledgeService +} + +// NewKnowledgeHandler creates a new knowledge handler +func NewKnowledgeHandler() *KnowledgeHandler { + return &KnowledgeHandler{ + knowledgeService: knowledge.NewKnowledgeService(), + } +} + +// CreateKnowledgeEntry handles creating a new knowledge base entry +func (h *KnowledgeHandler) CreateKnowledgeEntry(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req models.CreateKnowledgeBaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse create knowledge entry request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Create knowledge entry + entry, err := h.knowledgeService.CreateKnowledgeEntry(userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to create knowledge entry") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to create knowledge entry", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "Knowledge entry created successfully", + "data": gin.H{ + "entry": entry, + }, + }) +} + +// GetKnowledgeEntry handles getting a knowledge base entry by ID +func (h *KnowledgeHandler) GetKnowledgeEntry(c *gin.Context) { + // Get knowledge entry ID from URL params + entryID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse knowledge entry ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid knowledge entry ID", + }) + return + } + + // Get knowledge entry + entry, err := h.knowledgeService.GetKnowledgeEntry(uint(entryID)) + if err != nil { + logger.WithError(err).Error("Failed to get knowledge entry") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get knowledge entry", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "entry": entry, + }, + }) +} + +// ListKnowledgeEntries handles listing knowledge base entries +func (h *KnowledgeHandler) ListKnowledgeEntries(c *gin.Context) { + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + category := c.DefaultQuery("category", "") + activeStr := c.DefaultQuery("active", "true") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + // Parse active parameter + var active *bool + if activeStr != "" { + activeVal := activeStr == "true" + active = &activeVal + } + + // List knowledge entries + response, err := h.knowledgeService.ListKnowledgeEntries(page, pageSize, category, active) + if err != nil { + logger.WithError(err).Error("Failed to list knowledge entries") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to list knowledge entries", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "entries": response.Entries, + "total": response.Total, + "page": response.Page, + "pageSize": response.PageSize, + }, + }) +} + +// UpdateKnowledgeEntry handles updating a knowledge base entry +func (h *KnowledgeHandler) UpdateKnowledgeEntry(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get knowledge entry ID from URL params + entryID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse knowledge entry ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid knowledge entry ID", + }) + return + } + + var req models.UpdateKnowledgeBaseRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse update knowledge entry request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Update knowledge entry + entry, err := h.knowledgeService.UpdateKnowledgeEntry(uint(entryID), userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to update knowledge entry") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update knowledge entry", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Knowledge entry updated successfully", + "data": gin.H{ + "entry": entry, + }, + }) +} + +// DeleteKnowledgeEntry handles deleting a knowledge base entry +func (h *KnowledgeHandler) DeleteKnowledgeEntry(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get knowledge entry ID from URL params + entryID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse knowledge entry ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid knowledge entry ID", + }) + return + } + + // Delete knowledge entry + err = h.knowledgeService.DeleteKnowledgeEntry(uint(entryID), userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to delete knowledge entry") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete knowledge entry", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Knowledge entry deleted successfully", + }) +} + +// SearchKnowledge handles searching knowledge base entries +func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) { + // Parse query parameters + query := c.DefaultQuery("query", "") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + category := c.DefaultQuery("category", "") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + // Search knowledge entries + response, err := h.knowledgeService.SearchKnowledge(query, page, pageSize, category) + if err != nil { + logger.WithError(err).Error("Failed to search knowledge entries") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to search knowledge entries", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "results": response.Results, + "total": response.Total, + "query": response.Query, + "page": response.Page, + "pageSize": response.PageSize, + }, + }) +} + +// GetCategories handles getting all unique categories in the knowledge base +func (h *KnowledgeHandler) GetCategories(c *gin.Context) { + // Get categories + categories, err := h.knowledgeService.GetCategories() + if err != nil { + logger.WithError(err).Error("Failed to get knowledge categories") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get knowledge categories", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "categories": categories, + }, + }) +} + +// GetTags handles getting all unique tags in the knowledge base +func (h *KnowledgeHandler) GetTags(c *gin.Context) { + // Get tags + tags, err := h.knowledgeService.GetTags() + if err != nil { + logger.WithError(err).Error("Failed to get knowledge tags") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get knowledge tags", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "tags": tags, + }, + }) +} + +// RateKnowledgeEntry handles rating a knowledge base entry as helpful or not +func (h *KnowledgeHandler) RateKnowledgeEntry(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get knowledge entry ID from URL params + entryID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse knowledge entry ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid knowledge entry ID", + }) + return + } + + var req models.CreateKnowledgeBaseFeedbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse rate knowledge entry request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Rate knowledge entry + err = h.knowledgeService.RateKnowledgeEntry(uint(entryID), userID.(uint), req.Helpful, req.Comment) + if err != nil { + logger.WithError(err).Error("Failed to rate knowledge entry") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to rate knowledge entry", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Knowledge entry rated successfully", + }) +} + +// GetPopularKnowledge handles getting popular knowledge base entries +func (h *KnowledgeHandler) GetPopularKnowledge(c *gin.Context) { + // Parse query parameters + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + if limit < 1 || limit > 100 { + limit = 10 + } + + // Get popular knowledge entries + entries, err := h.knowledgeService.GetPopularKnowledge(limit) + if err != nil { + logger.WithError(err).Error("Failed to get popular knowledge entries") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get popular knowledge entries", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "entries": entries, + }, + }) +} + +// GetRecentKnowledge handles getting recent knowledge base entries +func (h *KnowledgeHandler) GetRecentKnowledge(c *gin.Context) { + // Parse query parameters + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + if limit < 1 || limit > 100 { + limit = 10 + } + + // Get recent knowledge entries + entries, err := h.knowledgeService.GetRecentKnowledge(limit) + if err != nil { + logger.WithError(err).Error("Failed to get recent knowledge entries") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get recent knowledge entries", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "entries": entries, + }, + }) +} + +// FindBestMatch handles finding the best matching knowledge base entry for a query +func (h *KnowledgeHandler) FindBestMatch(c *gin.Context) { + // Parse query parameters + query := c.DefaultQuery("query", "") + if query == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Query parameter is required", + }) + return + } + + // Find best match + entry, err := h.knowledgeService.FindBestMatch(query) + if err != nil { + logger.WithError(err).Error("Failed to find best match") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to find best match", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "entry": entry, + }, + }) +} + +// GetKnowledgeStats handles getting statistics for the knowledge base +func (h *KnowledgeHandler) GetKnowledgeStats(c *gin.Context) { + // Get knowledge stats + stats, err := h.knowledgeService.GetKnowledgeStats() + if err != nil { + logger.WithError(err).Error("Failed to get knowledge stats") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get knowledge stats", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "stats": stats, + }, + }) +} diff --git a/backend/internal/handlers/user.go b/backend/internal/handlers/user.go new file mode 100644 index 0000000..0588648 --- /dev/null +++ b/backend/internal/handlers/user.go @@ -0,0 +1,453 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "customer-support-system/internal/auth" + "customer-support-system/internal/database" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// UserHandler handles user-related HTTP requests +type UserHandler struct { + authService *auth.AuthService +} + +// NewUserHandler creates a new user handler +func NewUserHandler() *UserHandler { + return &UserHandler{ + authService: auth.NewAuthService(database.GetDB()), + } +} + +// Register handles user registration +func (h *UserHandler) Register(c *gin.Context) { + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse register request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Validate request + if err := validateRegisterRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // Create user + user, err := h.authService.Register(&req) + if err != nil { + logger.WithError(err).Error("Failed to register user") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to register user", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "success": true, + "message": "User registered successfully", + "data": gin.H{ + "user": user, + }, + }) +} + +// Login handles user login +func (h *UserHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse login request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Validate request + if err := validateLoginRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // Get client IP + clientIP := c.ClientIP() + + // Authenticate user + response, err := h.authService.Login(req.Username, req.Password, clientIP) + if err != nil { + logger.WithError(err).Error("Failed to login user") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Invalid username or password", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "User logged in successfully", + "data": gin.H{ + "user": response.User, + "token": response.Token, + }, + }) +} + +// GetProfile handles getting user profile +func (h *UserHandler) GetProfile(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Get user profile + user, err := h.authService.GetUserByID(userID.(uint)) + if err != nil { + logger.WithError(err).Error("Failed to get user profile") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get user profile", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "user": user.ToSafeUser(), + }, + }) +} + +// UpdateProfile handles updating user profile +func (h *UserHandler) UpdateProfile(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req models.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse update profile request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Update user profile + user, err := h.authService.UpdateUser(userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to update user profile") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update user profile", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "User profile updated successfully", + "data": gin.H{ + "user": user, + }, + }) +} + +// ChangePassword handles changing user password +func (h *UserHandler) ChangePassword(c *gin.Context) { + // Get user ID from context + userID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + var req models.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse change password request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Validate request + if err := validateChangePasswordRequest(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // Change password + err := h.authService.ChangePassword(userID.(uint), &req) + if err != nil { + logger.WithError(err).Error("Failed to change password") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to change password", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Password changed successfully", + }) +} + +// validateRegisterRequest validates the register request +func validateRegisterRequest(req *models.CreateUserRequest) error { + if req.Username == "" { + return fmt.Errorf("username is required") + } + if req.Password == "" { + return fmt.Errorf("password is required") + } + if req.Email == "" { + return fmt.Errorf("email is required") + } + return nil +} + +// validateLoginRequest validates the login request +func validateLoginRequest(req *models.LoginRequest) error { + if req.Username == "" { + return fmt.Errorf("username is required") + } + if req.Password == "" { + return fmt.Errorf("password is required") + } + return nil +} + +// validateChangePasswordRequest validates the change password request +func validateChangePasswordRequest(req *models.ChangePasswordRequest) error { + if req.CurrentPassword == "" { + return fmt.Errorf("current password is required") + } + if req.NewPassword == "" { + return fmt.Errorf("new password is required") + } + return nil +} + +// AdminGetUsers handles getting all users (admin only) +func (h *UserHandler) AdminGetUsers(c *gin.Context) { + // Parse query parameters + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + // Get users + var users []models.User + var total int64 + + db := database.GetDB() + + // Get total count + if err := db.Model(&models.User{}).Count(&total).Error; err != nil { + logger.WithError(err).Error("Failed to count users") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to count users", + }) + return + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { + logger.WithError(err).Error("Failed to get users") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get users", + }) + return + } + + // Convert to safe users + safeUsers := make([]models.SafeUser, len(users)) + for i, user := range users { + safeUsers[i] = user.ToSafeUser() + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "users": safeUsers, + "total": total, + "page": page, + "pageSize": pageSize, + }, + }) +} + +// AdminGetUser handles getting a user by ID (admin only) +func (h *UserHandler) AdminGetUser(c *gin.Context) { + // Get target user ID from URL params + targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse user ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid user ID", + }) + return + } + + // Get user + user, err := h.authService.GetUserByID(uint(targetUserID)) + if err != nil { + logger.WithError(err).Error("Failed to get user") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to get user", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "user": user.ToSafeUser(), + }, + }) +} + +// AdminUpdateUser handles updating a user (admin only) +func (h *UserHandler) AdminUpdateUser(c *gin.Context) { + // Get target user ID from URL params + targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse user ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid user ID", + }) + return + } + + var req models.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.WithError(err).Error("Failed to parse update user request") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request body", + }) + return + } + + // Update user + updatedUser, err := h.authService.UpdateUser(uint(targetUserID), &req) + if err != nil { + logger.WithError(err).Error("Failed to update user") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to update user", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "User updated successfully", + "data": gin.H{ + "user": updatedUser, + }, + }) +} + +// AdminDeleteUser handles deleting a user (admin only) +func (h *UserHandler) AdminDeleteUser(c *gin.Context) { + // Get target user ID from URL params + targetUserID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + logger.WithError(err).Error("Failed to parse user ID") + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid user ID", + }) + return + } + + // Get current user ID from context + currentUserID, exists := c.Get("userID") + if !exists { + logger.Error("Failed to get current user ID from context") + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Unauthorized", + }) + return + } + + // Prevent self-deletion + if currentUserID.(uint) == uint(targetUserID) { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Cannot delete your own account", + }) + return + } + + // Delete user + db := database.GetDB() + if err := db.Delete(&models.User{}, uint(targetUserID)).Error; err != nil { + logger.WithError(err).Error("Failed to delete user") + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to delete user", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "User deleted successfully", + }) +} diff --git a/backend/internal/knowledge/knowledge.go b/backend/internal/knowledge/knowledge.go new file mode 100644 index 0000000..072910e --- /dev/null +++ b/backend/internal/knowledge/knowledge.go @@ -0,0 +1,553 @@ +package knowledge + +import ( + "database/sql" + "fmt" + "strings" + + "gorm.io/gorm" + + "customer-support-system/internal/database" + "customer-support-system/internal/models" + "customer-support-system/pkg/logger" +) + +// KnowledgeService handles knowledge base operations +type KnowledgeService struct { + db *gorm.DB +} + +// NewKnowledgeService creates a new knowledge service +func NewKnowledgeService() *KnowledgeService { + return &KnowledgeService{ + db: database.GetDB(), + } +} + +// CreateKnowledgeEntry creates a new knowledge base entry +func (s *KnowledgeService) CreateKnowledgeEntry(userID uint, req *models.CreateKnowledgeBaseRequest) (*models.KnowledgeBase, error) { + knowledge := models.KnowledgeBase{ + Question: req.Question, + Answer: req.Answer, + Category: req.Category, + Tags: req.Tags, + Priority: req.Priority, + ViewCount: 0, + Helpful: 0, + NotHelpful: 0, + Active: true, + CreatedBy: userID, + UpdatedBy: userID, + } + + if err := s.db.Create(&knowledge).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to create knowledge entry") + return nil, fmt.Errorf("failed to create knowledge entry: %w", err) + } + + logger.WithField("knowledge_id", knowledge.ID).Info("Knowledge entry created successfully") + + return &knowledge, nil +} + +// GetKnowledgeEntry retrieves a knowledge base entry by ID +func (s *KnowledgeService) GetKnowledgeEntry(knowledgeID uint) (*models.KnowledgeBase, error) { + var knowledge models.KnowledgeBase + if err := s.db.First(&knowledge, knowledgeID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("knowledge entry not found") + } + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry") + return nil, fmt.Errorf("failed to get knowledge entry: %w", err) + } + + // Increment view count + s.db.Model(&knowledge).Update("view_count", knowledge.ViewCount+1) + + return &knowledge, nil +} + +// ListKnowledgeEntries retrieves a list of knowledge base entries +func (s *KnowledgeService) ListKnowledgeEntries(page, pageSize int, category string, active *bool) (*models.KnowledgeBaseListResponse, error) { + var knowledge []models.KnowledgeBase + var total int64 + + query := s.db.Model(&models.KnowledgeBase{}) + + if category != "" { + query = query.Where("category = ?", category) + } + + if active != nil { + query = query.Where("active = ?", *active) + } + + // Get total count + if err := query.Count(&total).Error; err != nil { + logger.WithError(err).Error("Failed to count knowledge entries") + return nil, fmt.Errorf("failed to count knowledge entries: %w", err) + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := query.Order("priority DESC, view_count DESC, created_at DESC").Offset(offset).Limit(pageSize).Find(&knowledge).Error; err != nil { + logger.WithError(err).Error("Failed to list knowledge entries") + return nil, fmt.Errorf("failed to list knowledge entries: %w", err) + } + + return &models.KnowledgeBaseListResponse{ + Entries: knowledge, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// UpdateKnowledgeEntry updates a knowledge base entry +func (s *KnowledgeService) UpdateKnowledgeEntry(knowledgeID uint, userID uint, req *models.UpdateKnowledgeBaseRequest) (*models.KnowledgeBase, error) { + var knowledge models.KnowledgeBase + if err := s.db.First(&knowledge, knowledgeID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("knowledge entry not found") + } + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry") + return nil, fmt.Errorf("failed to get knowledge entry: %w", err) + } + + // Check if user is the creator or an admin + if knowledge.CreatedBy != userID { + // Check if user is admin + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to get user") + return nil, fmt.Errorf("failed to get user: %w", err) + } + if user.Role != "admin" { + return nil, fmt.Errorf("unauthorized to update this knowledge entry") + } + } + + // Update fields + if req.Question != "" { + knowledge.Question = req.Question + } + if req.Answer != "" { + knowledge.Answer = req.Answer + } + if req.Category != "" { + knowledge.Category = req.Category + } + if req.Tags != "" { + knowledge.Tags = req.Tags + } + if req.Priority != 0 { + knowledge.Priority = req.Priority + } + if req.Active != nil { + knowledge.Active = *req.Active + } + knowledge.UpdatedBy = userID + + if err := s.db.Save(&knowledge).Error; err != nil { + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to update knowledge entry") + return nil, fmt.Errorf("failed to update knowledge entry: %w", err) + } + + logger.WithField("knowledge_id", knowledgeID).Info("Knowledge entry updated successfully") + + return &knowledge, nil +} + +// DeleteKnowledgeEntry deletes a knowledge base entry +func (s *KnowledgeService) DeleteKnowledgeEntry(knowledgeID uint, userID uint) error { + var knowledge models.KnowledgeBase + if err := s.db.First(&knowledge, knowledgeID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("knowledge entry not found") + } + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry") + return fmt.Errorf("failed to get knowledge entry: %w", err) + } + + // Check if user is the creator or an admin + if knowledge.CreatedBy != userID { + // Check if user is admin + var user models.User + if err := s.db.First(&user, userID).Error; err != nil { + logger.WithError(err).WithField("user_id", userID).Error("Failed to get user") + return fmt.Errorf("failed to get user: %w", err) + } + if user.Role != "admin" { + return fmt.Errorf("unauthorized to delete this knowledge entry") + } + } + + if err := s.db.Delete(&knowledge).Error; err != nil { + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to delete knowledge entry") + return fmt.Errorf("failed to delete knowledge entry: %w", err) + } + + logger.WithField("knowledge_id", knowledgeID).Info("Knowledge entry deleted successfully") + + return nil +} + +// SearchKnowledge searches for knowledge base entries +func (s *KnowledgeService) SearchKnowledge(query string, page, pageSize int, category string) (*models.KnowledgeBaseSearchResponse, error) { + var knowledge []models.KnowledgeBase + var total int64 + + // Build search query + searchQuery := s.db.Model(&models.KnowledgeBase{}) + + if query != "" { + // Search in question, answer, and tags + searchQuery = searchQuery.Where( + "active = ? AND (question ILIKE ? OR answer ILIKE ? OR tags ILIKE ?)", + true, + "%"+query+"%", + "%"+query+"%", + "%"+query+"%", + ) + } else { + searchQuery = searchQuery.Where("active = ?", true) + } + + if category != "" { + searchQuery = searchQuery.Where("category = ?", category) + } + + // Get total count + if err := searchQuery.Count(&total).Error; err != nil { + logger.WithError(err).WithField("query", query).Error("Failed to count knowledge entries") + return nil, fmt.Errorf("failed to count knowledge entries: %w", err) + } + + // Get paginated results + offset := (page - 1) * pageSize + if err := searchQuery.Order("priority DESC, view_count DESC, created_at DESC").Offset(offset).Limit(pageSize).Find(&knowledge).Error; err != nil { + logger.WithError(err).WithField("query", query).Error("Failed to search knowledge entries") + return nil, fmt.Errorf("failed to search knowledge entries: %w", err) + } + + // Convert to search results with relevance scores + results := make([]models.KnowledgeBaseSearchResult, len(knowledge)) + for i, entry := range knowledge { + results[i] = models.KnowledgeBaseSearchResult{ + KnowledgeBase: entry, + RelevanceScore: s.calculateRelevanceScore(query, entry), + MatchedFields: s.getMatchedFields(query, entry), + } + } + + return &models.KnowledgeBaseSearchResponse{ + Results: results, + Total: total, + Query: query, + Page: page, + PageSize: pageSize, + }, nil +} + +// GetCategories retrieves all unique categories in the knowledge base +func (s *KnowledgeService) GetCategories() ([]string, error) { + var categories []string + if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Distinct().Pluck("category", &categories).Error; err != nil { + logger.WithError(err).Error("Failed to get knowledge categories") + return nil, fmt.Errorf("failed to get knowledge categories: %w", err) + } + return categories, nil +} + +// GetTags retrieves all unique tags in the knowledge base +func (s *KnowledgeService) GetTags() ([]string, error) { + var tags []string + if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Find(&[]models.KnowledgeBase{}).Error; err != nil { + logger.WithError(err).Error("Failed to get knowledge entries for tags") + return nil, fmt.Errorf("failed to get knowledge entries for tags: %w", err) + } + + // Extract and deduplicate tags + tagMap := make(map[string]bool) + for _, entry := range []models.KnowledgeBase{} { + if entry.Tags != "" { + entryTags := strings.Split(entry.Tags, ",") + for _, tag := range entryTags { + tag = strings.TrimSpace(tag) + if tag != "" { + tagMap[tag] = true + } + } + } + } + + // Convert map to slice + tags = make([]string, 0, len(tagMap)) + for tag := range tagMap { + tags = append(tags, tag) + } + + return tags, nil +} + +// RateKnowledgeEntry rates a knowledge base entry as helpful or not +func (s *KnowledgeService) RateKnowledgeEntry(knowledgeID uint, userID uint, helpful bool, comment string) error { + var knowledge models.KnowledgeBase + if err := s.db.First(&knowledge, knowledgeID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("knowledge entry not found") + } + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to get knowledge entry") + return fmt.Errorf("failed to get knowledge entry: %w", err) + } + + // Create feedback record + feedback := models.KnowledgeBaseFeedback{ + KnowledgeBaseID: knowledgeID, + UserID: userID, + Helpful: helpful, + Comment: comment, + } + + if err := s.db.Create(&feedback).Error; err != nil { + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to create knowledge feedback") + return fmt.Errorf("failed to create knowledge feedback: %w", err) + } + + // Update helpful/not helpful counts + if helpful { + knowledge.Helpful++ + } else { + knowledge.NotHelpful++ + } + + if err := s.db.Save(&knowledge).Error; err != nil { + logger.WithError(err).WithField("knowledge_id", knowledgeID).Error("Failed to rate knowledge entry") + return fmt.Errorf("failed to rate knowledge entry: %w", err) + } + + logger.WithFields(map[string]interface{}{ + "knowledge_id": knowledgeID, + "user_id": userID, + "helpful": helpful, + }).Info("Knowledge entry rated successfully") + + return nil +} + +// GetPopularKnowledge retrieves popular knowledge base entries +func (s *KnowledgeService) GetPopularKnowledge(limit int) ([]models.KnowledgeBase, error) { + var knowledge []models.KnowledgeBase + if err := s.db.Where("active = ?", true). + Order("view_count DESC, helpful DESC"). + Limit(limit). + Find(&knowledge).Error; err != nil { + logger.WithError(err).Error("Failed to get popular knowledge entries") + return nil, fmt.Errorf("failed to get popular knowledge entries: %w", err) + } + return knowledge, nil +} + +// GetRecentKnowledge retrieves recent knowledge base entries +func (s *KnowledgeService) GetRecentKnowledge(limit int) ([]models.KnowledgeBase, error) { + var knowledge []models.KnowledgeBase + if err := s.db.Where("active = ?", true). + Order("created_at DESC"). + Limit(limit). + Find(&knowledge).Error; err != nil { + logger.WithError(err).Error("Failed to get recent knowledge entries") + return nil, fmt.Errorf("failed to get recent knowledge entries: %w", err) + } + return knowledge, nil +} + +// FindBestMatch finds the best matching knowledge base entry for a query +func (s *KnowledgeService) FindBestMatch(query string) (*models.KnowledgeBase, error) { + // This is a simple implementation that looks for exact matches in tags + // In a real implementation, this would use more sophisticated algorithms like TF-IDF or embeddings + + var knowledge models.KnowledgeBase + if err := s.db.Where("active = ? AND tags ILIKE ?", true, "%"+query+"%"). + Order("priority DESC, view_count DESC, helpful DESC"). + First(&knowledge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Try to find a match in the question + if err := s.db.Where("active = ? AND question ILIKE ?", true, "%"+query+"%"). + Order("priority DESC, view_count DESC, helpful DESC"). + First(&knowledge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Try to find a match in the answer + if err := s.db.Where("active = ? AND answer ILIKE ?", true, "%"+query+"%"). + Order("priority DESC, view_count DESC, helpful DESC"). + First(&knowledge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("no matching knowledge entry found") + } + logger.WithError(err).WithField("query", query).Error("Failed to find best match in answer") + return nil, fmt.Errorf("failed to find best match in answer: %w", err) + } + } + logger.WithError(err).WithField("query", query).Error("Failed to find best match in question") + return nil, fmt.Errorf("failed to find best match in question: %w", err) + } + } + logger.WithError(err).WithField("query", query).Error("Failed to find best match in tags") + return nil, fmt.Errorf("failed to find best match in tags: %w", err) + } + + return &knowledge, nil +} + +// GetKnowledgeStats retrieves statistics for the knowledge base +func (s *KnowledgeService) GetKnowledgeStats() (*models.KnowledgeBaseStats, error) { + var stats models.KnowledgeBaseStats + + // Get total entries + if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Count(&stats.TotalEntries).Error; err != nil { + logger.WithError(err).Error("Failed to count knowledge entries") + return nil, fmt.Errorf("failed to count knowledge entries: %w", err) + } + + // Get total views + var totalViews sql.NullInt64 + if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ?", true).Select("SUM(view_count)").Scan(&totalViews).Error; err != nil { + logger.WithError(err).Error("Failed to calculate total views") + return nil, fmt.Errorf("failed to calculate total views: %w", err) + } + if totalViews.Valid { + stats.TotalViews = totalViews.Int64 + } + + // Get average helpful percentage + var avgHelpful sql.NullFloat64 + if err := s.db.Model(&models.KnowledgeBase{}).Where("active = ? AND helpful + not_helpful > 0", true). + Select("AVG(helpful::float / (helpful + not_helpful)::float * 100)").Scan(&avgHelpful).Error; err != nil { + logger.WithError(err).Error("Failed to calculate average helpful percentage") + return nil, fmt.Errorf("failed to calculate average helpful percentage: %w", err) + } + if avgHelpful.Valid { + stats.AverageHelpful = avgHelpful.Float64 + } + + // Get top categories + var categoryStats []models.CategoryStat + if err := s.db.Model(&models.KnowledgeBase{}). + Where("active = ?", true). + Select("category, COUNT(*) as count"). + Group("category"). + Order("count DESC"). + Limit(5). + Scan(&categoryStats).Error; err != nil { + logger.WithError(err).Error("Failed to get category statistics") + return nil, fmt.Errorf("failed to get category statistics: %w", err) + } + stats.TopCategories = categoryStats + + // Get top tags + var tagStats []models.TagStat + if err := s.db.Model(&models.KnowledgeBase{}). + Where("active = ? AND tags != ''", true). + Select("unnest(string_to_array(tags, ',')) as tag, COUNT(*) as count"). + Group("tag"). + Order("count DESC"). + Limit(10). + Scan(&tagStats).Error; err != nil { + logger.WithError(err).Error("Failed to get tag statistics") + return nil, fmt.Errorf("failed to get tag statistics: %w", err) + } + stats.TopTags = tagStats + + return &stats, nil +} + +// calculateRelevanceScore calculates a relevance score for a knowledge base entry against a query +func (s *KnowledgeService) calculateRelevanceScore(query string, entry models.KnowledgeBase) float64 { + if query == "" { + return 0 + } + + query = strings.ToLower(query) + score := 0.0 + + // Check for exact match in question + if strings.Contains(strings.ToLower(entry.Question), query) { + score += 10.0 + } + + // Check for exact match in answer + if strings.Contains(strings.ToLower(entry.Answer), query) { + score += 5.0 + } + + // Check for exact match in tags + if strings.Contains(strings.ToLower(entry.Tags), query) { + score += 7.0 + } + + // Check for word matches + queryWords := strings.Fields(query) + questionWords := strings.Fields(strings.ToLower(entry.Question)) + answerWords := strings.Fields(strings.ToLower(entry.Answer)) + tagsWords := strings.Fields(strings.ToLower(entry.Tags)) + + for _, word := range queryWords { + // Check question words + for _, qWord := range questionWords { + if strings.EqualFold(word, qWord) { + score += 2.0 + } + } + + // Check answer words + for _, aWord := range answerWords { + if strings.EqualFold(word, aWord) { + score += 1.0 + } + } + + // Check tag words + for _, tWord := range tagsWords { + if strings.EqualFold(word, tWord) { + score += 3.0 + } + } + } + + // Boost score based on priority + score += float64(entry.Priority) * 0.5 + + // Boost score based on helpfulness + if entry.Helpful+entry.NotHelpful > 0 { + helpfulness := float64(entry.Helpful) / float64(entry.Helpful+entry.NotHelpful) + score += helpfulness * 2.0 + } + + return score +} + +// getMatchedFields returns the fields that matched the query +func (s *KnowledgeService) getMatchedFields(query string, entry models.KnowledgeBase) []string { + if query == "" { + return []string{} + } + + query = strings.ToLower(query) + matchedFields := []string{} + + // Check question + if strings.Contains(strings.ToLower(entry.Question), query) { + matchedFields = append(matchedFields, "question") + } + + // Check answer + if strings.Contains(strings.ToLower(entry.Answer), query) { + matchedFields = append(matchedFields, "answer") + } + + // Check tags + if strings.Contains(strings.ToLower(entry.Tags), query) { + matchedFields = append(matchedFields, "tags") + } + + return matchedFields +} diff --git a/backend/internal/models/ai.go b/backend/internal/models/ai.go new file mode 100644 index 0000000..30c8a76 --- /dev/null +++ b/backend/internal/models/ai.go @@ -0,0 +1,127 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// AIModel represents an AI model configuration +type AIModel struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"not null"` + Type string `json:"type" gorm:"not null"` // 'openai', 'local', 'custom' + Endpoint string `json:"endpoint"` + APIKey string `json:"apiKey"` // Encrypted in database + Model string `json:"model"` // e.g., 'gpt-4', 'llama2', etc. + MaxTokens int `json:"maxTokens" gorm:"default:1000"` + Temperature float64 `json:"temperature" gorm:"default:0.7"` + TopP float64 `json:"topP" gorm:"default:1.0"` + Active bool `json:"active" gorm:"default:true"` + Priority int `json:"priority" gorm:"default:1"` // Higher number = higher priority + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// AIInteraction represents an interaction with an AI model +type AIInteraction struct { + ID uint `json:"id" gorm:"primaryKey"` + ConversationID uint `json:"conversationId"` + MessageID uint `json:"messageId"` + AIModelID uint `json:"aiModelId"` + Prompt string `json:"prompt" gorm:"not null"` + Response string `json:"response" gorm:"not null"` + TokensUsed int `json:"tokensUsed"` + ResponseTime int64 `json:"responseTime"` // in milliseconds + Cost float64 `json:"cost"` + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage"` + CreatedAt time.Time `json:"createdAt"` + + // Relationships + Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"` + Message Message `json:"message" gorm:"foreignKey:MessageID"` + AIModel AIModel `json:"aiModel" gorm:"foreignKey:AIModelID"` +} + +// AIFallback represents a fallback event when an AI model fails +type AIFallback struct { + ID uint `json:"id" gorm:"primaryKey"` + ConversationID uint `json:"conversationId"` + MessageID uint `json:"messageId"` + FromAIModelID uint `json:"fromAiModelId"` + ToAIModelID uint `json:"toAiModelId"` + Reason string `json:"reason" gorm:"not null"` + CreatedAt time.Time `json:"createdAt"` + + // Relationships + Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"` + Message Message `json:"message" gorm:"foreignKey:MessageID"` + FromAIModel AIModel `json:"fromAiModel" gorm:"foreignKey:FromAIModelID"` + ToAIModel AIModel `json:"toAiModel" gorm:"foreignKey:ToAIModelID"` +} + + +// BeforeCreate is a GORM hook that validates the AI model before creation +func (a *AIModel) BeforeCreate(tx *gorm.DB) error { + if a.Priority < 1 { + a.Priority = 1 + } + if a.MaxTokens < 1 { + a.MaxTokens = 1000 + } + if a.Temperature < 0 { + a.Temperature = 0 + } else if a.Temperature > 2 { + a.Temperature = 2 + } + if a.TopP < 0 { + a.TopP = 0 + } else if a.TopP > 1 { + a.TopP = 1 + } + return nil +} + +// BeforeUpdate is a GORM hook that validates the AI model before update +func (a *AIModel) BeforeUpdate(tx *gorm.DB) error { + if a.Priority < 1 { + a.Priority = 1 + } + if a.MaxTokens < 1 { + a.MaxTokens = 1000 + } + if a.Temperature < 0 { + a.Temperature = 0 + } else if a.Temperature > 2 { + a.Temperature = 2 + } + if a.TopP < 0 { + a.TopP = 0 + } else if a.TopP > 1 { + a.TopP = 1 + } + return nil +} + +// IsActive checks if the AI model is active +func (a *AIModel) IsActive() bool { + return a.Active +} + +// IsOpenAI checks if the AI model is an OpenAI model +func (a *AIModel) IsOpenAI() bool { + return a.Type == "openai" +} + +// IsLocal checks if the AI model is a local model +func (a *AIModel) IsLocal() bool { + return a.Type == "local" +} + +// IsCustom checks if the AI model is a custom model +func (a *AIModel) IsCustom() bool { + return a.Type == "custom" +} diff --git a/backend/internal/models/conversation.go b/backend/internal/models/conversation.go new file mode 100644 index 0000000..05a6bf8 --- /dev/null +++ b/backend/internal/models/conversation.go @@ -0,0 +1,63 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Conversation represents a chat conversation between users and/or agents +type Conversation struct { + ID uint `json:"id" gorm:"primaryKey"` + Title string `json:"title"` + UserID uint `json:"userId"` + AgentID *uint `json:"agentId,omitempty"` + Status string `json:"status" gorm:"default:'active'"` // 'active', 'closed', 'escalated' + Department string `json:"department"` + Priority string `json:"priority" gorm:"default:'medium'"` // 'low', 'medium', 'high', 'urgent' + Tags string `json:"tags"` // Comma-separated tags + LastMessageAt time.Time `json:"lastMessageAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // Relationships + User User `json:"user" gorm:"foreignKey:UserID"` + Agent *User `json:"agent,omitempty" gorm:"foreignKey:AgentID"` + Messages []Message `json:"messages" gorm:"foreignKey:ConversationID"` +} + +// Message represents a single message in a conversation +type Message struct { + ID uint `json:"id" gorm:"primaryKey"` + ConversationID uint `json:"conversationId"` + UserID uint `json:"userId"` + Content string `json:"content" gorm:"not null"` + Type string `json:"type" gorm:"default:'text'"` // 'text', 'image', 'file', 'system' + Status string `json:"status" gorm:"default:'sent'"` // 'sent', 'delivered', 'read' + Sentiment float64 `json:"sentiment"` // Sentiment score from -1 (negative) to 1 (positive) + IsAI bool `json:"isAI" gorm:"default:false"` + AIModel string `json:"aiModel,omitempty"` // Which AI model generated this message + ReadAt *time.Time `json:"readAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // Relationships + Conversation Conversation `json:"conversation" gorm:"foreignKey:ConversationID"` + User User `json:"user" gorm:"foreignKey:UserID"` +} + + +// BeforeCreate is a GORM hook that sets the LastMessageAt field when creating a conversation +func (c *Conversation) BeforeCreate(tx *gorm.DB) error { + c.LastMessageAt = time.Now() + return nil +} + +// BeforeCreate is a GORM hook that updates the conversation's LastMessageAt when creating a message +func (m *Message) BeforeCreate(tx *gorm.DB) error { + // Update the conversation's LastMessageAt + tx.Model(&Conversation{}).Where("id = ?", m.ConversationID).Update("last_message_at", time.Now()) + return nil +} diff --git a/backend/internal/models/knowledge.go b/backend/internal/models/knowledge.go new file mode 100644 index 0000000..fa5e4b5 --- /dev/null +++ b/backend/internal/models/knowledge.go @@ -0,0 +1,85 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// KnowledgeBase represents a knowledge base entry (FAQ) +type KnowledgeBase struct { + ID uint `json:"id" gorm:"primaryKey"` + Question string `json:"question" gorm:"not null"` + Answer string `json:"answer" gorm:"not null"` + Category string `json:"category" gorm:"not null"` + Tags string `json:"tags"` // Comma-separated tags + Priority int `json:"priority" gorm:"default:1"` // Higher number = higher priority + ViewCount int `json:"viewCount" gorm:"default:0"` + Helpful int `json:"helpful" gorm:"default:0"` // Number of times marked as helpful + NotHelpful int `json:"notHelpful" gorm:"default:0"` // Number of times marked as not helpful + Active bool `json:"active" gorm:"default:true"` + CreatedBy uint `json:"createdBy"` + UpdatedBy uint `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + + // Relationships + Creator User `json:"creator" gorm:"foreignKey:CreatedBy"` + Updater User `json:"updater" gorm:"foreignKey:UpdatedBy"` +} + +// KnowledgeBaseFeedback represents user feedback on a knowledge base entry +type KnowledgeBaseFeedback struct { + ID uint `json:"id" gorm:"primaryKey"` + KnowledgeBaseID uint `json:"knowledgeBaseId" gorm:"not null"` + UserID uint `json:"userId" gorm:"not null"` + Helpful bool `json:"helpful"` // true if helpful, false if not helpful + Comment string `json:"comment"` + CreatedAt time.Time `json:"createdAt"` + + // Relationships + KnowledgeBase KnowledgeBase `json:"knowledgeBase" gorm:"foreignKey:KnowledgeBaseID"` + User User `json:"user" gorm:"foreignKey:UserID"` +} + + +// BeforeCreate is a GORM hook that validates the knowledge base entry before creation +func (k *KnowledgeBase) BeforeCreate(tx *gorm.DB) error { + if k.Priority < 1 { + k.Priority = 1 + } + return nil +} + +// BeforeUpdate is a GORM hook that validates the knowledge base entry before update +func (k *KnowledgeBase) BeforeUpdate(tx *gorm.DB) error { + if k.Priority < 1 { + k.Priority = 1 + } + return nil +} + +// IncrementViewCount increments the view count of a knowledge base entry +func (k *KnowledgeBase) IncrementViewCount(tx *gorm.DB) error { + return tx.Model(k).UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)).Error +} + +// MarkHelpful marks a knowledge base entry as helpful +func (k *KnowledgeBase) MarkHelpful(tx *gorm.DB) error { + return tx.Model(k).UpdateColumn("helpful", gorm.Expr("helpful + ?", 1)).Error +} + +// MarkNotHelpful marks a knowledge base entry as not helpful +func (k *KnowledgeBase) MarkNotHelpful(tx *gorm.DB) error { + return tx.Model(k).UpdateColumn("not_helpful", gorm.Expr("not_helpful + ?", 1)).Error +} + +// GetHelpfulnessPercentage returns the helpfulness percentage +func (k *KnowledgeBase) GetHelpfulnessPercentage() float64 { + total := k.Helpful + k.NotHelpful + if total == 0 { + return 0 + } + return float64(k.Helpful) / float64(total) * 100 +} diff --git a/backend/internal/models/models_test.go b/backend/internal/models/models_test.go new file mode 100644 index 0000000..2e03075 --- /dev/null +++ b/backend/internal/models/models_test.go @@ -0,0 +1,143 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserModel(t *testing.T) { + // Test user model fields + user := User{ + ID: 1, + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + FirstName: "Test", + LastName: "User", + Role: "user", + Active: true, + } + + // Verify user fields + assert.Equal(t, uint(1), user.ID) + assert.Equal(t, "testuser", user.Username) + assert.Equal(t, "test@example.com", user.Email) + assert.Equal(t, "hashedpassword", user.Password) + assert.Equal(t, "Test", user.FirstName) + assert.Equal(t, "User", user.LastName) + assert.Equal(t, "user", user.Role) + assert.True(t, user.Active) +} + +func TestConversationModel(t *testing.T) { + // Test conversation model fields + conversation := Conversation{ + ID: 1, + UserID: 1, + Title: "Test Conversation", + Status: "active", + Department: "support", + Priority: "medium", + Tags: "test,sample", + } + + // Verify conversation fields + assert.Equal(t, uint(1), conversation.ID) + assert.Equal(t, uint(1), conversation.UserID) + assert.Equal(t, "Test Conversation", conversation.Title) + assert.Equal(t, "active", conversation.Status) + assert.Equal(t, "support", conversation.Department) + assert.Equal(t, "medium", conversation.Priority) + assert.Equal(t, "test,sample", conversation.Tags) +} + +func TestMessageModel(t *testing.T) { + // Test message model fields + message := Message{ + ID: 1, + ConversationID: 1, + UserID: 1, + Content: "Test message content", + Type: "text", + Status: "sent", + Sentiment: 0.5, + IsAI: false, + AIModel: "", + } + + // Verify message fields + assert.Equal(t, uint(1), message.ID) + assert.Equal(t, uint(1), message.ConversationID) + assert.Equal(t, uint(1), message.UserID) + assert.Equal(t, "Test message content", message.Content) + assert.Equal(t, "text", message.Type) + assert.Equal(t, "sent", message.Status) + assert.Equal(t, 0.5, message.Sentiment) + assert.False(t, message.IsAI) + assert.Equal(t, "", message.AIModel) +} + +func TestKnowledgeModel(t *testing.T) { + // Test knowledge model fields + knowledge := KnowledgeBase{ + ID: 1, + Question: "Test Question", + Answer: "Test knowledge content", + Category: "general", + Tags: "test,sample", + Priority: 1, + ViewCount: 10, + Helpful: 5, + NotHelpful: 2, + Active: true, + CreatedBy: 1, + UpdatedBy: 1, + } + + // Verify knowledge fields + assert.Equal(t, uint(1), knowledge.ID) + assert.Equal(t, "Test Question", knowledge.Question) + assert.Equal(t, "Test knowledge content", knowledge.Answer) + assert.Equal(t, "general", knowledge.Category) + assert.Equal(t, "test,sample", knowledge.Tags) + assert.Equal(t, 1, knowledge.Priority) + assert.Equal(t, 10, knowledge.ViewCount) + assert.Equal(t, 5, knowledge.Helpful) + assert.Equal(t, 2, knowledge.NotHelpful) + assert.True(t, knowledge.Active) + assert.Equal(t, uint(1), knowledge.CreatedBy) + assert.Equal(t, uint(1), knowledge.UpdatedBy) +} + +func TestAIModel(t *testing.T) { + // Test AI model fields + aiModel := AIModel{ + ID: 1, + Name: "Test AI Model", + Type: "openai", + Endpoint: "https://api.openai.com/v1/chat/completions", + APIKey: "test-api-key", + Model: "gpt-4", + MaxTokens: 4000, + Temperature: 0.7, + TopP: 1.0, + Active: true, + Priority: 1, + Description: "Test AI model description", + } + + // Verify AI model fields + assert.Equal(t, uint(1), aiModel.ID) + assert.Equal(t, "Test AI Model", aiModel.Name) + assert.Equal(t, "openai", aiModel.Type) + assert.Equal(t, "https://api.openai.com/v1/chat/completions", aiModel.Endpoint) + assert.Equal(t, "test-api-key", aiModel.APIKey) + assert.Equal(t, "gpt-4", aiModel.Model) + assert.Equal(t, 4000, aiModel.MaxTokens) + assert.Equal(t, 0.7, aiModel.Temperature) + assert.Equal(t, 1.0, aiModel.TopP) + assert.True(t, aiModel.Active) + assert.Equal(t, 1, aiModel.Priority) + assert.Equal(t, "Test AI model description", aiModel.Description) +} diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go new file mode 100644 index 0000000..d7690af --- /dev/null +++ b/backend/internal/models/requests.go @@ -0,0 +1,261 @@ +package models + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// User Request/Response types +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role"` +} + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` + User SafeUser `json:"user"` +} + +type UpdateUserRequest struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Email string `json:"email" binding:"omitempty,email"` + Active *bool `json:"active"` + Role string `json:"role"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"currentPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required,min=6"` +} + +// Conversation Request/Response types +type CreateConversationRequest struct { + Title string `json:"title" binding:"required,min=1,max=100"` + Department string `json:"department" binding:"required"` + Priority string `json:"priority" binding:"oneof=low medium high urgent"` + Tags string `json:"tags"` +} + +type UpdateConversationRequest struct { + Title string `json:"title"` + Status string `json:"status" binding:"oneof=active closed escalated"` + Department string `json:"department"` + Priority string `json:"priority" binding:"oneof=low medium high urgent"` + Tags string `json:"tags"` + AgentID *uint `json:"agentId"` +} + +type CreateMessageRequest struct { + ConversationID uint `json:"conversationId" binding:"required"` + Content string `json:"content" binding:"required,min=1,max=5000"` + Type string `json:"type" binding:"oneof=text image file system"` +} + +type UpdateMessageRequest struct { + Content string `json:"content" binding:"omitempty,min=1,max=5000"` + Status string `json:"status" binding:"oneof=sent delivered read"` +} + +type ConversationListResponse struct { + Conversations []Conversation `json:"conversations"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type MessageListResponse struct { + Messages []Message `json:"messages"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type ConversationStats struct { + TotalMessages int64 `json:"totalMessages"` + AverageSentiment float64 `json:"averageSentiment"` + ResponseTime int64 `json:"responseTime"` // in seconds + FirstMessageAt time.Time `json:"firstMessageAt"` + LastMessageAt time.Time `json:"lastMessageAt"` +} + +// Knowledge Base Request/Response types +type CreateKnowledgeBaseRequest struct { + Question string `json:"question" binding:"required,min=1,max=500"` + Answer string `json:"answer" binding:"required,min=1,max=5000"` + Category string `json:"category" binding:"required,min=1,max=100"` + Tags string `json:"tags"` + Priority int `json:"priority" binding:"min=1,max=10"` +} + +type UpdateKnowledgeBaseRequest struct { + Question string `json:"question" binding:"omitempty,min=1,max=500"` + Answer string `json:"answer" binding:"omitempty,min=1,max=5000"` + Category string `json:"category" binding:"omitempty,min=1,max=100"` + Tags string `json:"tags"` + Priority int `json:"priority" binding:"omitempty,min=1,max=10"` + Active *bool `json:"active"` +} + +type CreateKnowledgeBaseFeedbackRequest struct { + KnowledgeBaseID uint `json:"knowledgeBaseId" binding:"required"` + Helpful bool `json:"helpful"` + Comment string `json:"comment" binding:"max=500"` +} + +type KnowledgeBaseSearchRequest struct { + Query string `json:"query" binding:"required,min=1,max=100"` + Category string `json:"category"` + Tags string `json:"tags"` + Page int `json:"page" binding:"min=1"` + PageSize int `json:"pageSize" binding:"min=1,max=100"` +} + +type KnowledgeBaseListResponse struct { + Entries []KnowledgeBase `json:"entries"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type KnowledgeBaseSearchResponse struct { + Results []KnowledgeBaseSearchResult `json:"results"` + Total int64 `json:"total"` + Query string `json:"query"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type KnowledgeBaseSearchResult struct { + KnowledgeBase + RelevanceScore float64 `json:"relevanceScore"` + MatchedFields []string `json:"matchedFields"` +} + +type KnowledgeBaseStats struct { + TotalEntries int64 `json:"totalEntries"` + TotalViews int64 `json:"totalViews"` + AverageHelpful float64 `json:"averageHelpful"` // Average helpful rating + TopCategories []CategoryStat `json:"topCategories"` + TopTags []TagStat `json:"topTags"` +} + +type CategoryStat struct { + Category string `json:"category"` + Count int64 `json:"count"` +} + +type TagStat struct { + Tag string `json:"tag"` + Count int64 `json:"count"` +} + +// AI Model Request/Response types +type CreateAIModelRequest struct { + Name string `json:"name" binding:"required,min=1,max=100"` + Type string `json:"type" binding:"required,oneof=openai local custom"` + Endpoint string `json:"endpoint"` + APIKey string `json:"apiKey"` + Model string `json:"model" binding:"required,min=1,max=100"` + MaxTokens int `json:"maxTokens" binding:"min=1,max=100000"` + Temperature float64 `json:"temperature" binding:"min=0,max=2"` + TopP float64 `json:"topP" binding:"min=0,max=1"` + Priority int `json:"priority" binding:"min=1,max=10"` + Description string `json:"description" binding:"max=500"` +} + +type UpdateAIModelRequest struct { + Name string `json:"name" binding:"omitempty,min=1,max=100"` + Type string `json:"type" binding:"omitempty,oneof=openai local custom"` + Endpoint string `json:"endpoint"` + APIKey string `json:"apiKey"` + Model string `json:"model" binding:"omitempty,min=1,max=100"` + MaxTokens int `json:"maxTokens" binding:"omitempty,min=1,max=100000"` + Temperature float64 `json:"temperature" binding:"omitempty,min=0,max=2"` + TopP float64 `json:"topP" binding:"omitempty,min=0,max=1"` + Active *bool `json:"active"` + Priority int `json:"priority" binding:"omitempty,min=1,max=10"` + Description string `json:"description" binding:"omitempty,max=500"` +} + +type AIModelListResponse struct { + Models []AIModel `json:"models"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AIInteractionListResponse struct { + Interactions []AIInteraction `json:"interactions"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AIFallbackListResponse struct { + Fallbacks []AIFallback `json:"fallbacks"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +type AIStats struct { + TotalInteractions int64 `json:"totalInteractions"` + TotalFallbacks int64 `json:"totalFallbacks"` + AverageResponseTime float64 `json:"averageResponseTime"` // in milliseconds + TotalTokensUsed int64 `json:"totalTokensUsed"` + TotalCost float64 `json:"totalCost"` + ModelStats []AIModelStats `json:"modelStats"` + SuccessRate float64 `json:"successRate"` +} + +type AIModelStats struct { + AIModel AIModel `json:"aiModel"` + InteractionsCount int64 `json:"interactionsCount"` + FallbacksCount int64 `json:"fallbacksCount"` + AverageResponseTime float64 `json:"averageResponseTime"` + SuccessRate float64 `json:"successRate"` + TokensUsed int64 `json:"tokensUsed"` + Cost float64 `json:"cost"` +} + +// Common Response types +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Status int `json:"status"` +} + +type SuccessResponse struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` + Status int `json:"status"` +} + +// Response helpers +func NewErrorResponse(c *gin.Context, message string, statusCode int) { + c.JSON(statusCode, ErrorResponse{ + Error: http.StatusText(statusCode), + Message: message, + Status: statusCode, + }) +} + +func NewSuccessResponse(c *gin.Context, message string, data interface{}, statusCode int) { + c.JSON(statusCode, SuccessResponse{ + Message: message, + Data: data, + Status: statusCode, + }) +} \ No newline at end of file diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..2595b83 --- /dev/null +++ b/backend/internal/models/user.go @@ -0,0 +1,69 @@ +package models + +import ( + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// User represents a user in the system +type User struct { + ID uint `json:"id" gorm:"primaryKey"` + Username string `json:"username" gorm:"uniqueIndex;not null"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + Password string `json:"-" gorm:"not null"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role" gorm:"default:'user'"` // 'user', 'agent', 'admin' + Active bool `json:"active" gorm:"default:true"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// BeforeSave is a GORM hook that hashes the password before saving +func (u *User) BeforeSave(tx *gorm.DB) error { + if u.Password != "" { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.Password = string(hashedPassword) + } + return nil +} + +// ComparePassword compares a plaintext password with the user's hashed password +func (u *User) ComparePassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) + return err == nil +} + +// ToSafeUser returns a user object without sensitive information +func (u *User) ToSafeUser() SafeUser { + return SafeUser{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + FirstName: u.FirstName, + LastName: u.LastName, + Role: u.Role, + Active: u.Active, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + +// SafeUser represents user information that can be safely exposed to clients +type SafeUser struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role"` + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..81f756d --- /dev/null +++ b/backend/internal/routes/routes.go @@ -0,0 +1,142 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "customer-support-system/internal/auth" + "customer-support-system/internal/handlers" +) + +// SetupRoutes configures all the routes for the application +func SetupRoutes() *gin.Engine { + // Create a new Gin engine + r := gin.Default() + + // Add CORS middleware + r.Use(CORSMiddleware()) + + // Create handlers + userHandler := handlers.NewUserHandler() + conversationHandler := handlers.NewConversationHandler() + knowledgeHandler := handlers.NewKnowledgeHandler() + aiHandler := handlers.NewAIHandler() + + // Health check endpoint + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + }) + }) + + // API version 1 group + v1 := r.Group("/api/v1") + { + // Public routes (no authentication required) + public := v1.Group("/public") + { + // User authentication routes + public.POST("/register", userHandler.Register) + public.POST("/login", userHandler.Login) + } + + // Protected routes (authentication required) + protected := v1.Group("") + protected.Use(auth.AuthMiddleware()) + { + // User routes + user := protected.Group("/user") + { + user.GET("/profile", userHandler.GetProfile) + user.PUT("/profile", userHandler.UpdateProfile) + user.PUT("/change-password", userHandler.ChangePassword) + } + + // Conversation routes + conversations := protected.Group("/conversations") + { + conversations.GET("", conversationHandler.ListConversations) + conversations.POST("", conversationHandler.CreateConversation) + conversations.GET("/:id", conversationHandler.GetConversation) + conversations.PUT("/:id", conversationHandler.UpdateConversation) + conversations.DELETE("/:id", conversationHandler.DeleteConversation) + conversations.GET("/:id/stats", conversationHandler.GetConversationStats) + + // Message routes + conversations.POST("/:id/messages", conversationHandler.CreateMessage) + conversations.GET("/:id/messages", conversationHandler.GetMessages) + conversations.PUT("/:id/messages/:messageId", conversationHandler.UpdateMessage) + conversations.DELETE("/:id/messages/:messageId", conversationHandler.DeleteMessage) + + // AI interaction routes + conversations.POST("/:id/ai", conversationHandler.SendMessageWithAI) + } + + // Knowledge base routes + knowledge := protected.Group("/knowledge") + { + knowledge.GET("", knowledgeHandler.ListKnowledgeEntries) + knowledge.GET("/search", knowledgeHandler.SearchKnowledge) + knowledge.GET("/categories", knowledgeHandler.GetCategories) + knowledge.GET("/tags", knowledgeHandler.GetTags) + knowledge.GET("/popular", knowledgeHandler.GetPopularKnowledge) + knowledge.GET("/recent", knowledgeHandler.GetRecentKnowledge) + knowledge.GET("/best-match", knowledgeHandler.FindBestMatch) + knowledge.GET("/stats", knowledgeHandler.GetKnowledgeStats) + knowledge.GET("/:id", knowledgeHandler.GetKnowledgeEntry) + knowledge.POST("/:id/rate", knowledgeHandler.RateKnowledgeEntry) + } + + // AI routes + ai := protected.Group("/ai") + { + ai.POST("/query", aiHandler.QueryAI) + ai.POST("/analyze-complexity", aiHandler.AnalyzeComplexity) + ai.GET("/models", aiHandler.GetAvailableModels) + ai.POST("/openai", aiHandler.QueryOpenAI) + ai.POST("/ollama", aiHandler.QueryOllama) + } + + // Admin routes (admin role required) + admin := protected.Group("/admin") + admin.Use(auth.RoleMiddleware("admin")) + { + // User management + admin.GET("/users", userHandler.AdminGetUsers) + admin.GET("/users/:id", userHandler.AdminGetUser) + admin.PUT("/users/:id", userHandler.AdminUpdateUser) + admin.DELETE("/users/:id", userHandler.AdminDeleteUser) + + // Knowledge base management + admin.POST("/knowledge", knowledgeHandler.CreateKnowledgeEntry) + admin.PUT("/knowledge/:id", knowledgeHandler.UpdateKnowledgeEntry) + admin.DELETE("/knowledge/:id", knowledgeHandler.DeleteKnowledgeEntry) + } + + // Agent routes (agent or admin role required) + agent := protected.Group("/agent") + agent.Use(auth.RoleMiddleware("agent", "admin")) + { + // Additional agent-only endpoints can be added here + } + } + } + + return r +} + +// CORSMiddleware adds CORS headers to the response +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/backend/main b/backend/main new file mode 100755 index 0000000..9f57a44 Binary files /dev/null and b/backend/main differ diff --git a/backend/migrate b/backend/migrate new file mode 100755 index 0000000..0e28dad Binary files /dev/null and b/backend/migrate differ diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go new file mode 100644 index 0000000..04a56ac --- /dev/null +++ b/backend/pkg/config/config.go @@ -0,0 +1,205 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + Auth AuthConfig + AI AIConfig + JWT JWTConfig +} + +type ServerConfig struct { + Host string + Port string + ReadTimeout time.Duration + WriteTimeout time.Duration +} + +type DatabaseConfig struct { + Host string + Port string + User string + Password string + DBName string + SSLMode string + TimeZone string +} + +type AuthConfig struct { + JWTSecret string + JWTExpirationHours int + BCryptCost int + MaxLoginAttempts int + AccountLockDuration time.Duration +} + +type AIConfig struct { + OpenAI APIConfig + Local APIConfig +} + +type APIConfig struct { + APIKey string + Endpoint string + Model string + MaxTokens int + Temperature float64 + TopP float64 + Timeout time.Duration +} + +type JWTConfig struct { + Secret string + ExpirationHours int + Issuer string + Audience string +} + +var AppConfig Config + +// LoadConfig loads configuration from environment variables and config file +func LoadConfig() error { + // Set default values + viper.SetDefault("server.host", "0.0.0.0") + viper.SetDefault("server.port", "8080") + viper.SetDefault("server.readTimeout", "15s") + viper.SetDefault("server.writeTimeout", "15s") + + viper.SetDefault("database.host", "localhost") + viper.SetDefault("database.port", "5432") + viper.SetDefault("database.user", "postgres") + viper.SetDefault("database.password", "postgres") + viper.SetDefault("database.dbname", "support") + viper.SetDefault("database.sslmode", "disable") + viper.SetDefault("database.timezone", "UTC") + + viper.SetDefault("auth.jwtSecret", "your-secret-key") + viper.SetDefault("auth.jwtExpirationHours", 24) + viper.SetDefault("auth.bcryptCost", 12) + viper.SetDefault("auth.maxLoginAttempts", 5) + viper.SetDefault("auth.accountLockDuration", "30m") + + viper.SetDefault("ai.openai.maxTokens", 4000) + viper.SetDefault("ai.openai.temperature", 0.7) + viper.SetDefault("ai.openai.topP", 1.0) + viper.SetDefault("ai.openai.timeout", "30s") + + viper.SetDefault("ai.local.maxTokens", 2000) + viper.SetDefault("ai.local.temperature", 0.7) + viper.SetDefault("ai.local.topP", 1.0) + viper.SetDefault("ai.local.timeout", "60s") + viper.SetDefault("ai.local.endpoint", "http://localhost:11434") + viper.SetDefault("ai.local.model", "llama2") + + viper.SetDefault("jwt.secret", "your-jwt-secret") + viper.SetDefault("jwt.expirationHours", 24) + viper.SetDefault("jwt.issuer", "support-system") + viper.SetDefault("jwt.audience", "support-users") + + // Configure viper to read from environment variables + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // Read config file if it exists + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath("./") + viper.AddConfigPath("./config") + viper.AddConfigPath("../config") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("error reading config file: %w", err) + } + // Config file not found; ignore error + } + + // Unmarshal config + if err := viper.Unmarshal(&AppConfig); err != nil { + return fmt.Errorf("unable to decode config: %w", err) + } + + return nil +} + +// GetDSN returns the database connection string +func (c *DatabaseConfig) GetDSN() string { + return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=%s", + c.Host, c.User, c.Password, c.DBName, c.Port, c.SSLMode, c.TimeZone) +} + +// GetServerAddress returns the server address +func (c *ServerConfig) GetServerAddress() string { + return fmt.Sprintf("%s:%s", c.Host, c.Port) +} + +// GetJWTSigningKey returns the JWT signing key +func (c *JWTConfig) GetJWTSigningKey() []byte { + return []byte(c.Secret) +} + +// ParseJWTToken parses a JWT token and returns the claims +func (c *JWTConfig) ParseJWTToken(tokenString string) (*jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, 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 c.GetJWTSigningKey(), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return &claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// GetEnv gets an environment variable with a default value +func GetEnv(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// GetEnvAsInt gets an environment variable as an integer with a default value +func GetEnvAsInt(key string, defaultValue int) int { + valueStr := GetEnv(key, "") + if value, err := strconv.Atoi(valueStr); err == nil { + return value + } + return defaultValue +} + +// GetEnvAsBool gets an environment variable as a boolean with a default value +func GetEnvAsBool(key string, defaultValue bool) bool { + valueStr := GetEnv(key, "") + if value, err := strconv.ParseBool(valueStr); err == nil { + return value + } + return defaultValue +} + +// GetEnvAsDuration gets an environment variable as a duration with a default value +func GetEnvAsDuration(key string, defaultValue time.Duration) time.Duration { + valueStr := GetEnv(key, "") + if value, err := time.ParseDuration(valueStr); err == nil { + return value + } + return defaultValue +} diff --git a/backend/pkg/logger/logger.go b/backend/pkg/logger/logger.go new file mode 100644 index 0000000..00b2560 --- /dev/null +++ b/backend/pkg/logger/logger.go @@ -0,0 +1,260 @@ +package logger + +import ( + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +var Logger *logrus.Logger + +// InitLogger initializes the logger +func InitLogger() { + Logger = logrus.New() + + // Set log level based on environment + logLevel := os.Getenv("LOG_LEVEL") + if logLevel == "" { + logLevel = "info" + } + + level, err := logrus.ParseLevel(logLevel) + if err != nil { + level = logrus.InfoLevel + } + + Logger.SetLevel(level) + + // Set formatter + Logger.SetFormatter(&logrus.JSONFormatter{ + TimestampFormat: time.RFC3339, + }) + + // Set output to stdout by default + Logger.SetOutput(os.Stdout) +} + +// GetLogger returns the logger instance +func GetLogger() *logrus.Logger { + if Logger == nil { + InitLogger() + } + return Logger +} + +// GinLogger returns a gin middleware for logging +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + // Start timer + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + + // Process request + c.Next() + + // Stop timer + end := time.Now() + latency := end.Sub(start) + + // Get client IP + clientIP := c.ClientIP() + + // Get method + method := c.Request.Method + + // Get status code + statusCode := c.Writer.Status() + + // Get error message if any + var errorMessage string + if len(c.Errors) > 0 { + errorMessage = c.Errors.String() + } + + // Get body size + bodySize := c.Writer.Size() + + // Get request ID if available + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = c.GetString("requestID") + } + + // Get user ID if available + userID := c.GetString("userID") + + // Log the request + entry := GetLogger().WithFields(logrus.Fields{ + "request_id": requestID, + "user_id": userID, + "client_ip": clientIP, + "method": method, + "path": path, + "query": raw, + "status_code": statusCode, + "body_size": bodySize, + "latency": latency, + "latency_human": latency.String(), + "error_message": errorMessage, + }) + + if statusCode >= 500 { + entry.Error() + } else if statusCode >= 400 { + entry.Warn() + } else { + entry.Info() + } + } +} + +// WithRequestID adds a request ID to the logger +func WithRequestID(requestID string) *logrus.Entry { + return GetLogger().WithField("request_id", requestID) +} + +// WithUserID adds a user ID to the logger +func WithUserID(userID string) *logrus.Entry { + return GetLogger().WithField("user_id", userID) +} + +// WithError adds an error to the logger +func WithError(err error) *logrus.Entry { + return GetLogger().WithError(err) +} + +// WithField adds a custom field to the logger +func WithField(key string, value interface{}) *logrus.Entry { + return GetLogger().WithField(key, value) +} + +// WithFields adds multiple custom fields to the logger +func WithFields(fields logrus.Fields) *logrus.Entry { + return GetLogger().WithFields(fields) +} + +// Info logs an info message +func Info(args ...interface{}) { + GetLogger().Info(args...) +} + +// Infof logs a formatted info message +func Infof(format string, args ...interface{}) { + GetLogger().Infof(format, args...) +} + +// Warn logs a warning message +func Warn(args ...interface{}) { + GetLogger().Warn(args...) +} + +// Warnf logs a formatted warning message +func Warnf(format string, args ...interface{}) { + GetLogger().Warnf(format, args...) +} + +// Error logs an error message +func Error(args ...interface{}) { + GetLogger().Error(args...) +} + +// Errorf logs a formatted error message +func Errorf(format string, args ...interface{}) { + GetLogger().Errorf(format, args...) +} + +// Fatal logs a fatal message and exits +func Fatal(args ...interface{}) { + GetLogger().Fatal(args...) +} + +// Fatalf logs a formatted fatal message and exits +func Fatalf(format string, args ...interface{}) { + GetLogger().Fatalf(format, args...) +} + +// Debug logs a debug message +func Debug(args ...interface{}) { + GetLogger().Debug(args...) +} + +// Debugf logs a formatted debug message +func Debugf(format string, args ...interface{}) { + GetLogger().Debugf(format, args...) +} + +// Panic logs a panic message and panics +func Panic(args ...interface{}) { + GetLogger().Panic(args...) +} + +// Panicf logs a formatted panic message and panics +func Panicf(format string, args ...interface{}) { + GetLogger().Panicf(format, args...) +} + +// LogDatabaseOperation logs a database operation +func LogDatabaseOperation(operation string, table string, duration time.Duration, err error) { + entry := GetLogger().WithFields(logrus.Fields{ + "operation": operation, + "table": table, + "duration": duration, + }) + + if err != nil { + entry.WithError(err).Error("Database operation failed") + } else { + entry.Info("Database operation completed") + } +} + +// LogAIInteraction logs an AI interaction +func LogAIInteraction(model string, promptLength int, responseLength int, duration time.Duration, success bool, err error) { + entry := GetLogger().WithFields(logrus.Fields{ + "ai_model": model, + "prompt_length": promptLength, + "response_length": responseLength, + "duration": duration, + "success": success, + }) + + if err != nil { + entry.WithError(err).Error("AI interaction failed") + } else { + entry.Info("AI interaction completed") + } +} + +// LogWebSocketEvent logs a WebSocket event +func LogWebSocketEvent(event string, clientID string, roomID string, err error) { + entry := GetLogger().WithFields(logrus.Fields{ + "event": event, + "client_id": clientID, + "room_id": roomID, + }) + + if err != nil { + entry.WithError(err).Error("WebSocket event failed") + } else { + entry.Info("WebSocket event completed") + } +} + +// LogAuthEvent logs an authentication event +func LogAuthEvent(event string, userID string, clientIP string, success bool, err error) { + entry := GetLogger().WithFields(logrus.Fields{ + "event": event, + "user_id": userID, + "client_ip": clientIP, + "success": success, + }) + + if err != nil { + entry.WithError(err).Error("Authentication event failed") + } else { + entry.Info("Authentication event completed") + } +} diff --git a/backend/seed b/backend/seed new file mode 100755 index 0000000..daf5094 Binary files /dev/null and b/backend/seed differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f7c5faa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,102 @@ +version: '3.8' + +services: + # PostgreSQL database + postgres: + image: postgres:15-alpine + container_name: support-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: support + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - support-network + + # Redis for caching and session management + redis: + image: redis:7-alpine + container_name: support-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - support-network + + # Backend API + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: support-backend + environment: + DB_HOST: postgres + DB_USER: postgres + DB_PASSWORD: postgres + DB_NAME: support + DB_PORT: 5432 + DB_SSLMODE: disable + DB_TIMEZONE: UTC + REDIS_HOST: redis + REDIS_PORT: 6379 + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 8080 + AUTH_JWT_SECRET: your-secret-key-change-in-production + AUTH_JWT_EXPIRATION_HOURS: 24 + AI_OPENAI_API_KEY: ${OPENAI_API_KEY} + AI_OPENAI_MODEL: gpt-4 + AI_OPENAI_MAX_TOKENS: 4000 + AI_OPENAI_TEMPERATURE: 0.7 + AI_OPENAI_TOP_P: 1.0 + AI_LOCAL_ENDPOINT: http://ollama:11434 + AI_LOCAL_MODEL: llama2 + AI_LOCAL_MAX_TOKENS: 2000 + AI_LOCAL_TEMPERATURE: 0.7 + AI_LOCAL_TOP_P: 1.0 + LOG_LEVEL: info + ports: + - "8080:8080" + depends_on: + - postgres + - redis + volumes: + - ./backend:/app + networks: + - support-network + + # Ollama for local LLM + ollama: + image: ollama/ollama:latest + container_name: support-ollama + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + networks: + - support-network + + # Frontend (to be implemented) + # frontend: + # build: + # context: ./frontend + # dockerfile: Dockerfile + # container_name: support-frontend + # ports: + # - "3000:3000" + # depends_on: + # - backend + # networks: + # - support-network + +volumes: + postgres_data: + redis_data: + ollama_data: + +networks: + support-network: + driver: bridge \ No newline at end of file