From 9e95e8ee5e3b18c07b1e80bcb5d40ca8df61e345 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Sat, 9 Aug 2025 15:08:26 +0700 Subject: [PATCH] Init Eslogad --- .DS_Store | Bin 0 -> 6148 bytes .air.toml | 44 ++ .dockerignore | 82 +++ .gitignore | 8 + .gitlab-ci.yml | 32 + DOCKER.md | 320 +++++++++ Dockerfile | 106 +++ LICENSE | 21 + Makefile | 115 ++++ README.md | 310 +++++++++ cmd/server/main.go | 29 + config/configs.go | 81 +++ config/crypto.go | 22 + config/db.go | 28 + config/http.go | 17 + config/jwt.go | 10 + config/log.go | 6 + config/s3.go | 39 ++ config/server.go | 7 + deployment.sh | 23 + docker-build.sh | 211 ++++++ go.mod | 72 ++ go.sum | 624 ++++++++++++++++++ infra/development.yaml | 34 + internal/.DS_Store | Bin 0 -> 6148 bytes internal/README.md | 242 +++++++ internal/app/app.go | 165 +++++ internal/app/server.go | 25 + internal/appcontext/context.go | 80 +++ internal/appcontext/context_info.go | 81 +++ internal/client/s3_file_client.go | 84 +++ internal/constants/constant.go | 17 + internal/constants/error.go | 60 ++ internal/constants/header.go | 27 + internal/constants/user.go | 28 + internal/contract/common.go | 49 ++ internal/contract/response.go | 39 ++ internal/contract/response_error.go | 33 + internal/contract/user_contract.go | 125 ++++ internal/db/database.go | 89 +++ internal/entities/rbac.go | 39 ++ internal/entities/title.go | 18 + internal/entities/user.go | 70 ++ internal/entities/user_profile.go | 47 ++ internal/handler/auth_handler.go | 168 +++++ internal/handler/auth_service.go | 13 + internal/handler/common.go | 57 ++ internal/handler/file_handler.go | 78 +++ internal/handler/health.go | 23 + internal/handler/user_handler.go | 306 +++++++++ internal/handler/user_service.go | 23 + internal/handler/user_validator.go | 16 + internal/logger/app_logger.go | 134 ++++ internal/middleware/auth_middleware.go | 170 +++++ internal/middleware/auth_processor.go | 4 + internal/middleware/auth_service.go | 13 + internal/middleware/context.go | 67 ++ internal/middleware/correlation_id.go | 22 + internal/middleware/cors.go | 21 + internal/middleware/json.go | 17 + internal/middleware/logging.go | 24 + internal/middleware/rate_limit.go | 69 ++ internal/middleware/recover.go | 30 + internal/middleware/stat_logger.go | 35 + internal/middleware/user_id_resolver.go | 40 ++ internal/middleware/user_processor.go | 12 + internal/processor/user_processor.go | 251 +++++++ internal/processor/user_repository.go | 25 + internal/repository/title_repository.go | 25 + .../repository/user_profile_repository.go | 45 ++ internal/repository/user_repository.go | 141 ++++ internal/router/auth_handler.go | 9 + internal/router/health_handler.go | 20 + internal/router/middleware.go | 13 + internal/router/router.go | 81 +++ internal/service/auth_service.go | 194 ++++++ internal/service/file_service.go | 92 +++ internal/service/user_processor.go | 27 + internal/service/user_service.go | 91 +++ internal/transformer/common_transformer.go | 172 +++++ internal/transformer/user_transformer.go | 62 ++ internal/util/date_util.go | 72 ++ internal/util/date_util_test.go | 211 ++++++ internal/util/http_util.go | 49 ++ internal/validator/user_validator.go | 153 +++++ internal/validator/validator_helpers.go | 61 ++ migrations/000001_init_db.down.sql | 14 + migrations/000001_init_db.up.sql | 146 ++++ migrations/000002_seed_user_data.down.sql | 0 migrations/000002_seed_user_data.up.sql | 132 ++++ migrations/000003_permissions_seeder.down.sql | 0 migrations/000003_permissions_seeder.up.sql | 30 + migrations/000004_user_profile.down.sql | 1 + migrations/000004_user_profile.up.sql | 30 + migrations/000005_title_table.down.sql | 0 migrations/000005_title_table.up.sql | 60 ++ 96 files changed, 7108 insertions(+) create mode 100644 .DS_Store create mode 100644 .air.toml create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 config/configs.go create mode 100644 config/crypto.go create mode 100644 config/db.go create mode 100644 config/http.go create mode 100644 config/jwt.go create mode 100644 config/log.go create mode 100644 config/s3.go create mode 100644 config/server.go create mode 100644 deployment.sh create mode 100755 docker-build.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra/development.yaml create mode 100644 internal/.DS_Store create mode 100644 internal/README.md create mode 100644 internal/app/app.go create mode 100644 internal/app/server.go create mode 100644 internal/appcontext/context.go create mode 100644 internal/appcontext/context_info.go create mode 100644 internal/client/s3_file_client.go create mode 100644 internal/constants/constant.go create mode 100644 internal/constants/error.go create mode 100644 internal/constants/header.go create mode 100644 internal/constants/user.go create mode 100644 internal/contract/common.go create mode 100644 internal/contract/response.go create mode 100644 internal/contract/response_error.go create mode 100644 internal/contract/user_contract.go create mode 100644 internal/db/database.go create mode 100644 internal/entities/rbac.go create mode 100644 internal/entities/title.go create mode 100644 internal/entities/user.go create mode 100644 internal/entities/user_profile.go create mode 100644 internal/handler/auth_handler.go create mode 100644 internal/handler/auth_service.go create mode 100644 internal/handler/common.go create mode 100644 internal/handler/file_handler.go create mode 100644 internal/handler/health.go create mode 100644 internal/handler/user_handler.go create mode 100644 internal/handler/user_service.go create mode 100644 internal/handler/user_validator.go create mode 100644 internal/logger/app_logger.go create mode 100644 internal/middleware/auth_middleware.go create mode 100644 internal/middleware/auth_processor.go create mode 100644 internal/middleware/auth_service.go create mode 100644 internal/middleware/context.go create mode 100644 internal/middleware/correlation_id.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/json.go create mode 100644 internal/middleware/logging.go create mode 100644 internal/middleware/rate_limit.go create mode 100644 internal/middleware/recover.go create mode 100644 internal/middleware/stat_logger.go create mode 100644 internal/middleware/user_id_resolver.go create mode 100644 internal/middleware/user_processor.go create mode 100644 internal/processor/user_processor.go create mode 100644 internal/processor/user_repository.go create mode 100644 internal/repository/title_repository.go create mode 100644 internal/repository/user_profile_repository.go create mode 100644 internal/repository/user_repository.go create mode 100644 internal/router/auth_handler.go create mode 100644 internal/router/health_handler.go create mode 100644 internal/router/middleware.go create mode 100644 internal/router/router.go create mode 100644 internal/service/auth_service.go create mode 100644 internal/service/file_service.go create mode 100644 internal/service/user_processor.go create mode 100644 internal/service/user_service.go create mode 100644 internal/transformer/common_transformer.go create mode 100644 internal/transformer/user_transformer.go create mode 100644 internal/util/date_util.go create mode 100644 internal/util/date_util_test.go create mode 100644 internal/util/http_util.go create mode 100644 internal/validator/user_validator.go create mode 100644 internal/validator/validator_helpers.go create mode 100644 migrations/000001_init_db.down.sql create mode 100644 migrations/000001_init_db.up.sql create mode 100644 migrations/000002_seed_user_data.down.sql create mode 100644 migrations/000002_seed_user_data.up.sql create mode 100644 migrations/000003_permissions_seeder.down.sql create mode 100644 migrations/000003_permissions_seeder.up.sql create mode 100644 migrations/000004_user_profile.down.sql create mode 100644 migrations/000004_user_profile.up.sql create mode 100644 migrations/000005_title_table.down.sql create mode 100644 migrations/000005_title_table.up.sql diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3642ece99647ac1d16a8332b7cba28905f22c12f GIT binary patch literal 6148 zcmeHK%}T>S5Z-O8O(;SR3OxqA7K~by;w8lT0!H+pQWFw1m}X0pnnNk%tS{t~_&m<+ zZp2arPa<{(X203_$+F*u{b7u8Zx-z{W--PLP{f7_%@=}k)D>xH4ZXtp*s+eX{y7`Og|DE%TRX1Nzk zud#O_L>kt15MCy6IkL7-MOFk!7EfhC62*{mbCqO~C_ORDqFmPcDqxs~IkLL*dEe>x z?Ec_*(X;1+0|)HGqs78Bc6Rqp&PR{wI1|r`P7Z&blr4=lynyk=%%1#dmWu2iqIG3m zK}ZY`1H{0(FrfE8qq8m(rg;(r#K4ak!2Q7nMRYY*3gy-T4PGBH-a ./kubeconfig + - export KUBECONFIG=$(pwd)/kubeconfig + - sed -i "s//$CI_COMMIT_SHORT_SHA/" k8s/staging/deployment.yaml + - kubectl apply -f k8s/staging/namespace.yaml + - kubectl apply -f k8s/staging/deployment.yaml + - kubectl apply -f k8s/staging/service.yaml + - kubectl apply -f k8s/staging/ingress.yaml + only: + - main diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..7c20420 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,320 @@ +# Docker Setup for APSKEL POS Backend + +This document describes how to run the APSKEL POS Backend using Docker and Docker Compose. + +## Prerequisites + +- Docker (version 20.10 or later) +- Docker Compose (version 2.0 or later) +- Git (for cloning the repository) +- Go 1.21+ (for local development) + +## Quick Start + +### 1. Build and Run Production Environment + +```bash +# Build and start all services +./docker-build.sh run + +# Or manually: +docker-compose up -d +``` + +The application will be available at: +- **Backend API**: http://localhost:3300 +- **Database**: localhost:5432 +- **Redis**: localhost:6379 + +### 2. Development Environment + +```bash +# Start development environment with live reload +./docker-build.sh dev + +# Or manually: +docker-compose --profile dev up -d +``` + +Development environment provides: +- **Backend API (Dev)**: http://localhost:3001 (with live reload) +- **Backend API (Prod)**: http://localhost:3300 +- Auto-restart on code changes using Air + +### 3. Database Migrations + +```bash +# Run database migrations +./docker-build.sh migrate + +# Or manually: +docker-compose --profile migrate up migrate +``` + +## Build Script Usage + +The `docker-build.sh` script provides convenient commands: + +```bash +# Build Docker image +./docker-build.sh build + +# Build and run production environment +./docker-build.sh run + +# Start development environment +./docker-build.sh dev + +# Run database migrations +./docker-build.sh migrate + +# Stop all containers +./docker-build.sh stop + +# Clean up containers and images +./docker-build.sh clean + +# Show container logs +./docker-build.sh logs + +# Show help +./docker-build.sh help +``` + +## Services + +### Backend API +- **Port**: 3300 (production), 3001 (development) +- **Health Check**: http://localhost:3300/health +- **Environment**: Configurable via `infra/` directory +- **User**: Runs as non-root user for security + +### PostgreSQL Database +- **Port**: 5432 +- **Database**: apskel_pos +- **Username**: apskel +- **Password**: See docker-compose.yaml +- **Volumes**: Persistent data storage + +### Redis Cache +- **Port**: 6379 +- **Usage**: Caching and session storage +- **Volumes**: Persistent data storage + +## Environment Configuration + +The application uses configuration files from the `infra/` directory: + +- `infra/development.yaml` - Development configuration +- `infra/production.yaml` - Production configuration (create if needed) + +### Configuration Structure + +```yaml +server: + port: 3300 + +postgresql: + host: postgres # Use service name in Docker + port: 5432 + db: apskel_pos + username: apskel + password: 7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk + +jwt: + token: + secret: "your-jwt-secret" + expires-ttl: 1440 + +s3: + access_key_id: "your-s3-key" + access_key_secret: "your-s3-secret" + endpoint: "your-s3-endpoint" + bucket_name: "your-bucket" + +log: + log_level: "info" + log_format: "json" +``` + +## Docker Compose Profiles + +### Default Profile (Production) +```bash +docker-compose up -d +``` +Starts: postgres, redis, backend + +### Development Profile +```bash +docker-compose --profile dev up -d +``` +Starts: postgres, redis, backend, backend-dev + +### Migration Profile +```bash +docker-compose --profile migrate up migrate +``` +Runs: database migrations + +## Health Checks + +All services include health checks: + +- **Backend**: HTTP GET /health +- **PostgreSQL**: pg_isready command +- **Redis**: Redis ping command + +## Logging + +View logs for specific services: + +```bash +# All services +docker-compose logs -f + +# Backend only +docker-compose logs -f backend + +# Database only +docker-compose logs -f postgres + +# Development backend +docker-compose logs -f backend-dev +``` + +## Volumes + +### Persistent Volumes +- `postgres_data`: Database files +- `redis_data`: Redis persistence files +- `go_modules`: Go module cache (development) + +### Bind Mounts +- `./infra:/infra:ro`: Configuration files (read-only) +- `./migrations:/app/migrations:ro`: Database migrations (read-only) +- `.:/app`: Source code (development only) + +## Security + +### Production Security Features +- Non-root user execution +- Read-only configuration mounts +- Minimal base image (Debian slim) +- Health checks for monitoring +- Resource limits (configurable) + +### Network Security +- Internal Docker network isolation +- Only necessary ports exposed +- Service-to-service communication via Docker network + +## Troubleshooting + +### Common Issues + +1. **Go Version Compatibility Error** + ```bash + # Error: package slices is not in GOROOT + # Solution: Make sure Dockerfile uses Go 1.21+ + # Check go.mod file requires Go 1.21 or later + ``` + +2. **Port Already in Use** + ```bash + # Check what's using the port + lsof -i :3300 + + # Change ports in docker-compose.yaml if needed + ``` + +3. **Database Connection Failed** + ```bash + # Check if database is running + docker-compose ps postgres + + # Check database logs + docker-compose logs postgres + ``` + +4. **Permission Denied** + ```bash + # Make sure script is executable + chmod +x docker-build.sh + ``` + +5. **Out of Disk Space** + ```bash + # Clean up unused Docker resources + docker system prune -a + + # Remove old images + docker image prune -a + ``` + +### Debug Mode + +Run containers in debug mode: + +```bash +# Start with debug logs +ENV_MODE=development docker-compose up + +# Enter running container +docker-compose exec backend sh + +# Check application logs +docker-compose logs -f backend +``` + +### Performance Tuning + +For production deployment: + +1. **Resource Limits**: Add resource limits to docker-compose.yaml +2. **Environment**: Use production configuration +3. **Logging**: Adjust log levels +4. **Health Checks**: Tune intervals for your needs + +```yaml +services: + backend: + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.5' + memory: 256M +``` + +## API Testing + +Once the application is running, test the API: + +```bash +# Health check +curl http://localhost:3300/health + +# Analytics endpoint (requires authentication) +curl -H "Authorization: Bearer " \ + -H "Organization-ID: " \ + "http://localhost:3300/api/v1/analytics/profit-loss?date_from=01-12-2023&date_to=31-12-2023" +``` + +## Deployment + +For production deployment: + +1. Update configuration in `infra/production.yaml` +2. Set appropriate environment variables +3. Use production Docker Compose file +4. Configure reverse proxy (nginx, traefik) +5. Set up SSL certificates +6. Configure monitoring and logging + +```bash +# Production deployment +ENV_MODE=production docker-compose -f docker-compose.prod.yaml up -d +``` \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c2037cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,106 @@ +# Build Stage +FROM golang:1.21-alpine AS build + +# Install necessary packages including CA certificates +RUN apk --no-cache add ca-certificates tzdata git curl + +WORKDIR /src + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /app cmd/server/main.go + +# Development Stage +FROM golang:1.21-alpine AS development + +# Install air for live reload and other dev tools +RUN go install github.com/cosmtrek/air@latest + +# Install necessary packages +RUN apk --no-cache add ca-certificates tzdata git curl + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Set timezone +ENV TZ=Asia/Jakarta + +# Expose port +EXPOSE 3300 + +# Use air for live reload in development +CMD ["air", "-c", ".air.toml"] + +# Migration Stage +FROM build AS migration + +# Install migration tool +RUN go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest + +WORKDIR /app + +# Copy migration files +COPY migrations ./migrations +COPY infra ./infra + +# Set the entrypoint for migrations +ENTRYPOINT ["migrate"] + +# Production Stage +FROM debian:bullseye-slim AS production + +# Install minimal runtime dependencies + Chrome, Chromium, and wkhtmltopdf for PDF generation +RUN apt-get update && apt-get install -y \ + ca-certificates \ + tzdata \ + curl \ + fontconfig \ + wget \ + gnupg \ + && wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - \ + && echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update \ + && apt-get install -y google-chrome-stable chromium wkhtmltopdf \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copy the binary +COPY --from=build /app /app + +# Copy configuration files +COPY --from=build /src/infra /infra + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app /infra + +# Set timezone +ENV TZ=Asia/Jakarta + +# Expose port +EXPOSE 3300 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:3300/health || exit 1 + +# Switch to non-root user +USER appuser + +# Set the entrypoint +ENTRYPOINT ["/app"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5bd11c7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Pavel Varentsov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fc655ba --- /dev/null +++ b/Makefile @@ -0,0 +1,115 @@ +#PROJECT_NAME = "enaklo-pos-backend" +DB_USERNAME :=eslogad_user +DB_PASSWORD :=M9u%24e%23jT2%40qR4pX%21zL +DB_HOST :=103.191.71.2 +DB_PORT :=5432 +DB_NAME :=eslogad_db + +DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable + +ifeq ($(OS),Windows_NT) + DETECTED_OS := Windows +else + DETECTED_OS := $(shell sh -c 'uname 2>/dev/null || echo Unknown') +endif + +.SILENT: help +help: + @echo + @echo "Usage: make [command]" + @echo + @echo "Commands:" + @echo " rename-project name={name} Rename project" + @echo + @echo " build-http Build http server" + @echo + @echo " migration-create name={name} Create migration" + @echo " migration-up Up migrations" + @echo " migration-down Down last migration" + @echo + @echo " docker-up Up docker services" + @echo " docker-down Down docker services" + @echo + @echo " fmt Format source code" + @echo " test Run unit tests" + @echo + +# Build + +.SILENT: rename-project +rename-project: + ifeq ($(name),) + @echo 'new project name not set' + else + ifeq ($(DETECTED_OS),Darwin) + @grep -RiIl '$(PROJECT_NAME)' | xargs sed -i '' 's/$(PROJECT_NAME)/$(name)/g' + endif + + ifeq ($(DETECTED_OS),Linux) + @grep -RiIl '$(PROJECT_NAME)' | xargs sed -i 's/$(PROJECT_NAME)/$(name)/g' + endif + + ifeq ($(DETECTED_OS),Windows) + @grep 'target is not implemented on Windows platform' + endif + endif + +.SILENT: build-http +build-http: + @go build -o ./bin/http-server ./cmd/http/main.go + @echo executable file \"http-server\" saved in ./bin/http-server + +# Test + +.SILENT: test +test: + @go test ./... -v + +# Create migration + +.SILENT: migration-create +migration-create: + @migrate create -ext sql -dir ./migrations -seq $(name) + +# Up migration + +.SILENT: migration-up +migration-up: + @migrate -database $(DB_URL) -path ./migrations up + +# Down migration + +.SILENT: migration-down +migration-down: + @migrate -database $(DB_URL) -path ./migrations down 1 + +.SILENT: seeder-create +seeder-create: + @migrate create -ext sql -dir ./seeders -seq $(name) + +.SILENT: seeder-up +seeder-up: + @migrate -database $(DB_URL) -path ./seeders up + +# Docker + +.SILENT: docker-up +docker-up: + @docker-compose up -d + +.SILENT: docker-down +docker-down: + @docker-compose down + +# Format + +.SILENT: fmt +fmt: + @go fmt ./... + +start: + go run main.go --env-path .env + +# Default + +.DEFAULT_GOAL := help \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a97aee7 --- /dev/null +++ b/README.md @@ -0,0 +1,310 @@ +

+ Go
Backend Template +

+ +> Clean architecture based backend template in Go. + +## Makefile + +Makefile requires installed dependecies: +* [go](https://go.dev/doc/install) +* [docker-compose](https://docs.docker.com/compose/reference) +* [migrate](https://github.com/golang-migrate/migrate) + + +```shell +$ make + +Usage: make [command] + +Commands: + rename-project name={name} Rename project + + build-http Build http server + + migration-create name={name} Create migration + migration-up Up migrations + migration-down Down last migration + + docker-up Up docker services + docker-down Down docker services + + fmt Format source code + test Run unit tests + +``` + +## HTTP Server + +```shell +$ ./bin/http-server --help + +Usage: http-server + +Flags: + -h, --help Show mycontext-sensitive help. + --env-path=STRING Path to env config file +``` + +**Configuration** is based on the environment variables. See [.env.template](.env). + +```shell +# Expose env vars before and start server +$ ./bin/http-server + +# Expose env vars from the file and start server +$ ./bin/http-server --env-path ./config/env/.env +``` + +## API Docs +* [eslogad Backend](https://eslogad-be.app-dev.altru.id/docs/index.html#/) + +## License + +This project is licensed under the [MIT License](https://github.com/pvarentsov/eslogad-be/blob/main/LICENSE). + +# Apskel POS Backend + +A SaaS Point of Sale (POS) Restaurant System backend built with clean architecture principles in Go. + +## Architecture Overview + +This application follows a clean architecture pattern with clear separation of concerns: + +``` +Handler → Service → Processor → Repository +``` + +### Layers + +1. **Contract Package** (`internal/contract/`) + - Request/Response DTOs for API communication + - Contains JSON tags for serialization + - Input validation tags + +2. **Handler Layer** (`internal/handler/`) + - HTTP request/response handling + - Request validation using go-playground/validator + - Route definitions and middleware + - Transforms contracts to/from services + +3. **Service Layer** (`internal/service/`) + - Business logic orchestration + - Calls processors and transformers + - Coordinates between different business operations + +4. **Processor Layer** (`internal/processor/`) + - Complex business operations + - Cross-repository transactions + - Business rule enforcement + - Handles operations like order creation with inventory updates + +5. **Repository Layer** (`internal/repository/`) + - Data access layer + - Individual repository per entity + - Database-specific operations + - Uses entities for database models + +6. **Supporting Packages**: + - **Models** (`internal/models/`) - Pure business logic models (no database dependencies) + - **Entities** (`internal/entities/`) - Database models with GORM tags + - **Constants** (`internal/constants/`) - Type-safe enums and business constants + - **Transformer** (`internal/transformer/`) - Contract ↔ Model conversions + - **Mappers** (`internal/mappers/`) - Model ↔ Entity conversions + +## Key Features + +- **Clean Architecture**: Strict separation between business logic and infrastructure +- **Type Safety**: Constants package with validation helpers +- **Validation**: Comprehensive request validation using go-playground/validator +- **Error Handling**: Structured error responses with proper HTTP status codes +- **Database Independence**: Business logic never depends on database implementation +- **Testability**: Each layer can be tested independently + +## API Endpoints + +### Health Check +- `GET /api/v1/health` - Health check endpoint + +### Organizations +- `POST /api/v1/organizations` - Create organization +- `GET /api/v1/organizations` - List organizations +- `GET /api/v1/organizations/{id}` - Get organization by ID +- `PUT /api/v1/organizations/{id}` - Update organization +- `DELETE /api/v1/organizations/{id}` - Delete organization + +### Users +- `POST /api/v1/users` - Create user +- `GET /api/v1/users` - List users +- `GET /api/v1/users/{id}` - Get user by ID +- `PUT /api/v1/users/{id}` - Update user +- `DELETE /api/v1/users/{id}` - Delete user +- `PUT /api/v1/users/{id}/password` - Change password +- `PUT /api/v1/users/{id}/activate` - Activate user +- `PUT /api/v1/users/{id}/deactivate` - Deactivate user + +### Orders +- `POST /api/v1/orders` - Create order with items +- `GET /api/v1/orders` - List orders +- `GET /api/v1/orders/{id}` - Get order by ID +- `GET /api/v1/orders/{id}?include_items=true` - Get order with items +- `PUT /api/v1/orders/{id}` - Update order +- `PUT /api/v1/orders/{id}/cancel` - Cancel order +- `PUT /api/v1/orders/{id}/complete` - Complete order +- `POST /api/v1/orders/{id}/items` - Add item to order + +### Order Items +- `PUT /api/v1/order-items/{id}` - Update order item +- `DELETE /api/v1/order-items/{id}` - Remove order item + +## Installation + +1. **Clone the repository** + ```bash + git clone + cd eslogad-backend + ``` + +2. **Install dependencies** + ```bash + go mod tidy + ``` + +3. **Set up database** + ```bash + # Set your PostgreSQL database URL + export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" + ``` + +4. **Run migrations** + ```bash + make migration-up + ``` + +## Usage + +### Development + +```bash +# Start the server +go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" + +# Or using environment variable +export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable" +go run cmd/server/main.go -port 8080 +``` + +### Using Make Commands + +```bash +# Run the application +make start + +# Format code +make fmt + +# Run tests +make test + +# Build for production +make build-http + +# Docker operations +make docker-up +make docker-down + +# Database migrations +make migration-create name=create_users_table +make migration-up +make migration-down +``` + +## Example API Usage + +### Create Organization +```bash +curl -X POST http://localhost:8080/api/v1/organizations \ + -H "Content-Type: application/json" \ + -d '{ + "name": "My Restaurant", + "plan_type": "premium" + }' +``` + +### Create User +```bash +curl -X POST http://localhost:8080/api/v1/users \ + -H "Content-Type: application/json" \ + -d '{ + "organization_id": "uuid-here", + "username": "john_doe", + "email": "john@example.com", + "password": "password123", + "full_name": "John Doe", + "role": "manager" + }' +``` + +### Create Order with Items +```bash +curl -X POST http://localhost:8080/api/v1/orders \ + -H "Content-Type: application/json" \ + -d '{ + "outlet_id": "uuid-here", + "user_id": "uuid-here", + "table_number": "A1", + "order_type": "dine_in", + "notes": "No onions", + "order_items": [ + { + "product_id": "uuid-here", + "quantity": 2, + "unit_price": 15.99 + } + ] + }' +``` + +## Project Structure + +``` +eslogad-backend/ +├── cmd/ +│ └── server/ # Application entry point +├── internal/ +│ ├── app/ # Application wiring and dependency injection +│ ├── contract/ # API contracts (request/response DTOs) +│ ├── handler/ # HTTP handlers and routes +│ ├── service/ # Business logic orchestration +│ ├── processor/ # Complex business operations +│ ├── repository/ # Data access layer +│ ├── models/ # Pure business models +│ ├── entities/ # Database entities (GORM models) +│ ├── constants/ # Business constants and enums +│ ├── transformer/ # Contract ↔ Model transformations +│ └── mappers/ # Model ↔ Entity transformations +├── migrations/ # Database migrations +├── Makefile # Build and development commands +├── go.mod # Go module definition +└── README.md # This file +``` + +## Dependencies + +- **[Gorilla Mux](https://github.com/gorilla/mux)** - HTTP router and URL matcher +- **[GORM](https://gorm.io/)** - ORM for database operations +- **[PostgreSQL Driver](https://github.com/lib/pq)** - PostgreSQL database driver +- **[Validator](https://github.com/go-playground/validator)** - Struct validation +- **[UUID](https://github.com/google/uuid)** - UUID generation and parsing + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Commit your changes +4. Push to the branch +5. Create a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..82bac29 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,29 @@ +package main + +import ( + "eslogad-be/config" + "eslogad-be/internal/app" + "eslogad-be/internal/db" + "eslogad-be/internal/logger" + "log" +) + +func main() { + cfg := config.LoadConfig() + logger.Setup(cfg.LogLevel(), cfg.LogFormat()) + + db, err := db.NewPostgres(cfg.Database) + if err != nil { + log.Fatal(err) + } + + application := app.NewApp(db) + + if err := application.Initialize(cfg); err != nil { + log.Fatalf("Failed to initialize application: %v", err) + } + + if err := application.Start(cfg.Port()); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/config/configs.go b/config/configs.go new file mode 100644 index 0000000..6a6c529 --- /dev/null +++ b/config/configs.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "os" + "sync" + + "github.com/spf13/viper" + _ "gopkg.in/yaml.v3" +) + +const ( + YAML_PATH = "infra/%s" + ENV_MODE = "ENV_MODE" + DEFAULT_ENV_MODE = "development" +) + +var ( + validEnvMode = map[string]struct{}{ + "local": {}, + "development": {}, + "production": {}, + } +) + +type Config struct { + Server Server `mapstructure:"server"` + Database Database `mapstructure:"postgresql"` + Jwt Jwt `mapstructure:"jwt"` + Log Log `mapstructure:"log"` + S3Config S3Config `mapstructure:"s3"` +} + +var ( + config *Config + configOnce sync.Once +) + +func LoadConfig() *Config { + envMode := os.Getenv(ENV_MODE) + if _, ok := validEnvMode[envMode]; !ok { + envMode = DEFAULT_ENV_MODE + } + cfgFilePath := fmt.Sprintf(YAML_PATH, envMode) + + configOnce.Do(func() { + v := viper.New() + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.SetConfigName(cfgFilePath) + if err := v.ReadInConfig(); err != nil { + panic(fmt.Errorf("failed to read config file: %s", err)) + } + + config = &Config{} + if err := v.Unmarshal(config); err != nil { + panic(fmt.Errorf("failed to unmarshal config: %s", err)) + } + }) + + return config +} + +func (c *Config) Auth() *AuthConfig { + return &AuthConfig{ + jwtTokenSecret: c.Jwt.Token.Secret, + jwtTokenExpiresTTL: c.Jwt.Token.ExpiresTTL, + } +} + +func (c *Config) LogLevel() string { + return c.Log.LogLevel +} + +func (c *Config) Port() string { + return c.Server.Port +} + +func (c *Config) LogFormat() string { + return c.Log.LogFormat +} diff --git a/config/crypto.go b/config/crypto.go new file mode 100644 index 0000000..e3e90c5 --- /dev/null +++ b/config/crypto.go @@ -0,0 +1,22 @@ +package config + +import "time" + +type AuthConfig struct { + jwtTokenExpiresTTL int + jwtTokenSecret string +} + +type JWT struct { + secret string + expireTTL int +} + +func (c *AuthConfig) AccessTokenSecret() string { + return c.jwtTokenSecret +} + +func (c *AuthConfig) AccessTokenExpiresDate() time.Time { + duration := time.Duration(c.jwtTokenExpiresTTL) + return time.Now().UTC().Add(time.Minute * duration) +} diff --git a/config/db.go b/config/db.go new file mode 100644 index 0000000..f8e1548 --- /dev/null +++ b/config/db.go @@ -0,0 +1,28 @@ +package config + +import ( + "fmt" + "time" +) + +type Database struct { + Host string `mapstructure:"host"` + Port string `mapstructure:"port"` + DB string `mapstructure:"db"` + Driver string `mapstructure:"driver"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + SslMode string `mapstructure:"ssl-mode"` + Debug bool `mapstructure:"debug"` + MaxIdleConnectionsInSecond int `mapstructure:"max-idle-connections-in-second"` + MaxOpenConnectionsInSecond int `mapstructure:"max-open-connections-in-second"` + ConnectionMaxLifetimeInSecond int64 `mapstructure:"connection-max-life-time-in-second"` +} + +func (c Database) DSN() string { + return fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=%s TimeZone=Asia/Jakarta", c.Host, c.Port, c.DB, c.Username, c.Password, c.SslMode) +} + +func (c Database) ConnectionMaxLifetime() time.Duration { + return time.Duration(c.ConnectionMaxLifetimeInSecond) * time.Second +} diff --git a/config/http.go b/config/http.go new file mode 100644 index 0000000..21f9b4b --- /dev/null +++ b/config/http.go @@ -0,0 +1,17 @@ +package config + +import "fmt" + +type httpConfig struct { + host string + port int + detailedError bool +} + +func (c *httpConfig) Address() string { + return fmt.Sprintf("%s:%d", c.host, c.port) +} + +func (c *httpConfig) DetailedError() bool { + return c.detailedError +} diff --git a/config/jwt.go b/config/jwt.go new file mode 100644 index 0000000..aec29b2 --- /dev/null +++ b/config/jwt.go @@ -0,0 +1,10 @@ +package config + +type Jwt struct { + Token Token `mapstructure:"token"` +} + +type Token struct { + ExpiresTTL int `mapstructure:"expires-ttl"` + Secret string `mapstructure:"secret"` +} diff --git a/config/log.go b/config/log.go new file mode 100644 index 0000000..3d33c16 --- /dev/null +++ b/config/log.go @@ -0,0 +1,6 @@ +package config + +type Log struct { + LogFormat string `mapstructure:"log_format"` + LogLevel string `mapstructure:"log_level"` +} diff --git a/config/s3.go b/config/s3.go new file mode 100644 index 0000000..89b7ccb --- /dev/null +++ b/config/s3.go @@ -0,0 +1,39 @@ +package config + +type S3Config struct { + AccessKeyID string `mapstructure:"access_key_id"` + AccessKeySecret string `mapstructure:"access_key_secret"` + Endpoint string `mapstructure:"endpoint"` + BucketName string `mapstructure:"bucket_name"` + PhotoFolder string `mapstructure:"photo_folder"` + LogLevel string `mapstructure:"log_level"` + HostURL string `mapstructure:"host_url"` +} + +func (c S3Config) GetAccessKeyID() string { + return c.AccessKeyID +} + +func (c S3Config) GetAccessKeySecret() string { + return c.AccessKeySecret +} + +func (c S3Config) GetEndpoint() string { + return c.Endpoint +} + +func (c S3Config) GetBucketName() string { + return c.BucketName +} + +func (c S3Config) GetLogLevel() string { + return c.LogLevel +} + +func (c S3Config) GetHostURL() string { + return c.HostURL +} + +func (c S3Config) GetPhotoFolder() string { + return c.PhotoFolder +} diff --git a/config/server.go b/config/server.go new file mode 100644 index 0000000..10ee173 --- /dev/null +++ b/config/server.go @@ -0,0 +1,7 @@ +package config + +type Server struct { + Port string `mapstructure:"port"` + BaseUrl string `mapstructure:"common-url"` + LocalUrl string `mapstructure:"local-url"` +} diff --git a/deployment.sh b/deployment.sh new file mode 100644 index 0000000..4122a2d --- /dev/null +++ b/deployment.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +APP_NAME="eslogad" +PORT="4000" + +echo "🔄 Pulling latest code..." +git pull + +echo "🐳 Building Docker image..." +docker build -t $APP_NAME . + +echo "🛑 Stopping and removing old container..." +docker stop $APP_NAME 2>/dev/null +docker rm $APP_NAME 2>/dev/null + +echo "🚀 Running new container..." +docker run -d --name $APP_NAME \ + -p $PORT:$PORT \ + -v "$(pwd)/infra":/infra:ro \ + -v "$(pwd)/templates":/templates:ro \ + $APP_NAME:latest + +echo "✅ Deployment complete." diff --git a/docker-build.sh b/docker-build.sh new file mode 100755 index 0000000..0239cf5 --- /dev/null +++ b/docker-build.sh @@ -0,0 +1,211 @@ +#!/bin/bash + +# Docker build script for eslogad-backend + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Help function +show_help() { + echo "Usage: $0 [OPTION]" + echo "Build and manage Docker containers for eslogad-backend" + echo "" + echo "Options:" + echo " build Build the Docker image" + echo " run Run the production container" + echo " dev Run development environment with live reload" + echo " migrate Run database migrations" + echo " stop Stop all containers" + echo " clean Remove containers and images" + echo " logs Show container logs" + echo " help Show this help message" + echo "" +} + +# Build Docker image +build_image() { + log_info "Building eslogad-backend Docker image..." + + # Check if Go build works locally first (optional quick test) + if command -v go &> /dev/null; then + log_info "Testing Go build locally first..." + if go build -o /tmp/test-build cmd/server/main.go 2>/dev/null; then + log_success "Local Go build test passed" + rm -f /tmp/test-build + else + log_warning "Local Go build test failed, but continuing with Docker build..." + fi + fi + + # Build the image with production target + docker build \ + --target production \ + -t eslogad-backend:latest \ + -t eslogad-backend:$(date +%Y%m%d-%H%M%S) \ + . + + if [ $? -eq 0 ]; then + log_success "Docker image built successfully!" + else + log_error "Failed to build Docker image" + log_info "Make sure you're using Go 1.21+ and all dependencies are available" + exit 1 + fi +} + +# Run production container +run_container() { + log_info "Starting production containers..." + + # Start the containers + docker-compose up -d + + if [ $? -eq 0 ]; then + log_success "Containers started successfully!" + log_info "Backend API available at: http://localhost:3300" + log_info "Database available at: localhost:5432" + log_info "Redis available at: localhost:6379" + log_info "" + log_info "Use 'docker-compose logs -f backend' to view logs" + else + log_error "Failed to start containers" + exit 1 + fi +} + +# Run development environment +run_dev() { + log_info "Starting development environment..." + + # Start development containers + docker-compose --profile dev up -d + + if [ $? -eq 0 ]; then + log_success "Development environment started!" + log_info "Backend API (dev) available at: http://localhost:3001" + log_info "Backend API (prod) available at: http://localhost:3300" + log_info "" + log_info "Use 'docker-compose logs -f backend-dev' to view development logs" + else + log_error "Failed to start development environment" + exit 1 + fi +} + +# Run migrations +run_migrations() { + log_info "Running database migrations..." + + # Ensure database is running + docker-compose up -d postgres + sleep 5 + + # Run migrations + docker-compose --profile migrate up migrate + + if [ $? -eq 0 ]; then + log_success "Migrations completed successfully!" + else + log_warning "Migrations may have failed or are already up to date" + fi +} + +# Stop containers +stop_containers() { + log_info "Stopping all containers..." + + docker-compose down + + if [ $? -eq 0 ]; then + log_success "All containers stopped" + else + log_error "Failed to stop containers" + exit 1 + fi +} + +# Clean up containers and images +clean_up() { + log_warning "This will remove all containers, networks, and images related to this project" + read -p "Are you sure? (y/N): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + log_info "Cleaning up containers and images..." + + # Stop and remove containers + docker-compose down -v --remove-orphans + + # Remove images + docker rmi eslogad-backend:latest || true + docker rmi $(docker images eslogad-backend -q) || true + + # Remove unused networks and volumes + docker network prune -f || true + docker volume prune -f || true + + log_success "Cleanup completed" + else + log_info "Cleanup cancelled" + fi +} + +# Show logs +show_logs() { + log_info "Showing container logs..." + + # Show logs for all services + docker-compose logs -f +} + +# Main script logic +case "${1:-help}" in + "build") + build_image + ;; + "run") + build_image + run_container + ;; + "dev") + run_dev + ;; + "migrate") + run_migrations + ;; + "stop") + stop_containers + ;; + "clean") + clean_up + ;; + "logs") + show_logs + ;; + "help"|*) + show_help + ;; +esac \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..59978db --- /dev/null +++ b/go.mod @@ -0,0 +1,72 @@ +module eslogad-be + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/go-playground/validator/v10 v10.17.0 + github.com/google/uuid v1.1.2 + github.com/lib/pq v1.2.0 + github.com/spf13/viper v1.16.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bytedance/sonic v1.10.2 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // 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/goccy/go-json v0.10.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/hashicorp/hcl v1.0.0 // 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.3.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // 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.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.11.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/stretchr/objx v0.5.0 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + golang.org/x/arch v0.7.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) + +require ( + github.com/aws/aws-sdk-go v1.55.7 + github.com/golang-jwt/jwt/v5 v5.2.3 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + go.uber.org/zap v1.21.0 + golang.org/x/crypto v0.28.0 + gorm.io/driver/postgres v1.5.0 + gorm.io/gorm v1.30.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6cc295c --- /dev/null +++ b/go.sum @@ -0,0 +1,624 @@ +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/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= +github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +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/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= +github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +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.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= +github.com/go-playground/validator/v10 v10.17.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.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= +github.com/golang-jwt/jwt/v5 v5.2.3/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/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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/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 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +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.3.0 h1:/NQi8KHMpKWHInxXesC8yD4DhkXPrVhmnwYkjp9AmBA= +github.com/jackc/pgx/v5 v5.3.0/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +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.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/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.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +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.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +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.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.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/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= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8= +go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= +golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +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/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +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/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/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-20210330210617-4fbd30eecc44/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-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +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/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +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.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +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.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U= +gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A= +gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +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= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +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/infra/development.yaml b/infra/development.yaml new file mode 100644 index 0000000..62ed50b --- /dev/null +++ b/infra/development.yaml @@ -0,0 +1,34 @@ +server: + base-url: + local-url: + port: 4000 + +jwt: + token: + expires-ttl: 1440 + secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs" + +postgresql: + host: 103.191.71.2 + port: 5432 + driver: postgres + db: eslogad_db + username: eslogad_user + password: 'M9u$e#jT2@qR4pX!zL' + ssl-mode: disable + max-idle-connections-in-second: 600 + max-open-connections-in-second: 600 + connection-max-life-time-in-second: 600 + debug: false + +s3: + access_key_id: minioadmin # from MINIO_ROOT_USER or Access Key you created in console + access_key_secret: minioadmin123 # from MINIO_ROOT_PASSWORD or Secret Key you created in console + endpoint: http://103.191.71.2:9000 # S3 API endpoint, not console port + bucket_name: enaklo + log_level: Error + host_url: 'http://103.191.71.2:9000/' + +log: + log_format: 'json' + log_level: 'debug' \ No newline at end of file diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0d3028ef7996d28f4ebe51024cefa1d977aae2f2 GIT binary patch literal 6148 zcmeHKJ5B>p3>=dbAv7sdzAJEprzklA7XT6pq$n?9p-8t~uFd!Z+GvH+QGhMkvuls{ zX{T7P0m$lOcMmK8%;<_ZYZ#l>)kk&{nF&$!8QcAHYkSS!M(HYO?*vac;<)RE{aZ8~ z(BYlM17kNV&+&#A_B0Y+S!}#N4GCAzO+qS=3Zw$5Kq~NG72wQPo838POa)SbRNz|y z{XP`BVh!va?bE?vD*$oIv>C5$mmn5R5NlxX$P7)qmFTS!D~5PG^CfsSuy^!!NGu-` zCrfN7VyE-@#psagm@ySd1uhlP-J7=1`M;t6u>UJ3Efq)w{-^>nT|cZ>e7Tsdqu10~ uTj-DUFC*9J3^t01Hi~(pqj)*s6}@J?2KJ8D&b+k~^G85+NlOKOLxE3j`XJ!| literal 0 HcmV?d00001 diff --git a/internal/README.md b/internal/README.md new file mode 100644 index 0000000..2ab1edf --- /dev/null +++ b/internal/README.md @@ -0,0 +1,242 @@ +# Internal Architecture + +This document describes the clean architecture implementation for the POS backend with complete separation of concerns between database entities, business models, and constants. + +## 📁 Package Structure + +### `/constants` - Business Constants +- **Purpose**: All business logic constants, enums, and validation helpers +- **Usage**: Used by models, services, and validation layers +- **Features**: + - Type-safe enums (UserRole, OrderStatus, PaymentStatus, etc.) + - Business validation functions (IsValidUserRole, etc.) + - Default values and limits + - No dependencies on database or frameworks + +### `/entities` - Database Models +- **Purpose**: Database-specific models with GORM tags and hooks +- **Usage**: **ONLY** used by repository layer for database operations +- **Features**: + - GORM annotations (`gorm:` tags) + - Database relationships and constraints + - BeforeCreate/AfterCreate hooks + - Table name specifications + - SQL-specific data types + - **Never used in business logic** + +### `/models` - Business Models +- **Purpose**: **Pure** business domain models without any framework dependencies +- **Usage**: Used by services, handlers, and business logic +- **Features**: + - Clean JSON serialization (`json:` tags) + - Validation rules (`validate:` tags) + - Request/Response DTOs + - **Zero GORM dependencies** + - **Zero database annotations** + - Uses constants package for type safety + - Pure business logic methods + +### `/mappers` - Data Transformation +- **Purpose**: Convert between entities and business models +- **Usage**: Bridge between repository and service layers +- **Features**: + - Entity ↔ Model conversion functions + - Request DTO → Entity conversion + - Entity → Response DTO conversion + - Null-safe conversions + - Slice/collection conversions + - Type conversions between constants and entities + +### `/repository` - Data Access Layer +- **Purpose**: Database operations using entities exclusively +- **Usage**: Only works with database entities +- **Features**: + - CRUD operations with entities + - Query methods with entities + - **Private repository implementations** + - Interface-based contracts + - **Never references business models** + +## 🔄 Data Flow + +``` +API Request (JSON) + ↓ +Request DTO (models) + ↓ +Business Logic (services with models + constants) + ↓ +Entity (via mapper) + ↓ +Repository Layer (entities only) + ↓ +Database + ↓ +Entity (from database) + ↓ +Business Model (via mapper) + ↓ +Response DTO (models) + ↓ +API Response (JSON) +``` + +## 🎯 Key Design Principles + +### ✅ **Clean Business Models** +```go + +type User struct { + ID uuid.UUID `json:"id"` + Role constants.UserRole `json:"role"` + } +``` + +```go + +type User struct { + ID uuid.UUID `gorm:"primaryKey" json:"id"` + Role string `gorm:"size:50" json:"role"` + } +``` + +### ✅ **Type-Safe Constants** +```go + +type UserRole string +const ( + RoleAdmin UserRole = "admin" + ) +func IsValidUserRole(role UserRole) bool { /* ... */ } +``` + +```go + +const AdminRole = "admin" ``` + +### ✅ **Repository Isolation** +```go + +func (r *userRepository) Create(ctx context.Context, user *entities.User) error { + return r.db.Create(user).Error +} +``` + +```go + +func (r *userRepository) Create(ctx context.Context, user *models.User) error { + } +``` + +## 📊 Example Usage + +### Service Layer (Business Logic) +```go +func (s *userService) CreateUser(req *models.UserCreateRequest) (*models.UserResponse, error) { + if !constants.IsValidUserRole(req.Role) { + return nil, errors.New("invalid role") + } + + entity := mappers.UserCreateRequestToEntity(req, hashedPassword) + + err := s.userRepo.Create(ctx, entity) + if err != nil { + return nil, err + } + + return mappers.UserEntityToResponse(entity), nil +} +``` + +### Repository Layer (Data Access) +```go +func (r *userRepository) Create(ctx context.Context, user *entities.User) error { + return r.db.WithContext(ctx).Create(user).Error +} +``` + +### Handler Layer (API) +```go +func (h *userHandler) CreateUser(c *gin.Context) { + var req models.UserCreateRequest + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + resp, err := h.userService.CreateUser(&req) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + c.JSON(201, resp) +} +``` + +## 🏗️ Architecture Benefits + +1. **🎯 Single Responsibility**: Each package has one clear purpose +2. **🔒 Zero Database Leakage**: Business logic never sees database concerns +3. **🧪 Testability**: Easy to mock interfaces and test business logic +4. **🔧 Maintainability**: Changes to database don't affect business models +5. **🚀 Flexibility**: Can change ORM without touching business logic +6. **📜 API Stability**: Business models provide stable contracts +7. **🛡️ Type Safety**: Constants package prevents invalid states +8. **🧹 Clean Code**: No mixed concerns anywhere in the codebase + +## 📋 Development Guidelines + +### Constants Package (`/constants`) +- ✅ Define all business enums and constants +- ✅ Provide validation helper functions +- ✅ Include default values and limits +- ❌ Never import database or framework packages +- ❌ No business logic, only constants and validation + +### Models Package (`/models`) +- ✅ Pure business structs with JSON tags only +- ✅ Use constants package for type safety +- ✅ Include validation tags for input validation +- ✅ Separate Request/Response DTOs +- ✅ Add business logic methods (validation, calculations) +- ❌ **NEVER** include GORM tags or database annotations +- ❌ **NEVER** import database packages +- ❌ No database relationships or foreign keys + +### Entities Package (`/entities`) +- ✅ Include GORM tags and database constraints +- ✅ Define relationships and foreign keys +- ✅ Add database hooks (BeforeCreate, etc.) +- ✅ Use database-specific types +- ❌ **NEVER** use in business logic or handlers +- ❌ **NEVER** add business validation rules + +### Mappers Package (`/mappers`) +- ✅ Always check for nil inputs +- ✅ Handle type conversions between constants and strings +- ✅ Provide slice conversion helpers +- ✅ Keep conversions simple and direct +- ❌ No business logic in mappers +- ❌ No database operations + +### Repository Package (`/repository`) +- ✅ Work exclusively with entities +- ✅ Use private repository implementations +- ✅ Provide clean interface contracts +- ❌ **NEVER** reference business models +- ❌ **NEVER** import models package + +## 🚀 Migration Complete + +**All packages have been successfully reorganized:** + +- ✅ **4 Constants files** - All business constants moved to type-safe enums +- ✅ **10 Clean Model files** - Zero GORM dependencies, pure business logic +- ✅ **11 Entity files** - Database-only models with GORM tags +- ✅ **11 Repository files** - Updated to use entities exclusively +- ✅ **2 Mapper files** - Handle conversions between layers +- ✅ **Complete separation** - No cross-layer dependencies + +**The codebase now follows strict clean architecture principles with complete separation of database concerns from business logic!** 🎉 \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..e2a78a8 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,165 @@ +package app + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "eslogad-be/config" + "eslogad-be/internal/client" + "eslogad-be/internal/handler" + "eslogad-be/internal/middleware" + "eslogad-be/internal/processor" + "eslogad-be/internal/repository" + "eslogad-be/internal/router" + "eslogad-be/internal/service" + "eslogad-be/internal/validator" + + "gorm.io/gorm" +) + +type App struct { + server *http.Server + db *gorm.DB + router *router.Router + shutdown chan os.Signal +} + +func NewApp(db *gorm.DB) *App { + return &App{ + db: db, + shutdown: make(chan os.Signal, 1), + } +} + +func (a *App) Initialize(cfg *config.Config) error { + repos := a.initRepositories() + processors := a.initProcessors(cfg, repos) + services := a.initServices(processors, repos, cfg) + middlewares := a.initMiddleware(services) + healthHandler := handler.NewHealthHandler() + fileHandler := handler.NewFileHandler(services.fileService) + + a.router = router.NewRouter( + cfg, + handler.NewAuthHandler(services.authService), + middlewares.authMiddleware, + healthHandler, + handler.NewUserHandler(services.userService, &validator.UserValidatorImpl{}), + fileHandler, + ) + + return nil +} + +func (a *App) Start(port string) error { + engine := a.router.Init() + + a.server = &http.Server{ + Addr: ":" + port, + Handler: engine, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + signal.Notify(a.shutdown, os.Interrupt, syscall.SIGTERM) + + go func() { + log.Printf("Server starting on port %s", port) + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + <-a.shutdown + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := a.server.Shutdown(ctx); err != nil { + log.Printf("Server forced to shutdown: %v", err) + return err + } + + log.Println("Server exited gracefully") + return nil +} + +func (a *App) Shutdown() { + close(a.shutdown) +} + +type repositories struct { + userRepo *repository.UserRepositoryImpl + userProfileRepo *repository.UserProfileRepository + titleRepo *repository.TitleRepository +} + +func (a *App) initRepositories() *repositories { + return &repositories{ + userRepo: repository.NewUserRepository(a.db), + userProfileRepo: repository.NewUserProfileRepository(a.db), + titleRepo: repository.NewTitleRepository(a.db), + } +} + +type processors struct { + userProcessor *processor.UserProcessorImpl +} + +func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { + return &processors{ + userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo), + } +} + +type services struct { + userService *service.UserServiceImpl + authService *service.AuthServiceImpl + fileService *service.FileServiceImpl +} + +func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { + authConfig := cfg.Auth() + jwtSecret := authConfig.AccessTokenSecret() + authService := service.NewAuthService(processors.userProcessor, jwtSecret) + + userSvc := service.NewUserService(processors.userProcessor, repos.titleRepo) + + // File storage client and service + fileCfg := cfg.S3Config + s3Client := client.NewFileClient(fileCfg) + fileSvc := service.NewFileService(s3Client, processors.userProcessor, "profile", "documents") + + return &services{ + userService: userSvc, + authService: authService, + fileService: fileSvc, + } +} + +type middlewares struct { + authMiddleware *middleware.AuthMiddleware +} + +func (a *App) initMiddleware(services *services) *middlewares { + return &middlewares{ + authMiddleware: middleware.NewAuthMiddleware(services.authService), + } +} + +type validators struct { + userValidator *validator.UserValidatorImpl +} + +func (a *App) initValidators() *validators { + return &validators{ + userValidator: validator.NewUserValidator(), + } +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..5017f1a --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,25 @@ +package app + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type Server struct { + *gin.Engine +} + +func generateServerID() string { + return uuid.New().String() +} + +func (s Server) Listen(address string) error { + fmt.Printf("API server listening at: %s\n\n", address) + return s.Run(address) +} + +func (s Server) StartScheduler() { + fmt.Printf("Scheduler started\n") +} diff --git a/internal/appcontext/context.go b/internal/appcontext/context.go new file mode 100644 index 0000000..dd35443 --- /dev/null +++ b/internal/appcontext/context.go @@ -0,0 +1,80 @@ +package appcontext + +import ( + "context" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type key string + +const ( + CorrelationIDKey = key("CorrelationID") + OrganizationIDKey = key("OrganizationIDKey") + UserIDKey = key("UserID") + OutletIDKey = key("OutletID") + RoleIDKey = key("RoleID") + AppVersionKey = key("AppVersion") + AppIDKey = key("AppID") + AppTypeKey = key("AppType") + PlatformKey = key("platform") + DeviceOSKey = key("deviceOS") + UserLocaleKey = key("userLocale") + UserRoleKey = key("userRole") +) + +func LogFields(ctx interface{}) map[string]interface{} { + fields := make(map[string]interface{}) + fields[string(CorrelationIDKey)] = value(ctx, CorrelationIDKey) + fields[string(OrganizationIDKey)] = value(ctx, OrganizationIDKey) + fields[string(OutletIDKey)] = value(ctx, OutletIDKey) + fields[string(AppVersionKey)] = value(ctx, AppVersionKey) + fields[string(AppIDKey)] = value(ctx, AppIDKey) + fields[string(AppTypeKey)] = value(ctx, AppTypeKey) + fields[string(UserIDKey)] = value(ctx, UserIDKey) + fields[string(PlatformKey)] = value(ctx, PlatformKey) + fields[string(DeviceOSKey)] = value(ctx, DeviceOSKey) + fields[string(UserLocaleKey)] = value(ctx, UserLocaleKey) + return fields +} + +func value(ctx interface{}, key key) string { + switch c := ctx.(type) { + case *gin.Context: + return getFromGinContext(c, key) + case context.Context: + return getFromGoContext(c, key) + default: + return "" + } +} + +func uuidValue(ctx interface{}, key key) uuid.UUID { + switch c := ctx.(type) { + case *gin.Context: + val, _ := uuid.Parse(getFromGinContext(c, key)) + return val + case context.Context: + val, _ := uuid.Parse(getFromGoContext(c, key)) + return val + default: + return uuid.New() + } +} + +func getFromGinContext(c *gin.Context, key key) string { + keyStr := string(key) + if val, exists := c.Get(keyStr); exists { + if str, ok := val.(string); ok { + return str + } + } + return getFromGoContext(c.Request.Context(), key) +} + +func getFromGoContext(ctx context.Context, key key) string { + if val, ok := ctx.Value(key).(string); ok { + return val + } + return "" +} diff --git a/internal/appcontext/context_info.go b/internal/appcontext/context_info.go new file mode 100644 index 0000000..63f6365 --- /dev/null +++ b/internal/appcontext/context_info.go @@ -0,0 +1,81 @@ +package appcontext + +import ( + "context" + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +type logCtxKeyType struct{} + +var logCtxKey = logCtxKeyType(struct{}{}) + +type Logger struct { + *logrus.Logger +} + +var log *Logger + +type ContextInfo struct { + CorrelationID string + UserID uuid.UUID + OrganizationID uuid.UUID + OutletID uuid.UUID + AppVersion string + AppID string + AppType string + Platform string + DeviceOS string + UserLocale string + UserRole string +} + +type ctxKeyType struct{} + +var ctxKey = ctxKeyType(struct{}{}) + +func NewAppContext(ctx context.Context, info *ContextInfo) context.Context { + ctx = NewContext(ctx, map[string]interface{}{ + "correlation_id": info.CorrelationID, + "user_id": info.UserID, + "app_version": info.AppVersion, + "app_id": info.AppID, + "app_type": info.AppType, + "platform": info.Platform, + "device_os": info.DeviceOS, + "user_locale": info.UserLocale, + }) + return context.WithValue(ctx, ctxKey, info) +} + +func NewContext(ctx context.Context, baseFields map[string]interface{}) context.Context { + entry, ok := ctx.Value(logCtxKey).(*logrus.Entry) + if !ok { + entry = log.WithFields(map[string]interface{}{}) + } + + return context.WithValue(ctx, logCtxKey, entry.WithFields(baseFields)) +} + +func FromGinContext(ctx context.Context) *ContextInfo { + return &ContextInfo{ + CorrelationID: value(ctx, CorrelationIDKey), + UserID: uuidValue(ctx, UserIDKey), + OutletID: uuidValue(ctx, OutletIDKey), + OrganizationID: uuidValue(ctx, OrganizationIDKey), + AppVersion: value(ctx, AppVersionKey), + AppID: value(ctx, AppIDKey), + AppType: value(ctx, AppTypeKey), + Platform: value(ctx, PlatformKey), + DeviceOS: value(ctx, DeviceOSKey), + UserLocale: value(ctx, UserLocaleKey), + UserRole: value(ctx, UserRoleKey), + } +} + +func FromContext(ctx context.Context) *ContextInfo { + if info, ok := ctx.Value(ctxKey).(*ContextInfo); ok { + return info + } + return nil +} diff --git a/internal/client/s3_file_client.go b/internal/client/s3_file_client.go new file mode 100644 index 0000000..cc780ae --- /dev/null +++ b/internal/client/s3_file_client.go @@ -0,0 +1,84 @@ +package client + +import ( + "bytes" + "context" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" +) + +type FileConfig interface { + GetAccessKeyID() string + GetAccessKeySecret() string + GetEndpoint() string + GetBucketName() string + GetHostURL() string +} + +const _awsRegion = "us-east-1" +const _s3ACL = "public-read" + +type S3FileClientImpl struct { + s3 *s3.S3 + cfg FileConfig +} + +func NewFileClient(fileCfg FileConfig) *S3FileClientImpl { + sess, err := session.NewSession(&aws.Config{ + S3ForcePathStyle: aws.Bool(true), + Endpoint: aws.String(fileCfg.GetEndpoint()), + Region: aws.String(_awsRegion), + Credentials: credentials.NewStaticCredentials(fileCfg.GetAccessKeyID(), fileCfg.GetAccessKeySecret(), ""), + }) + + if err != nil { + fmt.Println("Failed to create AWS session:", err) + return nil + } + + return &S3FileClientImpl{ + s3: s3.New(sess), + cfg: fileCfg, + } +} + +func (r *S3FileClientImpl) UploadFile(ctx context.Context, fileName string, fileContent []byte) (fileUrl string, err error) { + return r.Upload(ctx, r.cfg.GetBucketName(), fileName, fileContent, "application/octet-stream") +} + +func (r *S3FileClientImpl) Upload(ctx context.Context, bucket, key string, content []byte, contentType string) (string, error) { + reader := bytes.NewReader(content) + _, err := r.s3.PutObjectWithContext(ctx, &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: reader, + ACL: aws.String(_s3ACL), + ContentType: aws.String(contentType), + }) + if err != nil { + return "", err + } + return r.GetPublicURL(bucket, key), nil +} + +// EnsureBucket ensures a bucket exists (idempotent) +func (r *S3FileClientImpl) EnsureBucket(ctx context.Context, bucket string) error { + _, err := r.s3.HeadBucketWithContext(ctx, &s3.HeadBucketInput{Bucket: aws.String(bucket)}) + if err == nil { + return nil + } + _, err = r.s3.CreateBucketWithContext(ctx, &s3.CreateBucketInput{Bucket: aws.String(bucket)}) + return err +} + +func (r *S3FileClientImpl) GetPublicURL(bucket, key string) string { + if key == "" { + return "" + } + // HostURL expected to include scheme and optional host/path prefix; ensure single slash join + return fmt.Sprintf("%s%s/%s", r.cfg.GetHostURL(), bucket, key) +} diff --git a/internal/constants/constant.go b/internal/constants/constant.go new file mode 100644 index 0000000..eaa22a6 --- /dev/null +++ b/internal/constants/constant.go @@ -0,0 +1,17 @@ +package constants + +const ( + RequestMethod = "RequestMethod" + RequestPath = "RequestPath" + RequestURLQueryParam = "RequestURLQueryParam" + ResponseStatusCode = "ResponseStatusCode" + ResponseStatusText = "ResponseStatusText" + ResponseTimeTaken = "ResponseTimeTaken" +) + +var ValidCountryCodeMap = map[string]bool{ + "ID": true, + "VI": true, + "SG": true, + "TH": true, +} diff --git a/internal/constants/error.go b/internal/constants/error.go new file mode 100644 index 0000000..4de95bb --- /dev/null +++ b/internal/constants/error.go @@ -0,0 +1,60 @@ +package constants + +import ( + "fmt" + "net/http" +) + +const ( + InternalServerErrorCode = "900" + MissingFieldErrorCode = "303" + MalformedFieldErrorCode = "310" + ValidationErrorCode = "304" + InvalidFieldErrorCode = "305" + NotFoundErrorCode = "404" +) + +const ( + RequestEntity = "request" + UserServiceEntity = "user_service" + OrganizationServiceEntity = "organization_service" + CategoryServiceEntity = "category_service" + ProductServiceEntity = "product_service" + ProductVariantServiceEntity = "product_variant_service" + InventoryServiceEntity = "inventory_service" + OrderServiceEntity = "order_service" + CustomerServiceEntity = "customer_service" + UserValidatorEntity = "user_validator" + AuthHandlerEntity = "auth_handler" + UserHandlerEntity = "user_handler" + CategoryHandlerEntity = "category_handler" + ProductHandlerEntity = "product_handler" + ProductVariantHandlerEntity = "product_variant_handler" + InventoryHandlerEntity = "inventory_handler" + OrderValidatorEntity = "order_validator" + OrderHandlerEntity = "order_handler" + OrganizationValidatorEntity = "organization_validator" + OrgHandlerEntity = "organization_handler" + PaymentMethodValidatorEntity = "payment_method_validator" + PaymentMethodHandlerEntity = "payment_method_handler" + OutletServiceEntity = "outlet_service" + TableEntity = "table" +) + +var HttpErrorMap = map[string]int{ + InternalServerErrorCode: http.StatusInternalServerError, + MissingFieldErrorCode: http.StatusBadRequest, + MalformedFieldErrorCode: http.StatusBadRequest, + ValidationErrorCode: http.StatusBadRequest, + InvalidFieldErrorCode: http.StatusBadRequest, + NotFoundErrorCode: http.StatusNotFound, +} + +// Error messages +var ( + ErrPaymentMethodNameRequired = fmt.Errorf("payment method name is required") + ErrPaymentMethodTypeRequired = fmt.Errorf("payment method type is required") + ErrInvalidPaymentMethodType = fmt.Errorf("invalid payment method type") + ErrInvalidPageNumber = fmt.Errorf("page number must be greater than 0") + ErrInvalidLimit = fmt.Errorf("limit must be between 1 and 100") +) diff --git a/internal/constants/header.go b/internal/constants/header.go new file mode 100644 index 0000000..f2f225b --- /dev/null +++ b/internal/constants/header.go @@ -0,0 +1,27 @@ +package constants + +const ( + CorrelationIDHeader = "debug-id" + XAppVersionHeader = "x-appversion" + XDeviceOSHeader = "X-DeviceOS" + XPlatformHeader = "X-Platform" + XAppTypeHeader = "X-AppType" + XAppIDHeader = "x-appid" + XPhoneModelHeader = "X-PhoneModel" + OrganizationID = "x_organization_id" + OutletID = "x_owner_id" + CountryCodeHeader = "country-code" + AcceptedLanguageHeader = "accept-language" + XUserLocaleHeader = "x-user-locale" + LocaleHeader = "locale" + GojekTimezoneHeader = "Gojek-Timezone" + UserTypeHeader = "User-Type" + AccountIDHeader = "Account-Id" + GopayUserType = "gopay" + XCorrelationIDHeader = "X-Correlation-Id" + XRequestIDHeader = "X-Request-Id" + XCountryCodeHeader = "X-Country-Code" + XAppVersionHeaderPOP = "X-App-Version" + XOwnerIDHeader = "X-Owner-Id" + XAppIDHeaderPOP = "X-App-Id" +) diff --git a/internal/constants/user.go b/internal/constants/user.go new file mode 100644 index 0000000..be7cd27 --- /dev/null +++ b/internal/constants/user.go @@ -0,0 +1,28 @@ +package constants + +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleManager UserRole = "manager" + RoleCashier UserRole = "cashier" + RoleWaiter UserRole = "waiter" +) + +func GetAllUserRoles() []UserRole { + return []UserRole{ + RoleAdmin, + RoleManager, + RoleCashier, + RoleWaiter, + } +} + +func IsValidUserRole(role UserRole) bool { + for _, validRole := range GetAllUserRoles() { + if role == validRole { + return true + } + } + return false +} diff --git a/internal/contract/common.go b/internal/contract/common.go new file mode 100644 index 0000000..11484c8 --- /dev/null +++ b/internal/contract/common.go @@ -0,0 +1,49 @@ +package contract + +import "time" + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` + Code int `json:"code"` +} + +type ValidationErrorResponse struct { + Error string `json:"error"` + Message string `json:"message"` + Details map[string]string `json:"details"` + Code int `json:"code"` +} + +type SuccessResponse struct { + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +type PaginationRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` +} + +type PaginationResponse struct { + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +type SearchRequest struct { + Query string `json:"query,omitempty"` +} + +type DateRangeRequest struct { + From *time.Time `json:"from,omitempty"` + To *time.Time `json:"to,omitempty"` +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version"` +} diff --git a/internal/contract/response.go b/internal/contract/response.go new file mode 100644 index 0000000..cea5488 --- /dev/null +++ b/internal/contract/response.go @@ -0,0 +1,39 @@ +package contract + +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data"` + Errors []*ResponseError `json:"errors"` +} + +func (r *Response) GetSuccess() bool { + return r.Success +} + +func (r *Response) GetData() interface{} { + return r.Data +} + +func (r *Response) GetErrors() []*ResponseError { + return r.Errors +} + +func BuildSuccessResponse(data interface{}) *Response { + return &Response{ + Success: true, + Data: data, + Errors: []*ResponseError(nil), + } +} + +func BuildErrorResponse(errorList []*ResponseError) *Response { + return &Response{ + Success: false, + Data: nil, + Errors: errorList, + } +} + +func (r *Response) HasErrors() bool { + return r.GetErrors() != nil && len(r.GetErrors()) > 0 +} diff --git a/internal/contract/response_error.go b/internal/contract/response_error.go new file mode 100644 index 0000000..542a749 --- /dev/null +++ b/internal/contract/response_error.go @@ -0,0 +1,33 @@ +package contract + +import "fmt" + +type ResponseError struct { + Code string `json:"code"` + Entity string `json:"entity"` + Cause string `json:"cause"` +} + +func NewResponseError(code, entity, cause string) *ResponseError { + return &ResponseError{ + Code: code, + Cause: cause, + Entity: entity, + } +} + +func (e *ResponseError) GetCode() string { + return e.Code +} + +func (e *ResponseError) GetEntity() string { + return e.Entity +} + +func (e *ResponseError) GetCause() string { + return e.Cause +} + +func (e *ResponseError) Error() string { + return fmt.Sprintf("%s: %s: %s", e.GetCode(), e.GetEntity(), e.GetCause()) +} diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go new file mode 100644 index 0000000..bd0f340 --- /dev/null +++ b/internal/contract/user_contract.go @@ -0,0 +1,125 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateUserRequest struct { + OrganizationID uuid.UUID `json:"organization_id" validate:"required"` + OutletID *uuid.UUID `json:"outlet_id,omitempty"` + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"` + Permissions map[string]interface{} `json:"permissions,omitempty"` +} + +type UpdateUserRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"` + IsActive *bool `json:"is_active,omitempty"` + Permissions *map[string]interface{} `json:"permissions,omitempty"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" validate:"required"` + NewPassword string `json:"new_password" validate:"required,min=6"` +} + +type UpdateUserOutletRequest struct { + OutletID uuid.UUID `json:"outlet_id" validate:"required"` +} + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User UserResponse `json:"user"` + Roles []RoleResponse `json:"roles"` + Permissions []string `json:"permissions"` + Positions []PositionResponse `json:"positions"` +} + +type UserResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListUsersRequest struct { + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Role *string `json:"role,omitempty"` + IsActive *bool `json:"is_active,omitempty"` +} + +type ListUsersResponse struct { + Users []UserResponse `json:"users"` + Pagination PaginationResponse `json:"pagination"` +} + +type RoleResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +type PositionResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Path string `json:"path"` +} + +type UserProfileResponse struct { + UserID uuid.UUID `json:"user_id"` + FullName string `json:"full_name"` + DisplayName *string `json:"display_name,omitempty"` + Phone *string `json:"phone,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + JobTitle *string `json:"job_title,omitempty"` + EmployeeNo *string `json:"employee_no,omitempty"` + Bio *string `json:"bio,omitempty"` + Timezone string `json:"timezone"` + Locale string `json:"locale"` + Preferences map[string]interface{} `json:"preferences"` + NotificationPrefs map[string]interface{} `json:"notification_prefs"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpdateUserProfileRequest struct { + FullName *string `json:"full_name,omitempty"` + DisplayName *string `json:"display_name,omitempty"` + Phone *string `json:"phone,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + JobTitle *string `json:"job_title,omitempty"` + EmployeeNo *string `json:"employee_no,omitempty"` + Bio *string `json:"bio,omitempty"` + Timezone *string `json:"timezone,omitempty"` + Locale *string `json:"locale,omitempty"` + Preferences *map[string]interface{} `json:"preferences,omitempty"` + NotificationPrefs *map[string]interface{} `json:"notification_prefs,omitempty"` +} + +type TitleResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code *string `json:"code,omitempty"` + Description *string `json:"description,omitempty"` +} + +type ListTitlesResponse struct { + Titles []TitleResponse `json:"titles"` +} diff --git a/internal/db/database.go b/internal/db/database.go new file mode 100644 index 0000000..773113c --- /dev/null +++ b/internal/db/database.go @@ -0,0 +1,89 @@ +package db + +import ( + "eslogad-be/config" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + + _ "github.com/lib/pq" + "go.uber.org/zap" + _ "gopkg.in/yaml.v3" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func NewPostgres(c config.Database) (*gorm.DB, error) { + dialector := postgres.New(postgres.Config{ + DSN: c.DSN(), + }) + + db, err := gorm.Open(dialector, &gorm.Config{}) + + if err != nil { + return nil, err + } + + zapCfg := zap.NewProductionConfig() + zapCfg.Level = zap.NewAtomicLevelAt(zap.ErrorLevel) + zapCfg.DisableCaller = false + + sqlDB, err := db.DB() + if err != nil { + return nil, err + } + if err := sqlDB.Ping(); err != nil { + return nil, err + } + + sqlDB.SetMaxIdleConns(c.MaxIdleConnectionsInSecond) + sqlDB.SetMaxOpenConns(c.MaxOpenConnectionsInSecond) + sqlDB.SetConnMaxLifetime(c.ConnectionMaxLifetime()) + + fmt.Println("Successfully connected to PostgreSQL database") + + return db, nil +} + +func runMigrations(sqlDB *gorm.DB) error { + // use the underlying *sql.DB for Exec + db := sqlDB + sqlConn, err := db.DB() + if err != nil { + return err + } + + migrationsDir := "migrations" + entries := []string{} + if err := filepath.WalkDir(migrationsDir, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if filepath.Ext(d.Name()) == ".sql" { + entries = append(entries, path) + } + return nil + }); err != nil && !os.IsNotExist(err) { + return err + } + + // sort by name to ensure order + sort.Strings(entries) + + for _, file := range entries { + contents, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("read migration %s: %w", file, err) + } + if _, err := sqlConn.Exec(string(contents)); err != nil { + return fmt.Errorf("exec migration %s: %w", file, err) + } + fmt.Printf("Applied migration: %s\n", filepath.Base(file)) + } + return nil +} diff --git a/internal/entities/rbac.go b/internal/entities/rbac.go new file mode 100644 index 0000000..0485771 --- /dev/null +++ b/internal/entities/rbac.go @@ -0,0 +1,39 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Role struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `gorm:"uniqueIndex;not null" json:"code"` + Description string `json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Role) TableName() string { return "roles" } + +type Permission struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Code string `gorm:"uniqueIndex;not null" json:"code"` + Description string `json:"description"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Permission) TableName() string { return "permissions" } + +type Position struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `gorm:"uniqueIndex" json:"code"` + Path string `gorm:"type:ltree;uniqueIndex" json:"path"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Position) TableName() string { return "positions" } diff --git a/internal/entities/title.go b/internal/entities/title.go new file mode 100644 index 0000000..9e66942 --- /dev/null +++ b/internal/entities/title.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" +) + +type Title struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code *string `gorm:"uniqueIndex" json:"code,omitempty"` + Description *string `json:"description,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (Title) TableName() string { return "titles" } diff --git a/internal/entities/user.go b/internal/entities/user.go new file mode 100644 index 0000000..838b074 --- /dev/null +++ b/internal/entities/user.go @@ -0,0 +1,70 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleManager UserRole = "manager" + RoleCashier UserRole = "cashier" + RoleWaiter UserRole = "waiter" +) + +type Permissions map[string]interface{} + +func (p Permissions) Value() (driver.Value, error) { + return json.Marshal(p) +} + +func (p *Permissions) Scan(value interface{}) error { + if value == nil { + *p = make(Permissions) + return nil + } + + bytes, ok := value.([]byte) + if !ok { + return errors.New("type assertion to []byte failed") + } + + return json.Unmarshal(bytes, p) +} + +type User struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` + Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` + PasswordHash string `gorm:"not null;size:255" json:"-"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (u *User) BeforeCreate(tx *gorm.DB) error { + if u.ID == uuid.Nil { + u.ID = uuid.New() + } + return nil +} + +func (User) TableName() string { + return "users" +} + +func (u *User) HasPermission(permission string) bool { + return false +} + +func (u *User) CanAccessOutlet(outletID uuid.UUID) bool { + + return false +} diff --git a/internal/entities/user_profile.go b/internal/entities/user_profile.go new file mode 100644 index 0000000..114ac4c --- /dev/null +++ b/internal/entities/user_profile.go @@ -0,0 +1,47 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type JSONB map[string]interface{} + +func (j JSONB) Value() (driver.Value, error) { + return json.Marshal(j) +} + +func (j *JSONB) Scan(value interface{}) error { + if value == nil { + *j = make(JSONB) + return nil + } + bytes, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(bytes, j) +} + +type UserProfile struct { + UserID uuid.UUID `gorm:"type:uuid;primaryKey" json:"user_id"` + FullName string `gorm:"not null;size:150" json:"full_name"` + DisplayName *string `gorm:"size:100" json:"display_name,omitempty"` + Phone *string `gorm:"size:50" json:"phone,omitempty"` + AvatarURL *string `json:"avatar_url,omitempty"` + JobTitle *string `gorm:"size:120" json:"job_title,omitempty"` + EmployeeNo *string `gorm:"size:60" json:"employee_no,omitempty"` + Bio *string `json:"bio,omitempty"` + Timezone string `gorm:"size:64;default:Asia/Jakarta" json:"timezone"` + Locale string `gorm:"size:16;default:id-ID" json:"locale"` + Preferences JSONB `gorm:"type:jsonb;default:'{}'" json:"preferences"` + NotificationPrefs JSONB `gorm:"type:jsonb;default:'{}'" json:"notification_prefs"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +func (UserProfile) TableName() string { return "user_profiles" } diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..f5a3217 --- /dev/null +++ b/internal/handler/auth_handler.go @@ -0,0 +1,168 @@ +package handler + +import ( + "eslogad-be/internal/util" + "net/http" + "strings" + + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + authService AuthService +} + +func NewAuthHandler(authService AuthService) *AuthHandler { + return &AuthHandler{ + authService: authService, + } +} + +func (h *AuthHandler) Login(c *gin.Context) { + var req contract.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Login -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + if strings.TrimSpace(req.Email) == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::Login -> email is required") + h.sendValidationErrorResponse(c, "Email is required", constants.MissingFieldErrorCode) + return + } + + if strings.TrimSpace(req.Password) == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::Login -> password is required") + h.sendValidationErrorResponse(c, "Password is required", constants.MissingFieldErrorCode) + return + } + + loginResponse, err := h.authService.Login(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Login -> Failed to login") + h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) + return + } + + logger.FromContext(c.Request.Context()).Infof("AuthHandler::Login -> Successfully logged in user = %s", loginResponse.User.Email) + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(loginResponse), "AuthHandler::Login") +} + +func (h *AuthHandler) Logout(c *gin.Context) { + token := h.extractTokenFromHeader(c) + if token == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::Logout -> token is required") + h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) + return + } + + err := h.authService.Logout(c.Request.Context(), token) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::Logout -> Failed to logout") + h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) + return + } + + logger.FromContext(c.Request.Context()).Info("AuthHandler::Logout -> Successfully logged out") + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Successfully logged out"}) +} + +func (h *AuthHandler) RefreshToken(c *gin.Context) { + token := h.extractTokenFromHeader(c) + if token == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::RefreshToken -> token is required") + h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) + return + } + + loginResponse, err := h.authService.RefreshToken(c.Request.Context(), token) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::RefreshToken -> Failed to refresh token") + h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) + return + } + + logger.FromContext(c.Request.Context()).Infof("AuthHandler::RefreshToken -> Successfully refreshed token for user = %s", loginResponse.User.Email) + c.JSON(http.StatusOK, loginResponse) +} + +func (h *AuthHandler) ValidateToken(c *gin.Context) { + token := h.extractTokenFromHeader(c) + if token == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::ValidateToken -> token is required") + h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) + return + } + + userResponse, err := h.authService.ValidateToken(token) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::ValidateToken -> Failed to validate token") + h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) + return + } + + logger.FromContext(c.Request.Context()).Infof("AuthHandler::ValidateToken -> Successfully validated token for user = %s", userResponse.Email) + c.JSON(http.StatusOK, userResponse) +} + +func (h *AuthHandler) GetProfile(c *gin.Context) { + token := h.extractTokenFromHeader(c) + if token == "" { + logger.FromContext(c.Request.Context()).Error("AuthHandler::GetProfile -> token is required") + h.sendErrorResponse(c, "Token is required", http.StatusUnauthorized) + return + } + + userResponse, err := h.authService.ValidateToken(token) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthHandler::GetProfile -> Failed to get profile") + h.sendErrorResponse(c, err.Error(), http.StatusUnauthorized) + return + } + + logger.FromContext(c.Request.Context()).Infof("AuthHandler::GetProfile -> Successfully retrieved profile for user = %s", userResponse.Email) + c.JSON(http.StatusOK, &contract.SuccessResponse{Data: userResponse, Message: "success get user profile"}) +} + +func (h *AuthHandler) extractTokenFromHeader(c *gin.Context) string { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + return "" + } + + // Expected format: "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return "" + } + + return parts[1] +} + +func (h *AuthHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { + errorResponse := &contract.ErrorResponse{ + Error: "error", + Message: message, + Code: statusCode, + } + c.JSON(statusCode, errorResponse) +} + +func (h *AuthHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) { + errorResponse := &contract.ErrorResponse{ + Error: "validation_error", + Message: message, + Code: http.StatusBadRequest, + Details: map[string]interface{}{ + "error_code": errorCode, + "entity": constants.AuthHandlerEntity, + }, + } + c.JSON(http.StatusBadRequest, errorResponse) +} diff --git a/internal/handler/auth_service.go b/internal/handler/auth_service.go new file mode 100644 index 0000000..fd8f65d --- /dev/null +++ b/internal/handler/auth_service.go @@ -0,0 +1,13 @@ +package handler + +import ( + "context" + "eslogad-be/internal/contract" +) + +type AuthService interface { + Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error) + ValidateToken(tokenString string) (*contract.UserResponse, error) + RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) + Logout(ctx context.Context, tokenString string) error +} diff --git a/internal/handler/common.go b/internal/handler/common.go new file mode 100644 index 0000000..eb801a2 --- /dev/null +++ b/internal/handler/common.go @@ -0,0 +1,57 @@ +package handler + +import ( + "net/http" + "time" +) + +type CommonMiddleware struct{} + +func NewCommonMiddleware() *CommonMiddleware { + return &CommonMiddleware{} +} + +func (m *CommonMiddleware) CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (m *CommonMiddleware) ContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +func (m *CommonMiddleware) Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + next.ServeHTTP(w, r) + + _ = time.Since(start) + }) +} + +func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + }() + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/handler/file_handler.go b/internal/handler/file_handler.go new file mode 100644 index 0000000..c00b18c --- /dev/null +++ b/internal/handler/file_handler.go @@ -0,0 +1,78 @@ +package handler + +import ( + "context" + "io" + "net/http" + + "eslogad-be/internal/appcontext" + "eslogad-be/internal/contract" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type FileService interface { + UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error) + UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) +} + +type FileHandler struct { + service FileService +} + +func NewFileHandler(service FileService) *FileHandler { + return &FileHandler{service: service} +} + +func (h *FileHandler) UploadProfileAvatar(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized}) + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest}) + return + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, 10<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest}) + return + } + ct := header.Header.Get("Content-Type") + url, err := h.service.UploadProfileAvatar(c.Request.Context(), appCtx.UserID, header.Filename, content, ct) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url})) +} + +func (h *FileHandler) UploadDocument(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + c.JSON(http.StatusUnauthorized, &contract.ErrorResponse{Error: "Unauthorized", Code: http.StatusUnauthorized}) + return + } + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "file is required", Code: http.StatusBadRequest}) + return + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, 20<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "failed to read file", Code: http.StatusBadRequest}) + return + } + ct := header.Header.Get("Content-Type") + url, key, err := h.service.UploadDocument(c.Request.Context(), appCtx.UserID, header.Filename, content, ct) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]string{"url": url, "key": key})) +} diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..ae2b821 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,23 @@ +package handler + +import ( + "eslogad-be/internal/logger" + "net/http" + + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { +} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (hh *HealthHandler) HealthCheck(c *gin.Context) { + log := logger.NewContextLogger(c, "healthCheck") + log.Info("Health Check success") + c.JSON(http.StatusOK, gin.H{ + "status": "Healthy!!", + }) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go new file mode 100644 index 0000000..4ca7c84 --- /dev/null +++ b/internal/handler/user_handler.go @@ -0,0 +1,306 @@ +package handler + +import ( + "net/http" + "strconv" + + "eslogad-be/internal/appcontext" + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UserHandler struct { + userService UserService + userValidator UserValidator +} + +func NewUserHandler(userService UserService, userValidator UserValidator) *UserHandler { + return &UserHandler{ + userService: userService, + userValidator: userValidator, + } +} + +func (h *UserHandler) CreateUser(c *gin.Context) { + var req contract.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + validationError, validationErrorCode := h.userValidator.ValidateCreateUserRequest(&req) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::CreateUser -> request validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + userResponse, err := h.userService.CreateUser(c.Request.Context(), &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::CreateUser -> Failed to create user from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("UserHandler::CreateUser -> Successfully created user = %+v", userResponse) + c.JSON(http.StatusCreated, userResponse) +} + +func (h *UserHandler) UpdateUser(c *gin.Context) { + userIDStr := c.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> Invalid user ID") + h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) + return + } + + validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUser -> user ID validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + var req contract.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + validationError, validationErrorCode = h.userValidator.ValidateUpdateUserRequest(&req) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::UpdateUser -> request validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + userResponse, err := h.userService.UpdateUser(c.Request.Context(), userID, &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::UpdateUser -> Failed to update user from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("UserHandler::UpdateUser -> Successfully updated user = %+v", userResponse) + c.JSON(http.StatusOK, userResponse) +} + +func (h *UserHandler) DeleteUser(c *gin.Context) { + userIDStr := c.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::DeleteUser -> Invalid user ID") + h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) + return + } + + validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::DeleteUser -> user ID validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + err = h.userService.DeleteUser(c.Request.Context(), userID) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::DeleteUser -> Failed to delete user from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Info("UserHandler::DeleteUser -> Successfully deleted user") + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "User deleted successfully"}) +} + +func (h *UserHandler) GetUser(c *gin.Context) { + userIDStr := c.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::GetUser -> Invalid user ID") + h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) + return + } + + validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::GetUser -> user ID validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + userResponse, err := h.userService.GetUserByID(c.Request.Context(), userID) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::GetUser -> Failed to get user from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("UserHandler::GetUser -> Successfully retrieved user = %+v", userResponse) + c.JSON(http.StatusOK, userResponse) +} + +func (h *UserHandler) ListUsers(c *gin.Context) { + ctx := c.Request.Context() + + req := &contract.ListUsersRequest{ + Page: 1, + Limit: 10, + } + + if page := c.Query("page"); page != "" { + if p, err := strconv.Atoi(page); err == nil { + req.Page = p + } + } + + if limit := c.Query("limit"); limit != "" { + if l, err := strconv.Atoi(limit); err == nil { + req.Limit = l + } + } + + if role := c.Query("role"); role != "" { + req.Role = &role + } + + if isActiveStr := c.Query("is_active"); isActiveStr != "" { + if isActive, err := strconv.ParseBool(isActiveStr); err == nil { + req.IsActive = &isActive + } + } + + validationError, validationErrorCode := h.userValidator.ValidateListUsersRequest(req) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::ListUsers -> request validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + usersResponse, err := h.userService.ListUsers(ctx, req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::ListUsers -> Failed to list users from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Infof("UserHandler::ListUsers -> Successfully listed users = %+v", usersResponse) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(usersResponse)) +} + +func (h *UserHandler) ChangePassword(c *gin.Context) { + userIDStr := c.Param("id") + userID, err := uuid.Parse(userIDStr) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Invalid user ID") + h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode) + return + } + + validationError, validationErrorCode := h.userValidator.ValidateUserID(userID) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> user ID validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + var req contract.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> request binding failed") + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + + validationError, validationErrorCode = h.userValidator.ValidateChangePasswordRequest(&req) + if validationError != nil { + logger.FromContext(c).WithError(validationError).Error("UserHandler::ChangePassword -> request validation failed") + h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode) + return + } + + err = h.userService.ChangePassword(c.Request.Context(), userID, &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::ChangePassword -> Failed to change password from service") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + + logger.FromContext(c).Info("UserHandler::ChangePassword -> Successfully changed password") + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "Password changed successfully"}) +} + +func (h *UserHandler) GetProfile(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) + return + } + profile, err := h.userService.GetProfile(c.Request.Context(), appCtx.UserID) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::GetProfile -> Failed to get profile") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(profile)) +} + +func (h *UserHandler) UpdateProfile(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + if appCtx.UserID == uuid.Nil { + h.sendErrorResponse(c, "Unauthorized", http.StatusUnauthorized) + return + } + var req contract.UpdateUserProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.sendValidationErrorResponse(c, "Invalid request body", constants.MissingFieldErrorCode) + return + } + updated, err := h.userService.UpdateProfile(c.Request.Context(), appCtx.UserID, &req) + if err != nil { + logger.FromContext(c).WithError(err).Error("UserHandler::UpdateProfile -> Failed to update profile") + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(updated)) +} + +func (h *UserHandler) ListTitles(c *gin.Context) { + resp, err := h.userService.ListTitles(c.Request.Context()) + if err != nil { + h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { + errorResponse := &contract.ErrorResponse{ + Error: message, + Code: statusCode, + Details: map[string]interface{}{}, + } + c.JSON(statusCode, errorResponse) +} + +func (h *UserHandler) sendValidationErrorResponse(c *gin.Context, message string, errorCode string) { + statusCode := constants.HttpErrorMap[errorCode] + if statusCode == 0 { + statusCode = http.StatusBadRequest + } + + errorResponse := &contract.ErrorResponse{ + Error: message, + Code: statusCode, + Details: map[string]interface{}{ + "error_code": errorCode, + "entity": constants.UserValidatorEntity, + }, + } + c.JSON(statusCode, errorResponse) +} diff --git a/internal/handler/user_service.go b/internal/handler/user_service.go new file mode 100644 index 0000000..d420fec --- /dev/null +++ b/internal/handler/user_service.go @@ -0,0 +1,23 @@ +package handler + +import ( + "context" + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +type UserService interface { + CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) + UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) + DeleteUser(ctx context.Context, id uuid.UUID) error + GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) + GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) + ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) + ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error + + GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) + UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) + + ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) +} diff --git a/internal/handler/user_validator.go b/internal/handler/user_validator.go new file mode 100644 index 0000000..a294244 --- /dev/null +++ b/internal/handler/user_validator.go @@ -0,0 +1,16 @@ +package handler + +import ( + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +type UserValidator interface { + ValidateCreateUserRequest(req *contract.CreateUserRequest) (error, string) + ValidateUpdateUserRequest(req *contract.UpdateUserRequest) (error, string) + ValidateListUsersRequest(req *contract.ListUsersRequest) (error, string) + ValidateChangePasswordRequest(req *contract.ChangePasswordRequest) (error, string) + ValidateUserID(userID uuid.UUID) (error, string) + ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string) +} diff --git a/internal/logger/app_logger.go b/internal/logger/app_logger.go new file mode 100644 index 0000000..9d3f449 --- /dev/null +++ b/internal/logger/app_logger.go @@ -0,0 +1,134 @@ +package logger + +import ( + "context" + "eslogad-be/internal/appcontext" + "log" + "os" + + "github.com/sirupsen/logrus" +) + +type logCtxKeyType struct{} + +var logCtxKey = logCtxKeyType(struct{}{}) + +var logger *logrus.Logger + +const ( + LogMethod = "Method" + LogError = "Error" +) + +func Setup(logLevel, logFormat string) { + level, err := logrus.ParseLevel(logLevel) + if err != nil { + log.Fatal(err.Error()) + } + + logger = &logrus.Logger{ + Out: os.Stdout, + Hooks: make(logrus.LevelHooks), + Level: level, + Formatter: &logrus.JSONFormatter{}, + } + + if logFormat != "json" { + logger.Formatter = &logrus.TextFormatter{} + } + NonContext = &ContextLogger{ + entry: logrus.NewEntry(logger), + } +} + +type ContextLogger struct { + entry *logrus.Entry +} + +var NonContext *ContextLogger + +func NewContextLogger(ctx interface{}, method string) *ContextLogger { + logEntry := logger.WithFields(appcontext.LogFields(ctx)).WithField(LogMethod, method) + return &ContextLogger{ + entry: logEntry, + } +} + +func (l *ContextLogger) Fatal(errMessage string, err error) { + l.entry. + WithField(LogError, err). + Fatal(errMessage) +} + +func (l *ContextLogger) Error(errMessage string, err error) { + l.entry.WithField(LogError, err).Error(errMessage) +} + +func (l *ContextLogger) Errorf(err error, errMessageFormat string, errMessages ...interface{}) { + l.entry.WithField(LogError, err).Errorf(errMessageFormat, errMessages...) +} + +func (l *ContextLogger) ErrorWithFields(msg string, fields map[string]interface{}, err error) { + for key, val := range fields { + l.entry = l.entry.WithField(key, val) + } + + l.entry.WithField(LogError, err).Error(msg) +} + +func (l *ContextLogger) Info(msg string) { + l.entry.Info(msg) + +} + +func (l *ContextLogger) Infof(msg string, args ...interface{}) { + l.entry.Infof(msg, args...) +} + +func (l *ContextLogger) Debugf(msg string, args ...interface{}) { + l.entry.Debugf(msg, args...) +} + +func (l *ContextLogger) Debug(msg string) { + l.entry.Debug(msg) +} + +func (l *ContextLogger) InfoWithFields(msg string, fields map[string]interface{}) { + for key, val := range fields { + l.entry = l.entry.WithField(key, val) + } + + l.entry.Info(msg) +} + +func (l *ContextLogger) DebugWithFields(msg string, fields map[string]interface{}) { + for key, val := range fields { + l.entry = l.entry.WithField(key, val) + } + + l.entry.Debug(msg) +} + +func (l *ContextLogger) Warn(msg string) { + l.entry.Warn(msg) +} + +func (l *ContextLogger) Warnf(msg string, args ...interface{}) { + l.entry.Warnf(msg, args...) +} + +func (l *ContextLogger) WarnWithFields(msg string, fields map[string]interface{}, err error) { + for key, val := range fields { + l.entry = l.entry.WithField(key, val) + } + + l.entry.WithField(LogError, err) + l.entry.Warn(msg) +} + +func FromContext(ctx context.Context) *logrus.Entry { + if entry, ok := ctx.Value(logCtxKey).(*logrus.Entry); ok { + return entry + } + return logger.WithFields(map[string]interface{}{}) +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go new file mode 100644 index 0000000..8161499 --- /dev/null +++ b/internal/middleware/auth_middleware.go @@ -0,0 +1,170 @@ +package middleware + +import ( + "eslogad-be/internal/appcontext" + "net/http" + "strings" + + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + + "github.com/gin-gonic/gin" +) + +type AuthMiddleware struct { + authService AuthValidateService +} + +func NewAuthMiddleware(authService AuthValidateService) *AuthMiddleware { + return &AuthMiddleware{ + authService: authService, + } +} + +func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + token := m.extractTokenFromHeader(c) + if token == "" { + logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireAuth -> Missing authorization token") + m.sendErrorResponse(c, "Authorization token is required", http.StatusUnauthorized) + c.Abort() + return + } + + userResponse, err := m.authService.ValidateToken(token) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("AuthMiddleware::RequireAuth -> Invalid token") + m.sendErrorResponse(c, "Invalid or expired token", http.StatusUnauthorized) + c.Abort() + return + } + + setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) + + if roles, perms, err := m.authService.ExtractAccess(token); err == nil { + c.Set("user_roles", roles) + c.Set("user_permissions", perms) + } + + logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireAuth -> User authenticated: %s", userResponse.Email) + c.Next() + } +} + +func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + appCtx := appcontext.FromGinContext(c.Request.Context()) + + hasRequiredRole := false + for _, role := range allowedRoles { + if appCtx.UserRole == role { + hasRequiredRole = true + break + } + } + + if !hasRequiredRole { + m.sendErrorResponse(c, "Insufficient permissions", http.StatusForbidden) + c.Abort() + return + } + + c.Next() + } +} + +func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { + return m.RequireRole("admin", "manager") +} + +func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { + return m.RequireRole("admin") +} + +func (m *AuthMiddleware) RequireSuperAdmin() gin.HandlerFunc { + return m.RequireRole("superadmin") +} + +func (m *AuthMiddleware) RequireActiveUser() gin.HandlerFunc { + return func(c *gin.Context) { + userResponse, exists := c.Get("user") + if !exists { + logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> User not authenticated") + m.sendErrorResponse(c, "Authentication required", http.StatusUnauthorized) + c.Abort() + return + } + + user, ok := userResponse.(*contract.UserResponse) + if !ok { + logger.FromContext(c.Request.Context()).Error("AuthMiddleware::RequireActiveUser -> Invalid user context") + m.sendErrorResponse(c, "Invalid user context", http.StatusInternalServerError) + c.Abort() + return + } + + if !user.IsActive { + logger.FromContext(c.Request.Context()).Errorf("AuthMiddleware::RequireActiveUser -> User account is deactivated: %s", user.Email) + m.sendErrorResponse(c, "User account is deactivated", http.StatusForbidden) + c.Abort() + return + } + + logger.FromContext(c.Request.Context()).Infof("AuthMiddleware::RequireActiveUser -> Active user check passed: %s", user.Email) + c.Next() + } +} + +func (m *AuthMiddleware) RequirePermissions(required ...string) gin.HandlerFunc { + return func(c *gin.Context) { + if _, exists := c.Get("user_permissions"); !exists { + m.sendErrorResponse(c, "Authentication required", http.StatusUnauthorized) + c.Abort() + return + } + + permIface, _ := c.Get("user_permissions") + perms, _ := permIface.([]string) + userPerms := map[string]bool{} + for _, code := range perms { + userPerms[code] = true + } + + for _, need := range required { + if !userPerms[need] { + m.sendErrorResponse(c, "Insufficient permissions", http.StatusForbidden) + c.Abort() + return + } + } + + c.Next() + } +} + +func (m *AuthMiddleware) extractTokenFromHeader(c *gin.Context) string { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + return "" + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return "" + } + + return parts[1] +} + +func (m *AuthMiddleware) sendErrorResponse(c *gin.Context, message string, statusCode int) { + errorResponse := &contract.ErrorResponse{ + Error: "auth_error", + Message: message, + Code: statusCode, + Details: map[string]interface{}{ + "entity": constants.AuthHandlerEntity, + }, + } + c.JSON(statusCode, errorResponse) +} diff --git a/internal/middleware/auth_processor.go b/internal/middleware/auth_processor.go new file mode 100644 index 0000000..916acfe --- /dev/null +++ b/internal/middleware/auth_processor.go @@ -0,0 +1,4 @@ +package middleware + +type AuthProcessor interface { +} diff --git a/internal/middleware/auth_service.go b/internal/middleware/auth_service.go new file mode 100644 index 0000000..189ca64 --- /dev/null +++ b/internal/middleware/auth_service.go @@ -0,0 +1,13 @@ +package middleware + +import ( + "context" + "eslogad-be/internal/contract" +) + +type AuthValidateService interface { + ValidateToken(tokenString string) (*contract.UserResponse, error) + RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) + Logout(ctx context.Context, tokenString string) error + ExtractAccess(tokenString string) (roles []string, permissions []string, err error) +} diff --git a/internal/middleware/context.go b/internal/middleware/context.go new file mode 100644 index 0000000..60ed530 --- /dev/null +++ b/internal/middleware/context.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "context" + "eslogad-be/internal/appcontext" + "eslogad-be/internal/constants" + "github.com/gin-gonic/gin" +) + +func PopulateContext() gin.HandlerFunc { + return func(c *gin.Context) { + setKeyInContext(c, appcontext.AppIDKey, getAppID(c)) + setKeyInContext(c, appcontext.AppVersionKey, getAppVersion(c)) + setKeyInContext(c, appcontext.AppTypeKey, getAppType(c)) + setKeyInContext(c, appcontext.OrganizationIDKey, getOrganizationID(c)) + setKeyInContext(c, appcontext.OutletIDKey, getOutletID(c)) + setKeyInContext(c, appcontext.DeviceOSKey, getDeviceOS(c)) + setKeyInContext(c, appcontext.PlatformKey, getDevicePlatform(c)) + setKeyInContext(c, appcontext.UserLocaleKey, getUserLocale(c)) + c.Next() + } +} + +func getAppID(c *gin.Context) string { + return c.GetHeader(constants.XAppIDHeader) +} + +func getAppType(c *gin.Context) string { + return c.GetHeader(constants.XAppTypeHeader) +} + +func getAppVersion(c *gin.Context) string { + return c.GetHeader(constants.XAppVersionHeader) +} + +func getOrganizationID(c *gin.Context) string { + return c.GetHeader(constants.OrganizationID) +} + +func getOutletID(c *gin.Context) string { + return c.GetHeader(constants.OutletID) +} + +func getDeviceOS(c *gin.Context) string { + return c.GetHeader(constants.XDeviceOSHeader) +} + +func getDevicePlatform(c *gin.Context) string { + return c.GetHeader(constants.XPlatformHeader) +} + +func getUserLocale(c *gin.Context) string { + userLocale := c.GetHeader(constants.XUserLocaleHeader) + if userLocale == "" { + userLocale = c.GetHeader(constants.AcceptedLanguageHeader) + } + if userLocale == "" { + userLocale = c.GetHeader(constants.LocaleHeader) + } + return userLocale +} + +func setKeyInContext(c *gin.Context, contextKey interface{}, contextKeyValue string) { + ctx := context.WithValue(c.Request.Context(), + contextKey, contextKeyValue) + c.Request = c.Request.WithContext(ctx) +} diff --git a/internal/middleware/correlation_id.go b/internal/middleware/correlation_id.go new file mode 100644 index 0000000..4629465 --- /dev/null +++ b/internal/middleware/correlation_id.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "context" + "eslogad-be/internal/appcontext" + "eslogad-be/internal/constants" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func CorrelationID() gin.HandlerFunc { + return func(c *gin.Context) { + correlationID := c.GetHeader(constants.CorrelationIDHeader) + if correlationID == "" { + correlationID = uuid.New().String() + } + ctx := context.WithValue(c.Request.Context(), appcontext.CorrelationIDKey, correlationID) + c.Request = c.Request.WithContext(ctx) + c.Writer.Header().Set(constants.CorrelationIDHeader, correlationID) + c.Next() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..224079b --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + return gin.HandlerFunc(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + }) +} diff --git a/internal/middleware/json.go b/internal/middleware/json.go new file mode 100644 index 0000000..1dfd0e7 --- /dev/null +++ b/internal/middleware/json.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +const ( + contentTypeHeader = "Content-Type" + jsonContentType = "application/json" +) + +func JsonAPI() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set(contentTypeHeader, jsonContentType) + c.Next() + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..a5ff917 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "fmt" + "time" + + "github.com/gin-gonic/gin" +) + +func Logging() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + return fmt.Sprintf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n", + param.ClientIP, + param.TimeStamp.Format(time.RFC1123), + param.Method, + param.Path, + param.Request.Proto, + param.StatusCode, + param.Latency, + param.Request.UserAgent(), + param.ErrorMessage, + ) + }) +} diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go new file mode 100644 index 0000000..1186e64 --- /dev/null +++ b/internal/middleware/rate_limit.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type RateLimiter struct { + requests map[string][]time.Time + mutex sync.RWMutex + limit int + window time.Duration +} + +func NewRateLimiter(limit int, window time.Duration) *RateLimiter { + return &RateLimiter{ + requests: make(map[string][]time.Time), + limit: limit, + window: window, + } +} + +func (rl *RateLimiter) Allow(key string) bool { + rl.mutex.Lock() + defer rl.mutex.Unlock() + + now := time.Now() + windowStart := now.Add(-rl.window) + + // Clean old requests + if times, exists := rl.requests[key]; exists { + var validTimes []time.Time + for _, t := range times { + if t.After(windowStart) { + validTimes = append(validTimes, t) + } + } + rl.requests[key] = validTimes + } + + // Check if limit exceeded + if len(rl.requests[key]) >= rl.limit { + return false + } + + // Add current request + rl.requests[key] = append(rl.requests[key], now) + return true +} + +func RateLimit() gin.HandlerFunc { + limiter := NewRateLimiter(100, time.Minute) // 100 requests per minute + + return gin.HandlerFunc(func(c *gin.Context) { + clientIP := c.ClientIP() + + if !limiter.Allow(clientIP) { + c.JSON(429, gin.H{ + "error": "Rate limit exceeded", + }) + c.Abort() + return + } + + c.Next() + }) +} diff --git a/internal/middleware/recover.go b/internal/middleware/recover.go new file mode 100644 index 0000000..32ea840 --- /dev/null +++ b/internal/middleware/recover.go @@ -0,0 +1,30 @@ +package middleware + +import ( + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + "eslogad-be/internal/util" + "github.com/gin-gonic/gin" + "net/http" + "runtime/debug" +) + +func Recover() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + logger.NonContext.Errorf(nil, "Recovered from panic %v", map[string]interface{}{ + "stack_trace": string(debug.Stack()), + "error": err, + }) + debug.PrintStack() + errorResponse := contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError("900", "", string(debug.Stack())), + }) + util.WriteResponse(c.Writer, c.Request, *errorResponse, http.StatusInternalServerError, "Middleware::Recover") + c.Abort() + } + }() + c.Next() + } +} diff --git a/internal/middleware/stat_logger.go b/internal/middleware/stat_logger.go new file mode 100644 index 0000000..74311c5 --- /dev/null +++ b/internal/middleware/stat_logger.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "eslogad-be/internal/constants" + "eslogad-be/internal/logger" + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "time" +) + +func HTTPStatLogger() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.URL.Path == "/health" { + c.Next() + return + } + + start := time.Now() + c.Next() + duration := time.Since(start) + + status := c.Writer.Status() + + log := logger.NewContextLogger(c, "HTTPStatLogger") + log.Infof("CompletedHTTPRequest %v", map[string]string{ + constants.RequestMethod: c.Request.Method, + constants.RequestPath: c.Request.URL.Path, + constants.RequestURLQueryParam: c.Request.URL.RawQuery, + constants.ResponseStatusCode: fmt.Sprintf("%d", status), + constants.ResponseStatusText: http.StatusText(status), + constants.ResponseTimeTaken: fmt.Sprintf("%f", duration.Seconds()), + }) + } +} diff --git a/internal/middleware/user_id_resolver.go b/internal/middleware/user_id_resolver.go new file mode 100644 index 0000000..3aff85b --- /dev/null +++ b/internal/middleware/user_id_resolver.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type UserIDResolver struct { + userProcessor UserProcessor + authProcessor AuthProcessor +} + +func NewUserIDResolver(userProcessor UserProcessor, authProcessor AuthProcessor) *UserIDResolver { + return &UserIDResolver{ + userProcessor: userProcessor, + authProcessor: authProcessor, + } +} + +func (uir *UserIDResolver) Handle() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + } +} + +func (uir *UserIDResolver) resolveUserID(c *gin.Context, userID uuid.UUID) (*contract.UserResponse, error) { + user, err := uir.userProcessor.GetUserByID(c.Request.Context(), userID) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("UserIDResolver::resolveGopayUserID -> userID could not be resolved") + return nil, err + } + return user, nil +} + +func (uir *UserIDResolver) validate(c *gin.Context, tokenString string) string { + return "" +} diff --git a/internal/middleware/user_processor.go b/internal/middleware/user_processor.go new file mode 100644 index 0000000..54e552e --- /dev/null +++ b/internal/middleware/user_processor.go @@ -0,0 +1,12 @@ +package middleware + +import ( + "context" + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +type UserProcessor interface { + GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) +} diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go new file mode 100644 index 0000000..f253bc1 --- /dev/null +++ b/internal/processor/user_processor.go @@ -0,0 +1,251 @@ +package processor + +import ( + "context" + "fmt" + + "golang.org/x/crypto/bcrypt" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type UserProcessorImpl struct { + userRepo UserRepository + profileRepo UserProfileRepository +} + +type UserProfileRepository interface { + GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) + Create(ctx context.Context, profile *entities.UserProfile) error + Upsert(ctx context.Context, profile *entities.UserProfile) error + Update(ctx context.Context, profile *entities.UserProfile) error +} + +func NewUserProcessor( + userRepo UserRepository, + profileRepo UserProfileRepository, +) *UserProcessorImpl { + return &UserProcessorImpl{ + userRepo: userRepo, + profileRepo: profileRepo, + } +} + +func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) { + existingUser, err := p.userRepo.GetByEmail(ctx, req.Email) + if err == nil && existingUser != nil { + return nil, fmt.Errorf("user with email %s already exists", req.Email) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + userEntity := transformer.CreateUserRequestToEntity(req, string(passwordHash)) + + err = p.userRepo.Create(ctx, userEntity) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + // create default user profile + defaultFullName := userEntity.Name + profile := &entities.UserProfile{ + UserID: userEntity.ID, + FullName: defaultFullName, + Timezone: "Asia/Jakarta", + Locale: "id-ID", + Preferences: entities.JSONB{}, + NotificationPrefs: entities.JSONB{}, + } + _ = p.profileRepo.Create(ctx, profile) + + return transformer.EntityToContract(userEntity), nil +} + +func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { + existingUser, err := p.userRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + if req.Email != nil && *req.Email != existingUser.Email { + existingUserByEmail, err := p.userRepo.GetByEmail(ctx, *req.Email) + if err == nil && existingUserByEmail != nil && existingUserByEmail.ID != id { + return nil, fmt.Errorf("user with email %s already exists", *req.Email) + } + } + + updated := transformer.UpdateUserEntity(existingUser, req) + + err = p.userRepo.Update(ctx, updated) + if err != nil { + return nil, fmt.Errorf("failed to update user: %w", err) + } + + return transformer.EntityToContract(updated), nil +} + +func (p *UserProcessorImpl) DeleteUser(ctx context.Context, id uuid.UUID) error { + _, err := p.userRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + err = p.userRepo.Delete(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + return nil +} + +func (p *UserProcessorImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + user, err := p.userRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + return transformer.EntityToContract(user), nil +} + +func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { + user, err := p.userRepo.GetByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + return transformer.EntityToContract(user), nil +} + +func (p *UserProcessorImpl) ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error) { + offset := (page - 1) * limit + + filters := map[string]interface{}{} + + users, totalCount, err := p.userRepo.List(ctx, filters, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get users: %w", err) + } + + responses := transformer.EntitiesToContracts(users) + return responses, int(totalCount), nil +} + +func (p *UserProcessorImpl) GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) { + user, err := p.userRepo.GetByEmail(ctx, email) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + return user, nil +} + +func (p *UserProcessorImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error { + user, err := p.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)) + if err != nil { + return fmt.Errorf("current password is incorrect") + } + + newPasswordHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash new password: %w", err) + } + + err = p.userRepo.UpdatePassword(ctx, userID, string(newPasswordHash)) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + +func (p *UserProcessorImpl) ActivateUser(ctx context.Context, userID uuid.UUID) error { + _, err := p.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + err = p.userRepo.UpdateActiveStatus(ctx, userID, true) + if err != nil { + return fmt.Errorf("failed to activate user: %w", err) + } + + return nil +} + +func (p *UserProcessorImpl) DeactivateUser(ctx context.Context, userID uuid.UUID) error { + _, err := p.userRepo.GetByID(ctx, userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + err = p.userRepo.UpdateActiveStatus(ctx, userID, false) + if err != nil { + return fmt.Errorf("failed to deactivate user: %w", err) + } + + return nil +} + +// RBAC implementations +func (p *UserProcessorImpl) GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) { + roles, err := p.userRepo.GetRolesByUserID(ctx, userID) + if err != nil { + return nil, err + } + return transformer.RolesToContract(roles), nil +} + +func (p *UserProcessorImpl) GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) { + perms, err := p.userRepo.GetPermissionsByUserID(ctx, userID) + if err != nil { + return nil, err + } + codes := make([]string, 0, len(perms)) + for _, p := range perms { + codes = append(codes, p.Code) + } + return codes, nil +} + +func (p *UserProcessorImpl) GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) { + positions, err := p.userRepo.GetPositionsByUserID(ctx, userID) + if err != nil { + return nil, err + } + return transformer.PositionsToContract(positions), nil +} + +func (p *UserProcessorImpl) GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { + prof, err := p.profileRepo.GetByUserID(ctx, userID) + if err != nil { + return nil, err + } + return transformer.ProfileEntityToContract(prof), nil +} + +func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { + existing, _ := p.profileRepo.GetByUserID(ctx, userID) + entity := transformer.ProfileUpdateToEntity(userID, req, existing) + if existing == nil { + if err := p.profileRepo.Create(ctx, entity); err != nil { + return nil, err + } + } else { + if err := p.profileRepo.Update(ctx, entity); err != nil { + return nil, err + } + } + return transformer.ProfileEntityToContract(entity), nil +} diff --git a/internal/processor/user_repository.go b/internal/processor/user_repository.go new file mode 100644 index 0000000..ea0fb66 --- /dev/null +++ b/internal/processor/user_repository.go @@ -0,0 +1,25 @@ +package processor + +import ( + "context" + "eslogad-be/internal/entities" + "github.com/google/uuid" +) + +type UserRepository interface { + Create(ctx context.Context, user *entities.User) error + GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) + GetByEmail(ctx context.Context, email string) (*entities.User, error) + GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) + GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) + Update(ctx context.Context, user *entities.User) error + Delete(ctx context.Context, id uuid.UUID) error + UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error + UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error + List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) + Count(ctx context.Context, filters map[string]interface{}) (int64, error) + + GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) + GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) + GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error) +} diff --git a/internal/repository/title_repository.go b/internal/repository/title_repository.go new file mode 100644 index 0000000..23909a2 --- /dev/null +++ b/internal/repository/title_repository.go @@ -0,0 +1,25 @@ +package repository + +import ( + "context" + + "eslogad-be/internal/entities" + + "gorm.io/gorm" +) + +type TitleRepository struct { + db *gorm.DB +} + +func NewTitleRepository(db *gorm.DB) *TitleRepository { + return &TitleRepository{db: db} +} + +func (r *TitleRepository) ListAll(ctx context.Context) ([]entities.Title, error) { + var titles []entities.Title + if err := r.db.WithContext(ctx).Order("name ASC").Find(&titles).Error; err != nil { + return nil, err + } + return titles, nil +} diff --git a/internal/repository/user_profile_repository.go b/internal/repository/user_profile_repository.go new file mode 100644 index 0000000..fca1d60 --- /dev/null +++ b/internal/repository/user_profile_repository.go @@ -0,0 +1,45 @@ +package repository + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" +) + +type UserProfileRepository struct { + db *gorm.DB +} + +func NewUserProfileRepository(db *gorm.DB) *UserProfileRepository { + return &UserProfileRepository{db: db} +} + +func (r *UserProfileRepository) GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) { + var p entities.UserProfile + if err := r.db.WithContext(ctx).First(&p, "user_id = ?", userID).Error; err != nil { + return nil, err + } + return &p, nil +} + +func (r *UserProfileRepository) Create(ctx context.Context, profile *entities.UserProfile) error { + return r.db.WithContext(ctx).Create(profile).Error +} + +func (r *UserProfileRepository) Upsert(ctx context.Context, profile *entities.UserProfile) error { + return r.db.WithContext(ctx).Clauses( + clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}}, + DoUpdates: clause.AssignmentColumns([]string{"full_name", "display_name", "phone", "avatar_url", "job_title", "employee_no", "bio", "timezone", "locale", "preferences", "notification_prefs"}), + }, + ).Create(profile).Error +} + +func (r *UserProfileRepository) Update(ctx context.Context, profile *entities.UserProfile) error { + return r.db.WithContext(ctx).Model(&entities.UserProfile{}).Where("user_id = ?", profile.UserID).Updates(profile).Error +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..02e033e --- /dev/null +++ b/internal/repository/user_repository.go @@ -0,0 +1,141 @@ +package repository + +import ( + "context" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserRepositoryImpl struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepositoryImpl { + return &UserRepositoryImpl{ + db: db, + } +} + +func (r *UserRepositoryImpl) Create(ctx context.Context, user *entities.User) error { + return r.db.WithContext(ctx).Create(user).Error +} + +func (r *UserRepositoryImpl) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) { + var user entities.User + err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entities.User, error) { + var user entities.User + err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepositoryImpl) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) { + var users []*entities.User + err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error + return users, err +} + +func (r *UserRepositoryImpl) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) { + var users []*entities.User + err := r.db.WithContext(ctx). + Where(" is_active = ?", organizationID, true). + Find(&users).Error + return users, err +} + +func (r *UserRepositoryImpl) Update(ctx context.Context, user *entities.User) error { + return r.db.WithContext(ctx).Save(user).Error +} + +func (r *UserRepositoryImpl) Delete(ctx context.Context, id uuid.UUID) error { + return r.db.WithContext(ctx).Delete(&entities.User{}, "id = ?", id).Error +} + +func (r *UserRepositoryImpl) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error { + return r.db.WithContext(ctx).Model(&entities.User{}). + Where("id = ?", id). + Update("password_hash", passwordHash).Error +} + +func (r *UserRepositoryImpl) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error { + return r.db.WithContext(ctx).Model(&entities.User{}). + Where("id = ?", id). + Update("is_active", isActive).Error +} + +func (r *UserRepositoryImpl) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) { + var users []*entities.User + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.User{}) + + for key, value := range filters { + query = query.Where(key+" = ?", value) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + err := query.Limit(limit).Offset(offset).Find(&users).Error + return users, total, err +} + +func (r *UserRepositoryImpl) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { + var count int64 + query := r.db.WithContext(ctx).Model(&entities.User{}) + + for key, value := range filters { + query = query.Where(key+" = ?", value) + } + + err := query.Count(&count).Error + return count, err +} + +// RBAC helpers +func (r *UserRepositoryImpl) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { + var roles []entities.Role + err := r.db.WithContext(ctx). + Table("roles as r"). + Select("r.*"). + Joins("JOIN user_role ur ON ur.role_id = r.id AND ur.removed_at IS NULL"). + Where("ur.user_id = ?", userID). + Find(&roles).Error + return roles, err +} + +func (r *UserRepositoryImpl) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) { + var perms []entities.Permission + err := r.db.WithContext(ctx). + Table("permissions as p"). + Select("DISTINCT p.*"). + Joins("JOIN role_permissions rp ON rp.permission_id = p.id"). + Joins("JOIN user_role ur ON ur.role_id = rp.role_id AND ur.removed_at IS NULL"). + Where("ur.user_id = ?", userID). + Find(&perms).Error + return perms, err +} + +func (r *UserRepositoryImpl) GetPositionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Position, error) { + var positions []entities.Position + err := r.db.WithContext(ctx). + Table("positions as p"). + Select("p.*"). + Joins("JOIN user_position up ON up.position_id = p.id AND up.removed_at IS NULL"). + Where("up.user_id = ?", userID). + Find(&positions).Error + return positions, err +} diff --git a/internal/router/auth_handler.go b/internal/router/auth_handler.go new file mode 100644 index 0000000..2324674 --- /dev/null +++ b/internal/router/auth_handler.go @@ -0,0 +1,9 @@ +package router + +import "github.com/gin-gonic/gin" + +type AuthHandler interface { + Login(c *gin.Context) + RefreshToken(c *gin.Context) + GetProfile(c *gin.Context) +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go new file mode 100644 index 0000000..83d9ab2 --- /dev/null +++ b/internal/router/health_handler.go @@ -0,0 +1,20 @@ +package router + +import "github.com/gin-gonic/gin" + +type HealthHandler interface { + HealthCheck(c *gin.Context) +} + +type UserHandler interface { + ListUsers(c *gin.Context) + GetProfile(c *gin.Context) + UpdateProfile(c *gin.Context) + ChangePassword(c *gin.Context) + ListTitles(c *gin.Context) +} + +type FileHandler interface { + UploadProfileAvatar(c *gin.Context) + UploadDocument(c *gin.Context) +} diff --git a/internal/router/middleware.go b/internal/router/middleware.go new file mode 100644 index 0000000..6828d7e --- /dev/null +++ b/internal/router/middleware.go @@ -0,0 +1,13 @@ +package router + +import "github.com/gin-gonic/gin" + +type AuthMiddleware interface { + RequireAuth() gin.HandlerFunc + RequireRole(allowedRoles ...string) gin.HandlerFunc + RequireAdminOrManager() gin.HandlerFunc + RequireAdmin() gin.HandlerFunc + RequireSuperAdmin() gin.HandlerFunc + RequireActiveUser() gin.HandlerFunc + RequirePermissions(required ...string) gin.HandlerFunc +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..b839f4b --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,81 @@ +package router + +import ( + "eslogad-be/config" + "eslogad-be/internal/middleware" + + "github.com/gin-gonic/gin" +) + +type Router struct { + config *config.Config + authHandler AuthHandler + healthHandler HealthHandler + authMiddleware AuthMiddleware + userHandler UserHandler + fileHandler FileHandler +} + +func NewRouter( + cfg *config.Config, + authHandler AuthHandler, + authMiddleware AuthMiddleware, + healthHandler HealthHandler, + userHandler UserHandler, + fileHandler FileHandler, +) *Router { + return &Router{ + config: cfg, + authHandler: authHandler, + authMiddleware: authMiddleware, + healthHandler: healthHandler, + userHandler: userHandler, + fileHandler: fileHandler, + } +} + +func (r *Router) Init() *gin.Engine { + gin.SetMode(gin.ReleaseMode) + engine := gin.New() + engine.Use( + middleware.JsonAPI(), + middleware.CorrelationID(), + middleware.Recover(), + middleware.HTTPStatLogger(), + middleware.PopulateContext(), + ) + + r.addAppRoutes(engine) + return engine +} + +func (r *Router) addAppRoutes(rg *gin.Engine) { + rg.GET("/health", r.healthHandler.HealthCheck) + + v1 := rg.Group("/api/v1") + { + auth := v1.Group("/auth") + { + auth.POST("/login", r.authHandler.Login) + auth.POST("/refresh", r.authHandler.RefreshToken) + auth.GET("/profile", r.authHandler.GetProfile) + } + + users := v1.Group("/users") + users.Use(r.authMiddleware.RequireAuth()) + { + users.GET("", r.authMiddleware.RequirePermissions("user.view"), r.userHandler.ListUsers) + users.GET("/profile", r.userHandler.GetProfile) + users.PUT("/profile", r.userHandler.UpdateProfile) + users.PUT(":id/password", r.userHandler.ChangePassword) + users.GET("/titles", r.userHandler.ListTitles) + users.POST("/profile/avatar", r.fileHandler.UploadProfileAvatar) + } + + files := v1.Group("/files") + files.Use(r.authMiddleware.RequireAuth()) + { + files.POST("/documents", r.fileHandler.UploadDocument) + } + } +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..cc08589 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,194 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "eslogad-be/internal/contract" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +type AuthServiceImpl struct { + userProcessor UserProcessor + jwtSecret string + tokenTTL time.Duration +} + +type Claims struct { + UserID uuid.UUID `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + jwt.RegisteredClaims +} + +func NewAuthService(userProcessor UserProcessor, jwtSecret string) *AuthServiceImpl { + return &AuthServiceImpl{ + userProcessor: userProcessor, + jwtSecret: jwtSecret, + tokenTTL: 24 * time.Hour, + } +} + +func (s *AuthServiceImpl) Login(ctx context.Context, req *contract.LoginRequest) (*contract.LoginResponse, error) { + userResponse, err := s.userProcessor.GetUserByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("invalid credentials") + } + + if !userResponse.IsActive { + return nil, fmt.Errorf("user account is deactivated") + } + + userEntity, err := s.userProcessor.GetUserEntityByEmail(ctx, req.Email) + if err != nil { + return nil, fmt.Errorf("invalid credentials") + } + + err = bcrypt.CompareHashAndPassword([]byte(userEntity.PasswordHash), []byte(req.Password)) + if err != nil { + return nil, fmt.Errorf("invalid credentials") + } + + // fetch roles, permissions, positions for response and token + roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID) + permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID) + positions, _ := s.userProcessor.GetUserPositions(ctx, userResponse.ID) + + token, expiresAt, err := s.generateToken(userResponse, roles, permCodes) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &contract.LoginResponse{ + Token: token, + ExpiresAt: expiresAt, + User: *userResponse, + Roles: roles, + Permissions: permCodes, + Positions: positions, + }, nil +} + +func (s *AuthServiceImpl) ValidateToken(tokenString string) (*contract.UserResponse, error) { + claims, err := s.parseToken(tokenString) + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + userResponse, err := s.userProcessor.GetUserByID(context.Background(), claims.UserID) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + if !userResponse.IsActive { + return nil, fmt.Errorf("user account is deactivated") + } + + return userResponse, nil +} + +func (s *AuthServiceImpl) RefreshToken(ctx context.Context, tokenString string) (*contract.LoginResponse, error) { + claims, err := s.parseToken(tokenString) + if err != nil { + return nil, fmt.Errorf("invalid token: %w", err) + } + + userResponse, err := s.userProcessor.GetUserByID(ctx, claims.UserID) + if err != nil { + return nil, fmt.Errorf("user not found: %w", err) + } + + if !userResponse.IsActive { + return nil, fmt.Errorf("user account is deactivated") + } + + roles, _ := s.userProcessor.GetUserRoles(ctx, userResponse.ID) + permCodes, _ := s.userProcessor.GetUserPermissionCodes(ctx, userResponse.ID) + newToken, expiresAt, err := s.generateToken(userResponse, roles, permCodes) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + positions, _ := s.userProcessor.GetUserPositions(ctx, userResponse.ID) + return &contract.LoginResponse{ + Token: newToken, + ExpiresAt: expiresAt, + User: *userResponse, + Roles: roles, + Permissions: permCodes, + Positions: positions, + }, nil +} + +func (s *AuthServiceImpl) Logout(ctx context.Context, tokenString string) error { + _, err := s.parseToken(tokenString) + if err != nil { + return fmt.Errorf("invalid token: %w", err) + } + return nil +} + +func (s *AuthServiceImpl) generateToken(user *contract.UserResponse, roles []contract.RoleResponse, permissionCodes []string) (string, time.Time, error) { + expiresAt := time.Now().Add(s.tokenTTL) + + roleCodes := make([]string, 0, len(roles)) + for _, r := range roles { + roleCodes = append(roleCodes, r.Code) + } + + claims := &Claims{ + UserID: user.ID, + Email: user.Email, + Roles: roleCodes, + Permissions: permissionCodes, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "eslogad-be", + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(s.jwtSecret)) + if err != nil { + return "", time.Time{}, err + } + + return tokenString, expiresAt, nil +} + +func (s *AuthServiceImpl) parseToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.jwtSecret), nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, errors.New("invalid token") +} + +func (s *AuthServiceImpl) ExtractAccess(tokenString string) (roles []string, permissions []string, err error) { + claims, err := s.parseToken(tokenString) + if err != nil { + return nil, nil, err + } + return claims.Roles, claims.Permissions, nil +} diff --git a/internal/service/file_service.go b/internal/service/file_service.go new file mode 100644 index 0000000..a7bc4c1 --- /dev/null +++ b/internal/service/file_service.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + "path/filepath" + "strings" + "time" + + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +type FileStorage interface { + Upload(ctx context.Context, bucket, key string, content []byte, contentType string) (string, error) + EnsureBucket(ctx context.Context, bucket string) error +} + +type FileServiceImpl struct { + storage FileStorage + userProcessor UserProcessor + profileBucket string + docBucket string +} + +func NewFileService(storage FileStorage, userProcessor UserProcessor, profileBucket, docBucket string) *FileServiceImpl { + return &FileServiceImpl{storage: storage, userProcessor: userProcessor, profileBucket: profileBucket, docBucket: docBucket} +} + +func (s *FileServiceImpl) UploadProfileAvatar(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, error) { + if err := s.storage.EnsureBucket(ctx, s.profileBucket); err != nil { + return "", err + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + if ext := mimeExtFromContentType(contentType); ext != "" { + ext = ext + } + key := buildObjectKey("profile", userID, ext) + url, err := s.storage.Upload(ctx, s.profileBucket, key, content, contentType) + if err != nil { + return "", err + } + + _, _ = s.userProcessor.UpdateUserProfile(ctx, userID, &contract.UpdateUserProfileRequest{AvatarURL: &url}) + return url, nil +} + +func (s *FileServiceImpl) UploadDocument(ctx context.Context, userID uuid.UUID, filename string, content []byte, contentType string) (string, string, error) { + if err := s.storage.EnsureBucket(ctx, s.docBucket); err != nil { + return "", "", err + } + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + if ext := mimeExtFromContentType(contentType); ext != "" { + ext = ext + } + key := buildObjectKey("documents", userID, ext) + url, err := s.storage.Upload(ctx, s.docBucket, key, content, contentType) + if err != nil { + return "", "", err + } + return url, key, nil +} + +func buildObjectKey(prefix string, userID uuid.UUID, ext string) string { + now := time.Now().UTC() + parts := []string{ + prefix, + userID.String(), + now.Format("2006/01/02"), + uuid.New().String(), + } + key := strings.Join(parts, "/") + if ext != "" { + key += "." + ext + } + return key +} + +func mimeExtFromContentType(ct string) string { + switch strings.ToLower(ct) { + case "image/jpeg", "image/jpg": + return "jpg" + case "image/png": + return "png" + case "image/webp": + return "webp" + case "application/pdf": + return "pdf" + default: + return "" + } +} diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go new file mode 100644 index 0000000..57a554d --- /dev/null +++ b/internal/service/user_processor.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + + "github.com/google/uuid" +) + +type UserProcessor interface { + UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) + CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) + DeleteUser(ctx context.Context, id uuid.UUID) error + GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) + GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) + ListUsers(ctx context.Context, page, limit int) ([]contract.UserResponse, int, error) + GetUserEntityByEmail(ctx context.Context, email string) (*entities.User, error) + ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error + + GetUserRoles(ctx context.Context, userID uuid.UUID) ([]contract.RoleResponse, error) + GetUserPermissionCodes(ctx context.Context, userID uuid.UUID) ([]string, error) + GetUserPositions(ctx context.Context, userID uuid.UUID) ([]contract.PositionResponse, error) + + GetUserProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) + UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go new file mode 100644 index 0000000..2c7aeb1 --- /dev/null +++ b/internal/service/user_service.go @@ -0,0 +1,91 @@ +package service + +import ( + "context" + + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "eslogad-be/internal/transformer" + + "github.com/google/uuid" +) + +type UserServiceImpl struct { + userProcessor UserProcessor + titleRepo TitleRepository +} + +type TitleRepository interface { + ListAll(ctx context.Context) ([]entities.Title, error) +} + +func NewUserService(userProcessor UserProcessor, titleRepo TitleRepository) *UserServiceImpl { + return &UserServiceImpl{ + userProcessor: userProcessor, + titleRepo: titleRepo, + } +} + +func (s *UserServiceImpl) CreateUser(ctx context.Context, req *contract.CreateUserRequest) (*contract.UserResponse, error) { + return s.userProcessor.CreateUser(ctx, req) +} + +func (s *UserServiceImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *contract.UpdateUserRequest) (*contract.UserResponse, error) { + return s.userProcessor.UpdateUser(ctx, id, req) +} + +func (s *UserServiceImpl) DeleteUser(ctx context.Context, id uuid.UUID) error { + return s.userProcessor.DeleteUser(ctx, id) +} + +func (s *UserServiceImpl) GetUserByID(ctx context.Context, id uuid.UUID) (*contract.UserResponse, error) { + return s.userProcessor.GetUserByID(ctx, id) +} + +func (s *UserServiceImpl) GetUserByEmail(ctx context.Context, email string) (*contract.UserResponse, error) { + return s.userProcessor.GetUserByEmail(ctx, email) +} + +func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) { + page := req.Page + if page <= 0 { + page = 1 + } + limit := req.Limit + if limit <= 0 { + limit = 10 + } + + userResponses, totalCount, err := s.userProcessor.ListUsers(ctx, page, limit) + if err != nil { + return nil, err + } + + return &contract.ListUsersResponse{ + Users: userResponses, + Pagination: transformer.CreatePaginationResponse(totalCount, page, limit), + }, nil +} + +func (s *UserServiceImpl) ChangePassword(ctx context.Context, userID uuid.UUID, req *contract.ChangePasswordRequest) error { + return s.userProcessor.ChangePassword(ctx, userID, req) +} + +func (s *UserServiceImpl) GetProfile(ctx context.Context, userID uuid.UUID) (*contract.UserProfileResponse, error) { + return s.userProcessor.GetUserProfile(ctx, userID) +} + +func (s *UserServiceImpl) UpdateProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error) { + return s.userProcessor.UpdateUserProfile(ctx, userID, req) +} + +func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesResponse, error) { + if s.titleRepo == nil { + return &contract.ListTitlesResponse{Titles: []contract.TitleResponse{}}, nil + } + titles, err := s.titleRepo.ListAll(ctx) + if err != nil { + return nil, err + } + return &contract.ListTitlesResponse{Titles: transformer.TitlesToContract(titles)}, nil +} diff --git a/internal/transformer/common_transformer.go b/internal/transformer/common_transformer.go new file mode 100644 index 0000000..1db67bb --- /dev/null +++ b/internal/transformer/common_transformer.go @@ -0,0 +1,172 @@ +package transformer + +import ( + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" + "math" + + "github.com/google/uuid" +) + +func PaginationToRequest(page, limit int) (int, int) { + if page < 1 { + page = 1 + } + if limit < 1 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + return page, limit +} + +func CreatePaginationResponse(totalCount, page, limit int) contract.PaginationResponse { + totalPages := int(math.Ceil(float64(totalCount) / float64(limit))) + if totalPages < 1 { + totalPages = 1 + } + + return contract.PaginationResponse{ + TotalCount: totalCount, + Page: page, + Limit: limit, + TotalPages: totalPages, + } +} + +func CreateListUsersResponse(users []contract.UserResponse, totalCount, page, limit int) *contract.ListUsersResponse { + pagination := CreatePaginationResponse(totalCount, page, limit) + return &contract.ListUsersResponse{ + Users: users, + Pagination: pagination, + } +} + +func CreateErrorResponse(message string, code int) *contract.ErrorResponse { + return &contract.ErrorResponse{ + Error: "error", + Message: message, + Code: code, + } +} + +func CreateValidationErrorResponse(message string, details map[string]string) *contract.ValidationErrorResponse { + return &contract.ValidationErrorResponse{ + Error: "validation_error", + Message: message, + Details: details, + Code: 400, + } +} + +func CreateSuccessResponse(message string, data interface{}) *contract.SuccessResponse { + return &contract.SuccessResponse{ + Message: message, + Data: data, + } +} + +func RolesToContract(roles []entities.Role) []contract.RoleResponse { + if roles == nil { + return nil + } + res := make([]contract.RoleResponse, 0, len(roles)) + for _, r := range roles { + res = append(res, contract.RoleResponse{ID: r.ID, Name: r.Name, Code: r.Code}) + } + return res +} + +func PositionsToContract(positions []entities.Position) []contract.PositionResponse { + if positions == nil { + return nil + } + res := make([]contract.PositionResponse, 0, len(positions)) + for _, p := range positions { + res = append(res, contract.PositionResponse{ID: p.ID, Name: p.Name, Code: p.Code, Path: p.Path}) + } + return res +} + +func ProfileEntityToContract(p *entities.UserProfile) *contract.UserProfileResponse { + if p == nil { + return nil + } + return &contract.UserProfileResponse{ + UserID: p.UserID, + FullName: p.FullName, + DisplayName: p.DisplayName, + Phone: p.Phone, + AvatarURL: p.AvatarURL, + JobTitle: p.JobTitle, + EmployeeNo: p.EmployeeNo, + Bio: p.Bio, + Timezone: p.Timezone, + Locale: p.Locale, + Preferences: map[string]interface{}(p.Preferences), + NotificationPrefs: map[string]interface{}(p.NotificationPrefs), + LastSeenAt: p.LastSeenAt, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} + +func ProfileUpdateToEntity(userID uuid.UUID, req *contract.UpdateUserProfileRequest, existing *entities.UserProfile) *entities.UserProfile { + prof := &entities.UserProfile{} + if existing != nil { + *prof = *existing + } else { + prof.UserID = userID + } + if req.FullName != nil { + prof.FullName = *req.FullName + } + if req.DisplayName != nil { + prof.DisplayName = req.DisplayName + } + if req.Phone != nil { + prof.Phone = req.Phone + } + if req.AvatarURL != nil { + prof.AvatarURL = req.AvatarURL + } + if req.JobTitle != nil { + prof.JobTitle = req.JobTitle + } + if req.EmployeeNo != nil { + prof.EmployeeNo = req.EmployeeNo + } + if req.Bio != nil { + prof.Bio = req.Bio + } + if req.Timezone != nil { + prof.Timezone = *req.Timezone + } + if req.Locale != nil { + prof.Locale = *req.Locale + } + if req.Preferences != nil { + prof.Preferences = entities.JSONB(*req.Preferences) + } + if req.NotificationPrefs != nil { + prof.NotificationPrefs = entities.JSONB(*req.NotificationPrefs) + } + return prof +} + +func TitlesToContract(titles []entities.Title) []contract.TitleResponse { + if titles == nil { + return nil + } + out := make([]contract.TitleResponse, 0, len(titles)) + for _, t := range titles { + out = append(out, contract.TitleResponse{ + ID: t.ID, + Name: t.Name, + Code: t.Code, + Description: t.Description, + }) + } + return out +} diff --git a/internal/transformer/user_transformer.go b/internal/transformer/user_transformer.go new file mode 100644 index 0000000..94ede7d --- /dev/null +++ b/internal/transformer/user_transformer.go @@ -0,0 +1,62 @@ +package transformer + +import ( + "eslogad-be/internal/contract" + "eslogad-be/internal/entities" +) + +func CreateUserRequestToEntity(req *contract.CreateUserRequest, passwordHash string) *entities.User { + if req == nil { + return nil + } + return &entities.User{ + Name: req.Name, + Email: req.Email, + PasswordHash: passwordHash, + IsActive: true, + } +} + +func UpdateUserEntity(existing *entities.User, req *contract.UpdateUserRequest) *entities.User { + if existing == nil || req == nil { + return existing + } + if req.Name != nil { + existing.Name = *req.Name + } + if req.Email != nil { + existing.Email = *req.Email + } + if req.IsActive != nil { + existing.IsActive = *req.IsActive + } + return existing +} + +func EntityToContract(user *entities.User) *contract.UserResponse { + if user == nil { + return nil + } + return &contract.UserResponse{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + IsActive: user.IsActive, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +func EntitiesToContracts(users []*entities.User) []contract.UserResponse { + if users == nil { + return nil + } + responses := make([]contract.UserResponse, len(users)) + for i, u := range users { + resp := EntityToContract(u) + if resp != nil { + responses[i] = *resp + } + } + return responses +} diff --git a/internal/util/date_util.go b/internal/util/date_util.go new file mode 100644 index 0000000..e9459f5 --- /dev/null +++ b/internal/util/date_util.go @@ -0,0 +1,72 @@ +package util + +import ( + "time" +) + +const DateFormatDDMMYYYY = "02-01-2006" + +// ParseDateToJakartaTime parses a date string in DD-MM-YYYY format and converts it to Jakarta timezone +// Returns start of day (00:00:00) in Jakarta timezone +func ParseDateToJakartaTime(dateStr string) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + date, err := time.Parse(DateFormatDDMMYYYY, dateStr) + if err != nil { + return nil, err + } + + jakartaLoc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, err + } + + jakartaTime := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, jakartaLoc) + return &jakartaTime, nil +} + +// ParseDateToJakartaTimeEndOfDay parses a date string in DD-MM-YYYY format and converts it to Jakarta timezone +// Returns end of day (23:59:59.999999999) in Jakarta timezone +func ParseDateToJakartaTimeEndOfDay(dateStr string) (*time.Time, error) { + if dateStr == "" { + return nil, nil + } + + date, err := time.Parse(DateFormatDDMMYYYY, dateStr) + if err != nil { + return nil, err + } + + jakartaLoc, err := time.LoadLocation("Asia/Jakarta") + if err != nil { + return nil, err + } + + jakartaTime := time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 999999999, jakartaLoc) + return &jakartaTime, nil +} + +// ParseDateRangeToJakartaTime parses date_from and date_to strings and returns them in Jakarta timezone +// date_from will be start of day (00:00:00), date_to will be end of day (23:59:59.999999999) +func ParseDateRangeToJakartaTime(dateFrom, dateTo string) (*time.Time, *time.Time, error) { + var fromTime, toTime *time.Time + var err error + + if dateFrom != "" { + fromTime, err = ParseDateToJakartaTime(dateFrom) + if err != nil { + return nil, nil, err + } + } + + if dateTo != "" { + toTime, err = ParseDateToJakartaTimeEndOfDay(dateTo) + if err != nil { + return nil, nil, err + } + } + + return fromTime, toTime, nil +} diff --git a/internal/util/date_util_test.go b/internal/util/date_util_test.go new file mode 100644 index 0000000..54ff167 --- /dev/null +++ b/internal/util/date_util_test.go @@ -0,0 +1,211 @@ +package util + +import ( + "testing" + "time" +) + +func TestParseDateToJakartaTime(t *testing.T) { + tests := []struct { + name string + dateStr string + expected *time.Time + hasError bool + }{ + { + name: "valid date", + dateStr: "06-08-2025", + expected: nil, // Will be set during test + hasError: false, + }, + { + name: "empty string", + dateStr: "", + expected: nil, + hasError: false, + }, + { + name: "invalid date format", + dateStr: "2025-08-06", + hasError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDateToJakartaTime(tt.dateStr) + + if tt.hasError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tt.expected == nil && tt.dateStr == "" { + if result != nil { + t.Errorf("Expected nil but got %v", result) + } + return + } + + if result == nil && tt.dateStr != "" { + t.Errorf("Expected time but got nil") + return + } + + // Check if it's in Jakarta timezone + jakartaLoc, _ := time.LoadLocation("Asia/Jakarta") + if result.Location().String() != jakartaLoc.String() { + t.Errorf("Expected Jakarta timezone but got %v", result.Location()) + } + + // Check if it's start of day + if result.Hour() != 0 || result.Minute() != 0 || result.Second() != 0 { + t.Errorf("Expected start of day but got %v", result.Format("15:04:05")) + } + }) + } +} + +func TestParseDateToJakartaTimeEndOfDay(t *testing.T) { + tests := []struct { + name string + dateStr string + expected *time.Time + hasError bool + }{ + { + name: "valid date", + dateStr: "06-08-2025", + expected: nil, // Will be set during test + hasError: false, + }, + { + name: "empty string", + dateStr: "", + expected: nil, + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ParseDateToJakartaTimeEndOfDay(tt.dateStr) + + if tt.hasError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if tt.expected == nil && tt.dateStr == "" { + if result != nil { + t.Errorf("Expected nil but got %v", result) + } + return + } + + if result == nil && tt.dateStr != "" { + t.Errorf("Expected time but got nil") + return + } + + // Check if it's in Jakarta timezone + jakartaLoc, _ := time.LoadLocation("Asia/Jakarta") + if result.Location().String() != jakartaLoc.String() { + t.Errorf("Expected Jakarta timezone but got %v", result.Location()) + } + + // Check if it's end of day + if result.Hour() != 23 || result.Minute() != 59 || result.Second() != 59 { + t.Errorf("Expected end of day but got %v", result.Format("15:04:05")) + } + }) + } +} + +func TestParseDateRangeToJakartaTime(t *testing.T) { + tests := []struct { + name string + dateFrom string + dateTo string + hasError bool + }{ + { + name: "valid date range", + dateFrom: "06-08-2025", + dateTo: "06-08-2025", + hasError: false, + }, + { + name: "empty strings", + dateFrom: "", + dateTo: "", + hasError: false, + }, + { + name: "only date_from", + dateFrom: "06-08-2025", + dateTo: "", + hasError: false, + }, + { + name: "only date_to", + dateFrom: "", + dateTo: "06-08-2025", + hasError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fromTime, toTime, err := ParseDateRangeToJakartaTime(tt.dateFrom, tt.dateTo) + + if tt.hasError { + if err == nil { + t.Errorf("Expected error but got none") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + // If dateFrom is provided, check it's start of day + if tt.dateFrom != "" && fromTime != nil { + jakartaLoc, _ := time.LoadLocation("Asia/Jakarta") + if fromTime.Location().String() != jakartaLoc.String() { + t.Errorf("Expected Jakarta timezone for date_from but got %v", fromTime.Location()) + } + if fromTime.Hour() != 0 || fromTime.Minute() != 0 || fromTime.Second() != 0 { + t.Errorf("Expected start of day for date_from but got %v", fromTime.Format("15:04:05")) + } + } + + // If dateTo is provided, check it's end of day + if tt.dateTo != "" && toTime != nil { + jakartaLoc, _ := time.LoadLocation("Asia/Jakarta") + if toTime.Location().String() != jakartaLoc.String() { + t.Errorf("Expected Jakarta timezone for date_to but got %v", toTime.Location()) + } + if toTime.Hour() != 23 || toTime.Minute() != 59 || toTime.Second() != 59 { + t.Errorf("Expected end of day for date_to but got %v", toTime.Format("15:04:05")) + } + } + }) + } +} diff --git a/internal/util/http_util.go b/internal/util/http_util.go new file mode 100644 index 0000000..759229a --- /dev/null +++ b/internal/util/http_util.go @@ -0,0 +1,49 @@ +package util + +import ( + "encoding/json" + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + "eslogad-be/internal/logger" + "net/http" + "net/url" +) + +func HandleResponse(w http.ResponseWriter, r *http.Request, response *contract.Response, methodName string) { + var statusCode int + if response.GetSuccess() { + statusCode = http.StatusOK + } else { + responseError := response.GetErrors()[0] + statusCode = MapErrorCodeToHttpStatus(responseError.GetCode()) + } + WriteResponse(w, r, *response, statusCode, methodName) +} + +func WriteResponse(w http.ResponseWriter, r *http.Request, resp contract.Response, statusCode int, methodName string) { + w.WriteHeader(statusCode) + response, err := json.Marshal(resp) + if err != nil { + logger.FromContext(r.Context()).Error(methodName, "unable to marshal json response", err) + } + _, err = w.Write(response) + if err != nil { + logger.FromContext(r.Context()).Error(methodName, "unable to write to response", err) + } +} + +func MapErrorCodeToHttpStatus(code string) int { + statusCode := constants.HttpErrorMap[code] + if statusCode == 0 { + return http.StatusInternalServerError + } + return statusCode +} + +func ExtractEndpointFromURL(requestURL string) string { + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "/" + } + return parsedURL.Path +} diff --git a/internal/validator/user_validator.go b/internal/validator/user_validator.go new file mode 100644 index 0000000..b41e25c --- /dev/null +++ b/internal/validator/user_validator.go @@ -0,0 +1,153 @@ +package validator + +import ( + "errors" + "strings" + + "eslogad-be/internal/constants" + "eslogad-be/internal/contract" + + "github.com/google/uuid" +) + +type UserValidatorImpl struct{} + +func NewUserValidator() *UserValidatorImpl { + return &UserValidatorImpl{} +} + +func (v *UserValidatorImpl) ValidateCreateUserRequest(req *contract.CreateUserRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.Email) == "" { + return errors.New("email is required"), constants.MissingFieldErrorCode + } + + if !isValidEmail(req.Email) { + return errors.New("email format is invalid"), constants.MalformedFieldErrorCode + } + + if strings.TrimSpace(req.Password) == "" { + return errors.New("password is required"), constants.MissingFieldErrorCode + } + + if len(req.Password) < 6 { + return errors.New("password must be at least 6 characters"), constants.MalformedFieldErrorCode + } + + if strings.TrimSpace(req.Role) == "" { + return errors.New("role is required"), constants.MissingFieldErrorCode + } + + if !isValidUserRole(req.Role) { + return errors.New("invalid user role"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *UserValidatorImpl) ValidateUpdateUserRequest(req *contract.UpdateUserRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if req.Email == nil && req.Role == nil && req.IsActive == nil { + return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode + } + + if req.Email != nil { + if strings.TrimSpace(*req.Email) == "" { + return errors.New("email cannot be empty"), constants.MalformedFieldErrorCode + } + if !isValidEmail(*req.Email) { + return errors.New("email format is invalid"), constants.MalformedFieldErrorCode + } + } + + if req.Role != nil { + if strings.TrimSpace(*req.Role) == "" { + return errors.New("role cannot be empty"), constants.MalformedFieldErrorCode + } + if !isValidUserRole(*req.Role) { + return errors.New("invalid user role"), constants.MalformedFieldErrorCode + } + } + + return nil, "" +} + +func (v *UserValidatorImpl) ValidateListUsersRequest(req *contract.ListUsersRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.MissingFieldErrorCode + } + + if req.Page <= 0 { + return errors.New("page must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Limit <= 0 { + return errors.New("limit must be greater than 0"), constants.MalformedFieldErrorCode + } + + if req.Limit > 100 { + return errors.New("limit cannot exceed 100"), constants.MalformedFieldErrorCode + } + + if req.Role != nil && !isValidUserRole(*req.Role) { + return errors.New("invalid user role filter"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *UserValidatorImpl) ValidateChangePasswordRequest(req *contract.ChangePasswordRequest) (error, string) { + if req == nil { + return errors.New("request body is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.CurrentPassword) == "" { + return errors.New("current_password is required"), constants.MissingFieldErrorCode + } + + if strings.TrimSpace(req.NewPassword) == "" { + return errors.New("new_password is required"), constants.MissingFieldErrorCode + } + + if len(req.NewPassword) < 8 { + return errors.New("new_password must be at least 8 characters"), constants.MalformedFieldErrorCode + } + + if req.CurrentPassword == req.NewPassword { + return errors.New("new password must be different from current password"), constants.MalformedFieldErrorCode + } + + return nil, "" +} + +func (v *UserValidatorImpl) ValidateUserID(userID uuid.UUID) (error, string) { + if userID == uuid.Nil { + return errors.New("user_id is required"), constants.MissingFieldErrorCode + } + + return nil, "" +} + +func isValidUserRole(role string) bool { + validRoles := map[string]bool{ + string(constants.RoleAdmin): true, + string(constants.RoleManager): true, + string(constants.RoleCashier): true, + string(constants.RoleWaiter): true, + } + return validRoles[role] +} + +func (v *UserValidatorImpl) ValidateUpdateUserOutletRequest(req *contract.UpdateUserOutletRequest) (error, string) { + if req.OutletID == uuid.Nil { + return errors.New("outlet_id is required"), constants.MissingFieldErrorCode + } + + return nil, "" +} diff --git a/internal/validator/validator_helpers.go b/internal/validator/validator_helpers.go new file mode 100644 index 0000000..54a9705 --- /dev/null +++ b/internal/validator/validator_helpers.go @@ -0,0 +1,61 @@ +package validator + +import ( + "errors" + "regexp" + "strings" + + "github.com/go-playground/validator/v10" +) + +func isValidEmail(email string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(email) +} + +func isValidPhone(phone string) bool { + phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) + return phoneRegex.MatchString(phone) +} + +func isValidRole(role string) bool { + validRoles := map[string]bool{ + "admin": true, + "manager": true, + "cashier": true, + } + return validRoles[role] +} + +func isValidPlanType(planType string) bool { + validPlanTypes := map[string]bool{ + "basic": true, + "premium": true, + "enterprise": true, + } + return validPlanTypes[planType] +} + +func formatValidationError(err error) error { + if validationErrors, ok := err.(validator.ValidationErrors); ok { + var errorMessages []string + for _, fieldError := range validationErrors { + switch fieldError.Tag() { + case "required": + errorMessages = append(errorMessages, fieldError.Field()+" is required") + case "email": + errorMessages = append(errorMessages, fieldError.Field()+" must be a valid email") + case "min": + errorMessages = append(errorMessages, fieldError.Field()+" must be at least "+fieldError.Param()) + case "max": + errorMessages = append(errorMessages, fieldError.Field()+" must be at most "+fieldError.Param()) + case "oneof": + errorMessages = append(errorMessages, fieldError.Field()+" must be one of: "+fieldError.Param()) + default: + errorMessages = append(errorMessages, fieldError.Field()+" is invalid") + } + } + return errors.New(strings.Join(errorMessages, "; ")) + } + return err +} diff --git a/migrations/000001_init_db.down.sql b/migrations/000001_init_db.down.sql new file mode 100644 index 0000000..1241c00 --- /dev/null +++ b/migrations/000001_init_db.down.sql @@ -0,0 +1,14 @@ +DROP TABLE IF EXISTS position_roles; +DROP TABLE IF EXISTS user_position; +DROP TABLE IF EXISTS user_department; +DROP TABLE IF EXISTS positions; +DROP TABLE IF EXISTS departments; + +DROP TABLE IF EXISTS role_permissions; +DROP TABLE IF EXISTS user_role; +DROP TABLE IF EXISTS permissions; +DROP TABLE IF EXISTS roles; + +DROP TABLE IF EXISTS users; + +DROP FUNCTION IF EXISTS set_updated_at() CASCADE; diff --git a/migrations/000001_init_db.up.sql b/migrations/000001_init_db.up.sql new file mode 100644 index 0000000..aeea125 --- /dev/null +++ b/migrations/000001_init_db.up.sql @@ -0,0 +1,146 @@ +-- ESLOGAD Core Init (Users, Roles, Permissions, Departments, Positions) + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS ltree; + +-- Helper to auto-update updated_at +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; +RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ======================= +-- USERS +-- ======================= +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255), + email VARCHAR(255) UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active','inactive')), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_login_at TIMESTAMP WITHOUT TIME ZONE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + +CREATE TRIGGER trg_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- ======================= +-- ROLES & PERMISSIONS +-- ======================= +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, -- e.g., SUPERADMIN + code TEXT UNIQUE NOT NULL, -- e.g., superadmin + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); +CREATE TRIGGER trg_roles_updated_at + BEFORE UPDATE ON roles + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, -- e.g., letter.view + description TEXT, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER trg_permissions_updated_at + BEFORE UPDATE ON permissions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS user_role ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + removed_at TIMESTAMP WITHOUT TIME ZONE +) +; +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_role_active + ON user_role(user_id, role_id) WHERE removed_at IS NULL; + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + PRIMARY KEY (role_id, permission_id) + ); + +CREATE TABLE IF NOT EXISTS departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + code TEXT UNIQUE, + path LTREE UNIQUE NOT NULL, -- e.g., eslogad.aslog.waaslog_faskon_bmn + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); +CREATE INDEX IF NOT EXISTS idx_departments_path_gist + ON departments USING GIST (path); + +CREATE TRIGGER trg_departments_updated_at + BEFORE UPDATE ON departments + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, -- e.g., PABAN III/FASKON + code TEXT UNIQUE, -- e.g., paban-III-faskon + path LTREE UNIQUE NOT NULL, -- hierarchy within org chart + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_positions_path_gist + ON positions USING GIST (path); + +CREATE TRIGGER trg_positions_updated_at + BEFORE UPDATE ON positions + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS user_department ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + department_id UUID NOT NULL REFERENCES departments(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + removed_at TIMESTAMP WITHOUT TIME ZONE +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_department_active + ON user_department(user_id, department_id) WHERE removed_at IS NULL; + +CREATE TRIGGER trg_user_department_updated_at + BEFORE UPDATE ON user_department + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS user_position ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + position_id UUID NOT NULL REFERENCES positions(id) ON DELETE CASCADE, + assigned_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + removed_at TIMESTAMP WITHOUT TIME ZONE +); +CREATE UNIQUE INDEX IF NOT EXISTS uq_user_position_active + ON user_position(user_id, position_id) WHERE removed_at IS NULL; + +CREATE TRIGGER trg_user_position_updated_at + BEFORE UPDATE ON user_position + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE IF NOT EXISTS position_roles ( + position_id UUID NOT NULL REFERENCES positions(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (position_id, role_id) +); \ No newline at end of file diff --git a/migrations/000002_seed_user_data.down.sql b/migrations/000002_seed_user_data.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/migrations/000002_seed_user_data.up.sql b/migrations/000002_seed_user_data.up.sql new file mode 100644 index 0000000..600ed67 --- /dev/null +++ b/migrations/000002_seed_user_data.up.sql @@ -0,0 +1,132 @@ +BEGIN; + +-- ========================= +-- Departments (as requested) +-- ========================= +-- Root org namespace is "eslogad" in ltree path +INSERT INTO departments (name, code, path) VALUES + ('RENBINMINLOG', 'renbinminlog', 'eslogad.renbinminlog'), + ('FASKON BMN', 'faskon_bmn', 'eslogad.faskon_bmn'), + ('BEKPALKES', 'bekpalkes', 'eslogad.bekpalkes') + ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + path = EXCLUDED.path, + updated_at = CURRENT_TIMESTAMP; + +-- ========================= +-- Positions (hierarchy) +-- ========================= +-- Conventions: +-- - superadmin is a separate root +-- - eslogad.aslog is head; waaslog_* under aslog +-- - paban_* under each waaslog_*; pabandya_* under its paban_* +INSERT INTO positions (name, code, path) VALUES + -- ROOTS + ('SUPERADMIN', 'superadmin', 'superadmin'), + ('ASLOG', 'aslog', 'eslogad.aslog'), + + -- WAASLOG under ASLOG + ('WAASLOG RENBINMINLOG', 'waaslogrenbinminlog', 'eslogad.aslog.waaslog_renbinminlog'), + ('WAASLOG FASKON BMN', 'waaslogfaskonbmn', 'eslogad.aslog.waaslog_faskon_bmn'), + ('WAASLOG BEKPALKES', 'waaslogbekpalkes', 'eslogad.aslog.waaslog_bekpalkes'), + + -- Other posts directly under ASLOG + ('KADISADAAD', 'kadisadaad', 'eslogad.aslog.kadisadaad'), + ('KATUUD', 'katuud', 'eslogad.aslog.katuud'), + ('SPRI', 'spri', 'eslogad.aslog.spri'), + + -- PABAN under WAASLOG RENBINMINLOG + ('PABAN I/REN', 'paban-I-ren', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren'), + ('PABAN II/BINMINLOG', 'paban-II-binminlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog'), + + -- PABAN under WAASLOG FASKON BMN + ('PABAN III/FASKON', 'paban-III-faskon', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon'), + ('PABAN IV/BMN', 'paban-iv-bmn', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn'), + + -- PABAN under WAASLOG BEKPALKES + ('PABAN V/BEK', 'paban-v-bek', 'eslogad.aslog.waaslog_bekpalkes.paban_V_bek'), + ('PABAN VI/ALPAL', 'paban-vi-alpal', 'eslogad.aslog.waaslog_bekpalkes.paban_VI_alpal'), + ('PABAN VII/KES', 'paban-vii-kes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes'), + + -- PABANDYA under PABAN I/REN + ('PABANDYA 1 / RENPROGGAR', 'pabandya-1-renproggar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_1_renproggar'), + ('PABANDYA 2 / DALWASGAR', 'pabandya-2-dalwasgar', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_2_dalwasgar'), + ('PABANDYA 3 / ANEVDATA', 'pabandya-3-anevdata', 'eslogad.aslog.waaslog_renbinminlog.paban_I_ren.pabandya_3_anevdata'), + + -- PABANDYA under PABAN II/BINMINLOG + ('PABANDYA 1 / MINLOG', 'pabandya-1-minlog', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_1_minlog'), + ('PABANDYA 2 / HIBAHKOD', 'pabandya-2-hibahkod', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_2_hibahkod'), + ('PABANDYA 3 / PUSMAT', 'pabandya-3-pusmat', 'eslogad.aslog.waaslog_renbinminlog.paban_II_binminlog.pabandya_3_pusmat'), + + -- PABANDYA under PABAN IV/BMN + ('PABANDYA 1 / TANAH', 'pabandya-1-tanah', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_tanah'), + ('PABANDYA 2 / PANGKALAN KONSTRUKSI','pabandya-2-pangkalankonstruksi','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_pangkalan_konstruksi'), + ('PABANDYA 3 / FASMATZI', 'pabandya-3-fasmatzi', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_fasmatzi'), + + -- PABANDYA under PABAN IV/BMN (AKUN group) + ('PABANDYA 1 / AKUN BB', 'pabandya-1-akunbb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_1_akun_bb'), + ('PABANDYA 2 / AKUN BTB', 'pabandya-2-akunbtb', 'eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_2_akun_btb'), + ('PABANDYA 3 / SISFO BMN DAN UAKPB-KP','pabandya-3-sisfo-bmn-uakpbkp','eslogad.aslog.waaslog_faskon_bmn.paban_IV_bmn.pabandya_3_sisfo_bmn_uakpb_kp'), + + -- PABANDYA under PABAN III/FASKON + ('PABANDYA 1 / JATOPTIKMU', 'pabandya-1-jatoptikmu', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_1_jatoptikmu'), + ('PABANDYA 2 / RANTEKMEK', 'pabandya-2-rantekmek', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_2_rantekmek'), + ('PABANDYA 3 / ALHUBTOPPALSUS', 'pabandya-3-alhubtoppalsus', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_3_alhubtoppalsus'), + ('PABANDYA 4 / PESUD', 'pabandya-4-pesud', 'eslogad.aslog.waaslog_faskon_bmn.paban_III_faskon.pabandya_4_pesud'), + + -- PABANDYA under PABAN VII/KES + ('PABANDYA 1 / BEKKES', 'pabandya-1-bekkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_1_bekkes'), + ('PABANDYA 2 / ALKES', 'pabandya-2-alkes', 'eslogad.aslog.waaslog_bekpalkes.paban_VII_kes.pabandya_2_alkes') + ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + path = EXCLUDED.path, + updated_at = CURRENT_TIMESTAMP; + +-- ========================= +-- SUPERADMIN role (minimal) +-- ========================= +INSERT INTO roles (name, code, description) VALUES + ('SUPERADMIN', 'superadmin', 'Full system access and management'), + ('ADMIN', 'admin', 'Manage users, letters, and settings within their department'), + ('HEAD', 'head', 'Approve outgoing letters and manage dispositions in their department'), + ('STAFF', 'staff', 'Create letters, process assigned dispositions') + ON CONFLICT (code) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + updated_at = CURRENT_TIMESTAMP; + +-- ========================= +-- Users (seed 1 superadmin) +-- ========================= +-- Replace the plaintext password as needed; pgcrypto hashes it with bcrypt. +INSERT INTO users (username, password_hash, name, email, status, is_active) +VALUES ('superadmin', + crypt('ChangeMe!Super#123', gen_salt('bf')), + 'Super Admin', + 'superadmin@example.com', + 'active', + TRUE) + ON CONFLICT (username) DO UPDATE + SET name = EXCLUDED.name, + email = EXCLUDED.email, + status = EXCLUDED.status, + is_active = EXCLUDED.is_active, + updated_at = CURRENT_TIMESTAMP; + +-- ========================= +-- Link: SUPERADMIN user ↔ role ↔ position +-- ========================= +WITH u AS (SELECT id FROM users WHERE username = 'superadmin'), + r AS (SELECT id FROM roles WHERE code = 'superadmin'), + p AS (SELECT id FROM positions WHERE code = 'superadmin') +INSERT INTO user_role (user_id, role_id) +SELECT u.id, r.id FROM u, r + ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; + +WITH u AS (SELECT id FROM users WHERE username = 'superadmin'), + p AS (SELECT id FROM positions WHERE code = 'superadmin') +INSERT INTO user_position (user_id, position_id) +SELECT u.id, p.id FROM u, p + ON CONFLICT (user_id, position_id) WHERE removed_at IS NULL DO NOTHING; + +COMMIT; diff --git a/migrations/000003_permissions_seeder.down.sql b/migrations/000003_permissions_seeder.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/migrations/000003_permissions_seeder.up.sql b/migrations/000003_permissions_seeder.up.sql new file mode 100644 index 0000000..3f09b1f --- /dev/null +++ b/migrations/000003_permissions_seeder.up.sql @@ -0,0 +1,30 @@ +INSERT INTO permissions (id, code, description, created_at, updated_at) VALUES +-- Users +(gen_random_uuid(), 'user.read', 'View user list and details', now(), now()), +(gen_random_uuid(), 'user.create', 'Create new users', now(), now()), +(gen_random_uuid(), 'user.update', 'Edit existing users', now(), now()), +(gen_random_uuid(), 'user.delete', 'Delete users', now(), now()), + +-- Roles +(gen_random_uuid(), 'role.read', 'View roles', now(), now()), +(gen_random_uuid(), 'role.create', 'Create new roles', now(), now()), +(gen_random_uuid(), 'role.update', 'Edit existing roles', now(), now()), +(gen_random_uuid(), 'role.delete', 'Delete roles', now(), now()), + +-- Permissions +(gen_random_uuid(), 'permission.read', 'View permissions', now(), now()), +(gen_random_uuid(), 'permission.create', 'Create new permissions', now(), now()), +(gen_random_uuid(), 'permission.update', 'Edit existing permissions', now(), now()), +(gen_random_uuid(), 'permission.delete', 'Delete permissions', now(), now()), + +-- Departments +(gen_random_uuid(), 'department.read', 'View departments', now(), now()), +(gen_random_uuid(), 'department.create', 'Create new departments', now(), now()), +(gen_random_uuid(), 'department.update', 'Edit existing departments', now(), now()), +(gen_random_uuid(), 'department.delete', 'Delete departments', now(), now()), + +-- Positions +(gen_random_uuid(), 'position.read', 'View positions', now(), now()), +(gen_random_uuid(), 'position.create', 'Create new positions', now(), now()), +(gen_random_uuid(), 'position.update', 'Edit existing positions', now(), now()), +(gen_random_uuid(), 'position.delete', 'Delete positions', now(), now()); diff --git a/migrations/000004_user_profile.down.sql b/migrations/000004_user_profile.down.sql new file mode 100644 index 0000000..b48a59a --- /dev/null +++ b/migrations/000004_user_profile.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_profiles; diff --git a/migrations/000004_user_profile.up.sql b/migrations/000004_user_profile.up.sql new file mode 100644 index 0000000..ff1b2ae --- /dev/null +++ b/migrations/000004_user_profile.up.sql @@ -0,0 +1,30 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS user_profiles ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + full_name VARCHAR(150) NOT NULL, + display_name VARCHAR(100), + phone VARCHAR(50), + avatar_url TEXT, + job_title VARCHAR(120), + employee_no VARCHAR(60), + bio TEXT, + timezone VARCHAR(64) DEFAULT 'Asia/Jakarta', + locale VARCHAR(16) DEFAULT 'id-ID', + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + notification_prefs JSONB NOT NULL DEFAULT '{}'::jsonb, + last_seen_at TIMESTAMP WITHOUT TIME ZONE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_profiles_phone ON user_profiles(phone); +CREATE INDEX IF NOT EXISTS idx_user_profiles_employee_no ON user_profiles(employee_no); +CREATE INDEX IF NOT EXISTS idx_user_profiles_prefs_gin ON user_profiles USING GIN (preferences); +CREATE INDEX IF NOT EXISTS idx_user_profiles_notif_gin ON user_profiles USING GIN (notification_prefs); + +CREATE TRIGGER trg_user_profiles_updated_at + BEFORE UPDATE ON user_profiles + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +COMMIT; diff --git a/migrations/000005_title_table.down.sql b/migrations/000005_title_table.down.sql new file mode 100644 index 0000000..e69de29 diff --git a/migrations/000005_title_table.up.sql b/migrations/000005_title_table.up.sql new file mode 100644 index 0000000..973039d --- /dev/null +++ b/migrations/000005_title_table.up.sql @@ -0,0 +1,60 @@ +-- ======================= +-- TITLES +-- ======================= +CREATE TABLE IF NOT EXISTS titles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, -- e.g., "Senior Software Engineer" + code TEXT UNIQUE, -- e.g., "senior-software-engineer" + description TEXT, -- optional: extra details + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + +-- Trigger for updated_at +CREATE TRIGGER trg_titles_updated_at + BEFORE UPDATE ON titles + FOR EACH ROW + EXECUTE FUNCTION set_updated_at(); + +-- Perwira Tinggi (High-ranking Officers) +INSERT INTO titles (name, code, description) VALUES + ('Jenderal', 'jenderal', 'Pangkat tertinggi di TNI AD'), + ('Letnan Jenderal', 'letnan-jenderal', 'Pangkat tinggi di bawah Jenderal'), + ('Mayor Jenderal', 'mayor-jenderal', 'Pangkat tinggi di bawah Letnan Jenderal'), + ('Brigadir Jenderal', 'brigadir-jenderal', 'Pangkat tinggi di bawah Mayor Jenderal'); + +-- Perwira Menengah (Middle-ranking Officers) +INSERT INTO titles (name, code, description) VALUES + ('Kolonel', 'kolonel', 'Pangkat perwira menengah tertinggi'), + ('Letnan Kolonel', 'letnan-kolonel', 'Pangkat perwira menengah di bawah Kolonel'), + ('Mayor', 'mayor', 'Pangkat perwira menengah di bawah Letnan Kolonel'); + +-- Perwira Pertama (Junior Officers) +INSERT INTO titles (name, code, description) VALUES + ('Kapten', 'kapten', 'Pangkat perwira pertama tertinggi'), + ('Letnan Satu', 'letnan-satu', 'Pangkat perwira pertama di bawah Kapten'), + ('Letnan Dua', 'letnan-dua', 'Pangkat perwira pertama di bawah Letnan Satu'); + +-- Bintara Tinggi (Senior NCOs) +INSERT INTO titles (name, code, description) VALUES + ('Pembantu Letnan Satu', 'pembantu-letnan-satu', 'Pangkat bintara tinggi tertinggi'), + ('Pembantu Letnan Dua', 'pembantu-letnan-dua', 'Pangkat bintara tinggi di bawah Pelda'); + +-- Bintara (NCOs) +INSERT INTO titles (name, code, description) VALUES + ('Sersan Mayor', 'sersan-mayor', 'Pangkat bintara di bawah Pelda'), + ('Sersan Kepala', 'sersan-kepala', 'Pangkat bintara di bawah Serma'), + ('Sersan Satu', 'sersan-satu', 'Pangkat bintara di bawah Serka'), + ('Sersan Dua', 'sersan-dua', 'Pangkat bintara di bawah Sertu'); + +-- Tamtama Tinggi (Senior Enlisted) +INSERT INTO titles (name, code, description) VALUES + ('Kopral Kepala', 'kopral-kepala', 'Pangkat tamtama tinggi tertinggi'), + ('Kopral Satu', 'kopral-satu', 'Pangkat tamtama tinggi di bawah Kopka'), + ('Kopral Dua', 'kopral-dua', 'Pangkat tamtama tinggi di bawah Koptu'); + +-- Tamtama (Enlisted) +INSERT INTO titles (name, code, description) VALUES + ('Prajurit Kepala', 'prajurit-kepala', 'Pangkat tamtama di bawah Kopda'), + ('Prajurit Satu', 'prajurit-satu', 'Pangkat tamtama di bawah Prada'), + ('Prajurit Dua', 'prajurit-dua', 'Pangkat tamtama terendah');